C++

Еліпсис

Вивчіть еліпсис (три крапки ...) у C++ — механізм функцій зі змінною кількістю аргументів. Синтаксис va_list/va_start/va_arg/va_end, три способи відстеження кількості аргументів, небезпеки та сучасні альтернативи.

Еліпсис

Ідея: функції зі змінною кількістю аргументів

До цього моменту усі функції, що ми писали, мали фіксовану кількість параметрів. Навіть якщо деякі з них мали значення за замовчуванням — їх точна кількість визначалася при оголошенні. Але уявіть задачу: написати функцію findMax, що знаходить максимум з будь-якої кількості переданих чисел — двох, трьох, десяти.

Наївне рішення — зробити кілька перевантажень:

int findMax(int a, int b);
int findMax(int a, int b, int c);
int findMax(int a, int b, int c, int d);
// ... і т.д. до нескінченності

Це, очевидно, не масштабується. Саме для таких задач у C++ (успадкований з мови C) існує механізм еліпсиса — три крапки ..., що дозволяють передати довільну кількість аргументів.

Передумови. Розуміння вказівників, масивів і базового синтаксису функцій. Еліпсис — це успадкований з мови C механізм, тому він є частиною «низькорівневого» C++ і несе відповідні ризики.
Важливо наперед: еліпсис є небезпечним механізмом і у сучасному C++ практично не використовується у новому коді. Проте він присутній у великій кількості старих C і C++ бібліотек (включно із самим printf), тому розуміти його необхідно. Ми вивчаємо його, щоб читати існуючий код, а не писати новий.

Синтаксис: як виглядає функція з еліпсисом

Загальна форма функції з еліпсисом:

тип_повернення ім'я_функції(обов'язкові_параметри, ...)

Правила:

  • Еліпсис (...) завжди є останнім параметром.
  • До нього обов'язково має бути хоча б один звичайний параметр — компілятор вимагає цього для роботи va_start.
  • Аргументи за еліпсисом не мають типів у сигнатурі — компілятор їх не перевіряє.

Найпростіший приклад оголошення:

// count — обов'язковий параметр; ... — решта аргументів
double findAverage(int count, ...);

// Перший — фіксований; ... — решта
int findMax(int first, ...);

// format — рядок-декодер; ... — дані
void myPrint(const char* format, ...);

Механізм: va_list, va_start, va_arg, va_end

Доступ до аргументів еліпсиса здійснюється через чотири макроси з заголовку <cstdarg>:

МакросПризначення
va_list listОголошення «вказівника» на список аргументів
va_start(list, lastParam)Ініціалізація: вказати, що list починається після lastParam
va_arg(list, Type)Зчитати поточний аргумент як тип Type і перейти до наступного
va_end(list)Очищення — обов'язково викликати перед виходом з функції

Розглянемо покроково, як це працює на прикладі обчислення середнього арифметичного:

FindAverage.cpp
#include <iostream>
#include <cstdarg>  // обов'язково для va_list та ін.

// count — кількість аргументів, що підуть за ним в "..."
double findAverage(int count, ...)
{
    double sum = 0;

    // Крок 1: оголошуємо va_list — "вказівник" на список змінних аргументів
    va_list list;

    // Крок 2: ініціалізуємо va_list
    // Перший аргумент — наш va_list
    // Другий аргумент — ОСТАННІЙ ЗВИЧАЙНИЙ параметр (перед "...")
    va_start(list, count);

    // Крок 3: зчитуємо аргументи по одному
    for (int i = 0; i < count; ++i)
    {
        // va_arg зчитує поточний аргумент як тип int і переходить до наступного
        sum += va_arg(list, int);
    }

    // Крок 4: ОБОВ'ЯЗКОВЕ очищення
    va_end(list);

    return sum / count;
}

int main()
{
    std::cout << findAverage(3, 10, 20, 30)     << '\n'; // 20
    std::cout << findAverage(5, 1, 2, 3, 4, 5)  << '\n'; // 3
    std::cout << findAverage(2, 100, 200)        << '\n'; // 150

    return 0;
}
./FindAverage
$ ./FindAverage
20
3
150

Детальний розбір кожного кроку

Рядок 10. va_list list; — оголошення об'єкту типу va_list. Технічно va_list — це тип, що описує стек аргументів функції. Його можна уявити як «вказівник всередину пам'яті стеку», що вказує на поточний аргумент. Імʼя list — довільне, але загальноприйняте.

Рядок 15. va_start(list, count); — ініціалізація. Другий аргумент count — це ім'я останнього звичайного параметра функції (перед ...). Компілятор за допомогою адреси цього параметра знаходить початок «прихованих» аргументів на стеку.

Якщо другий аргумент va_start не є останнім звичайним параметром, поведінка невизначена. Потрібно завжди вказувати саме той параметр, що стоїть безпосередньо перед ....

Рядок 22. va_arg(list, int); — зчитування одного аргументу. Перший параметр — наш va_list, другий — тип, до якого треба перетворити поточний аргумент. Після виклику va_list «просувається» до наступного аргументу — це не просто зчитування, це й ітерація.

Рядок 25. va_end(list); — обов'язкове очищення. На більшості платформ це просто маркерна операція, але стандарт вимагає її виклику. Без неї — невизначена поведінка при виході з функції.


Три способи відстеження кількості аргументів

Ключова проблема еліпсиса: сама функція не знає, скільки аргументів було передано. Стандарт C++ пропонує три підходи до вирішення цієї проблеми — і кожен має свої недоліки.

Спосіб 1: параметр-кількість

Передаємо кількість аргументів явно як перший параметр. Це найпоширеніший підхід — власне, так ми зробили вище з findAverage(int count, ...).

Method1Count.cpp
#include <iostream>
#include <cstdarg>

int findMax(int count, ...)
{
    va_list list;
    va_start(list, count);

    int maxVal = va_arg(list, int); // читаємо перший як початкове максимальне

    for (int i = 1; i < count; ++i)
    {
        int current = va_arg(list, int);
        if (current > maxVal)
            maxVal = current;
    }

    va_end(list);
    return maxVal;
}

int main()
{
    std::cout << findMax(4, 7, 2, 15, 3)  << '\n'; // 15
    std::cout << findMax(3, 100, 50, 200) << '\n'; // 200

    // ❌ Помилка програміста: count=4, але передано лише 3 числа
    // std::cout << findMax(4, 7, 2, 15) << '\n'; // UB: читаємо 4-й аргумент зі сміття стеку

    return 0;
}
./Method1Count
$ ./Method1Count
15
200

Недолік: якщо count не відповідає реальній кількості аргументів — читаємо зі стеку сміття або пропускаємо аргументи. Компілятор про це не попередить.


Спосіб 2: контрольне значення (sentinel)

Замість лічильника — спеціальне «фінальне» значення (sentinel), що сигналізує про кінець списку. Наприклад, -1 для списків невід'ємних цілих або nullptr для списків вказівників.

Method2Sentinel.cpp
#include <iostream>
#include <cstdarg>

// Суммує числа до тих пір, поки не побачить -1
// first — перше число (обов'язковий параметр), решта — в еліпсисі
int sumUntilSentinel(int first, ...)
{
    int total = first; // перше число обробляємо окремо
    int count = 1;

    va_list list;
    va_start(list, first);

    while (true)
    {
        int current = va_arg(list, int);

        if (current == -1) // контрольне значення — зупиняємось
            break;

        total += current;
        ++count;
    }

    va_end(list);

    std::cout << "(sum of " << count << " numbers) ";
    return total;
}

int main()
{
    std::cout << sumUntilSentinel(1, 2, 3, 4, -1)    << '\n'; // 10
    std::cout << sumUntilSentinel(10, 20, 30, -1)    << '\n'; // 60
    std::cout << sumUntilSentinel(5, -1)             << '\n'; // 5 (лише одне число)

    // ❌ Забули -1 в кінці — нескінченний цикл + UB:
    // std::cout << sumUntilSentinel(1, 2, 3) << '\n';

    return 0;
}
./Method2Sentinel
$ ./Method2Sentinel
(sum of 4 numbers) 10
(sum of 3 numbers) 60
(sum of 1 numbers) 5

Недолік: якщо забути передати sentinel — цикл продовжується, читаючи зі стеку сміття, аж до UB або збою. Sentinel-значення не може бути водночас валідним аргументом (якщо -1 є контрольним, то ніяке від'ємне число не можна передати).


Спосіб 3: рядок-формат (format string)

Передаємо рядок, де кожен символ вказує тип чергового аргументу. Саме так влаштована стандартна функція printf: %d для int, %f для double, %s для рядка і т.д.

Method3Format.cpp
#include <iostream>
#include <cstdarg>

// fmt — рядок-декодер: 'i' = int, 'd' = double
// повертає суму всіх чисел (незалежно від типу)
double sumMixed(const char* fmt, ...)
{
    va_list list;
    va_start(list, fmt);

    double total = 0;
    int index = 0;

    while (fmt[index] != '\0') // проходимо по рядку-форматі
    {
        if (fmt[index] == 'i')
        {
            total += va_arg(list, int);
        }
        else if (fmt[index] == 'd')
        {
            total += va_arg(list, double);
        }
        // інші символи — ігноруємо або помилка
        ++index;
    }

    va_end(list);
    return total;
}

int main()
{
    // "iii" — три int
    std::cout << sumMixed("iii", 1, 2, 3) << '\n'; // 6

    // "iidi" — int, int, double, int
    std::cout << sumMixed("iidi", 10, 20, 3.5, 5) << '\n'; // 38.5

    // "dd" — два double
    std::cout << sumMixed("dd", 1.5, 2.5) << '\n'; // 4

    return 0;
}
./Method3Format
$ ./Method3Format
6
38.5
4

Рядок 15. while (fmt[index] != '\0') — рядок у C/C++ завжди завершується нуль-символом '\0'. Ми читаємо символ за символом, поки не натрапимо на нього — це природна «зупинка», яка не потребує окремого лічильника.

Недолік: якщо кількість або типи аргументів не збігаються з форматним рядком — UB. Ніякої перевірки під час компіляції немає.


Чому еліпсис небезпечний: детальний розбір

Небезпека 1: відсутність перевірки типів

Розглянемо найпростішу пастку:

TypeMismatch.cpp
#include <iostream>
#include <cstdarg>

double findAverage(int count, ...)
{
    double sum = 0;
    va_list list;
    va_start(list, count);

    for (int i = 0; i < count; ++i)
        sum += va_arg(list, int); // очікуємо лише int

    va_end(list);
    return sum / count;
}

int main()
{
    // ✅ Нормально: усі аргументи — int
    std::cout << findAverage(3, 10, 20, 30) << '\n'; // 20

    // ❌ НЕБЕЗПЕЧНО: передаємо double замість int
    std::cout << findAverage(3, 10, 2.5, 30) << '\n'; // UB: сміття!

    return 0;
}

Що відбувається при передачі 2.5 замість int? double займає 8 байт, а int4 байти. va_arg(list, int) читає лише перші 4 байти репрезентації числа 2.5 і трактує їх як int. Решту 4 байтів буде прочитано при наступному виклику va_arg — що зіпсує наступний аргумент.

Loading diagram...
graph TD
    A["Пам'ять стеку: [10:int 4B] [2.5:double 8B] [30:int 4B]"]
    A --> B["va_arg(list, int) #1: читає 4B → 10 ✅"]
    B --> C["va_arg(list, int) #2: читає 4B (перша половина 2.5) → сміття ❌"]
    C --> D["va_arg(list, int) #3: читає 4B (друга половина 2.5) → сміття ❌"]
    D --> E["30 взагалі не прочитане — залишається на стеку"]

    style B fill:#22c55e,color:#fff
    style C fill:#ef4444,color:#fff
    style D fill:#ef4444,color:#fff
    style E fill:#ef4444,color:#fff

Компілятор не попередить про невідповідність типів — він просто передасть байти у стек. Уся відповідальність за типову правильність лежить на програмісті.

Небезпека 2: невідповідність кількості аргументів

Якщо count більший за реальну кількість аргументів — читаємо зі стеку сміття:

CountMismatch.cpp
// ❌ count=4, але передано лише 3 числа
std::cout << findAverage(4, 10, 20, 30) << '\n'; // читає "30" + next stack garbage
// Можливий вивід: 23.5 (або будь-що)

// ❌ count=2, але передано 4 числа — зайві ігноруються без попередження
std::cout << findAverage(2, 10, 20, 30, 40) << '\n'; // виводить 15 — 30 і 40 ігноруються

У першому випадку четвертим «аргументом» стане довільне значення зі стеку. У другому — функція відпрацює «правильно», але тихо проігнорує два аргументи. Жоден із цих випадків не є помилкою компіляції.

Небезпека 3: проблема малих типів

У більшості компіляторів va_arg не підтримує типи менші за int. Тому:

// ❌ UB: char менший за int в контексті va_arg
char ch = va_arg(list, char);  // НЕБЕЗПЕЧНО

// ✅ Правильно: отримати int і перетворити в char
char ch = static_cast<char>(va_arg(list, int)); // безпечно

Аргументи типу char і short автоматично просуваються до int при передачі через еліпсис — такі правила мовного стандарту. Тому й зчитувати їх потрібно як int.


Зведення: всі небезпеки еліпсиса

Немає перевірки типів

Компілятор ніяк не перевіряє типи аргументів. Передати double замість int — тихе UB. Читаємо «сміттєві» байти без жодного попередження.

Немає перевірки кількості

Функція не знає, скільки аргументів насправді передано. Будь-який механізм відстеження (кількість, sentinel, формат) реалізується вручну і є потенційним джерелом помилок.

Малі типи просуваються

char, short, float автоматично просуваються до int/double. Зчитувати їх потрібно через більший тип + static_cast. Забув — UB.

Не працює з об'єктами C++

Передача об'єктів класів у еліпсис є невизначеною поведінкою, якщо клас має нетривіальний конструктор копіювання або деструктор. Еліпсис — механізм чистого C.

Рекомендації та сучасні альтернативи


Практичні завдання

Завдання

Рівень 1 — Базовий

Завдання 1. Що виведе код нижче? Поясніть по кроках, що відбувається з va_list:

#include <cstdarg>
#include <iostream>

int sumThree(int a, ...)
{
    va_list list;
    va_start(list, a);

    int b = va_arg(list, int);
    int c = va_arg(list, int);

    va_end(list);
    return a + b + c;
}

int main()
{
    std::cout << sumThree(1, 2, 3) << '\n';
    std::cout << sumThree(10, 20, 30) << '\n';
}

Завдання 2. Напишіть функцію int sumAll(int count, ...), що підсумовує count цілих чисел. Протестуйте: sumAll(3, 1, 2, 3) → 6, sumAll(5, 10, 20, 30, 40, 50) → 150.

Завдання 3. Поясніть, що відбудеться (і чому) при виклику:

double findAverage(int count, ...);
// Виклик:
std::cout << findAverage(3, 1.5, 2.5, 3.5) << '\n'; // double замість int!

Рівень 2 — Логіка

Завдання 4. Реалізуйте функцію int findMin(int count, ...), що знаходить мінімум серед count цілих чисел. Протестуйте: findMin(5, 7, 2, 14, 1, 9) → 1.

Завдання 5. Реалізуйте варіант findAverage зі sentinel-значенням 0 (нуль означає кінець): findAverageSentinel(10, 30, 20, 0) → 20. Що є фундаментальним обмеженням такого підходу (яке число не можна буде включити у список)?

Завдання 6. Перепишіть double findAverage(int count, ...) без еліпсиса — використовуючи масив int* і параметр int size. Порівняйте обидва підходи: що ви виграли і що «втратили» в зручності API?

Рівень 3 — Аналіз

Завдання 7. Напишіть функцію void printValues(const char* fmt, ...), де fmt — рядок-формат:

  • 'i' → зчитати int і вивести
  • 'd' → зчитати double і вивести
  • 'c' — зчитати int і вивести як char (через static_cast<char>)
  • Розделяти виводи пробілом

Протестуйте: printValues("idc", 42, 3.14, 65)42 3.14 A


Підсумок

Синтаксис

тип функція(param, ...)... завжди останнім. Обов'язково підключити <cstdarg>. va_start → цикл va_argva_end.

Три способи обмеження

Count: findAverage(4, ...) — найчастіший. Sentinel: sumUntil(-1) — контрольне значення. Format: print("iid", ...) — як printf. Усі три мають вразливості.

Небезпеки

Немає перевірки типів. Немає перевірки кількості. char/short/float просуваються. Об'єкти C++ — UB. Компілятор мовчить.

У новому коді

Замість ... — масив + розмір, або std::initializer_list. Еліпсис вивчаємо, щоб читати старий сод, не писати новий.

У наступній статті ми розглянемо аргументи командного рядка — встроєний механізм C++ для передачі параметрів програмі при її запуску.