Урок №111. Эллипсис

  Юрий  | 

  |

  Обновл. 4 Сен 2021  | 

 48548

 ǀ   13 

Во всех функциях, которые мы рассматривали до этого момента, количество их параметров должно было быть известно заранее (даже если это параметры по умолчанию). Однако есть несколько случаев, в которых полезно передать переменную, указывающую на количество параметров, в функцию. В языке C++ есть специальный объект, который позволяет это сделать — эллипсис.

Эллипсис

Функции, использующие эллипсис, выглядят следующим образом:

тип_возврата имя_функции(список_аргументов, ...)

список_аргументов — это один или несколько обычных параметров функции. Обратите внимание, функции, которые используют эллипсис, должны иметь по крайней мере один параметр, который не является эллипсисом.

Эллипсис (англ. «ellipsis»), который представлен в виде многоточия в языке C++, всегда должен быть последним параметром в функции. О нем можно думать, как о массиве, который содержит любые другие параметры, кроме тех, которые указаны в список_аргументов.

Рассмотрим пример с использованием эллипсиса. Предположим, что нам нужно написать функцию, которая вычисляет среднее арифметическое переданных аргументов:

Результат выполнения программы:

2.5
3

Как вы можете видеть, функция findAverage() принимает переменную count, которая указывает на количество передаваемых аргументов. Рассмотрим другие компоненты этого примера.

Во-первых, мы должны подключить заголовочный файл cstdarg. Этот заголовок определяет va_list, va_start и va_end — макросы, необходимые для доступа к параметрам, которые являются частью эллипсиса.

Затем мы объявляем функцию, которая использует эллипсис. Помните, что список_аргументов должен быть представлен одним или несколькими фиксированными параметрами. Здесь мы передаем одно целочисленное значение, которое сообщает функции, сколько будет параметров.

Обратите внимание, в эллипсисе нет никаких имен переменных! Вместо этого мы получаем доступ к значениям через специальный тип — va_list. О va_list можно думать, как об указателе, который указывает на массив с эллипсисом. Сначала мы объявляем переменную va_list, которую называем просто list для удобства использования.

Затем нам нужно, чтобы list указывал на параметры эллипсиса. Делается это с помощью va_start(), который имеет два параметра: va_list и имя последнего параметра, который не является эллипсисом. После того, как va_start() был вызван, va_list указывает на первый параметр из списка передаваемых аргументов.

Чтобы получить значение параметра, на который указывает va_list, нужно использовать va_arg(), который также имеет два параметра: va_list и тип данных параметра, к которому мы пытаемся получить доступ. Обратите внимание, с помощью va_arg() мы также переходим к следующему параметру va_list!

Наконец, когда мы уже всё сделали, нужно выполнить очистку: va_end() с параметром va_list.

Почему эллипсис небезопасен?


Эллипсис предоставляет программисту большую гибкость для реализации функций, которые принимают переменную, указывающую на общее количество параметров. Однако эта гибкость имеет свои недостатки.

С обычными параметрами функции компилятор использует проверку типов для гарантирования того, что типы аргументов функции соответствуют типам параметров функции (или аргументы могут быть неявно преобразованы для дальнейшего соответствия). Это делается с целью предотвращения случаев, когда вы передаете в функцию целочисленное значение, тогда как она ожидает строку (или наоборот). Обратите внимание, параметры эллипсиса не имеют объявлений типа данных. При их использовании компилятор полностью пропускает проверку типов данных. Это означает, что можно отправить аргументы любого типа в многоточии, и компилятор не сможет предупредить вас, что это произошло. В конечном итоге, мы получим сбой или неверные результаты. При использовании эллипсиса вся ответственность ложится на caller, и от него зависит корректность переданных аргументов в функцию. Очевидно, что это является хорошей лазейкой для возникновения ошибок. Рассмотрим пример такой ошибки:

Хотя на первый взгляд всё может показаться достаточно безвредным, но посмотрите на второй аргумент типа double — он должен быть типа int. Хотя всё скомпилируется без ошибок, но результат следующий:

1.78782e+08

Число не маленькое. Как это произошло?

Как мы уже знаем из предыдущих уроков, компьютер сохраняет все данные в виде последовательности бит. Тип переменной указывает компьютеру, как перевести эту последовательность бит в определенное (читабельное) значение. Однако в эллипсисе тип переменной отбрасывается. Следовательно, единственный способ получить нормальное значение обратно из эллипсиса — вручную указать va_arg(), каков ожидаемый тип параметра. Это то, что делает второй параметр в va_arg(). Если фактический тип параметра не соответствует ожидаемому типу параметра, то происходят плохие вещи.

В программе, приведенной выше, с помощью va_arg(), мы указали, что все параметры должны быть типа int. Следовательно, каждый вызов va_arg() будет возвращать последовательность бит, которая будет конвертирована в тип int.

В этом случае проблема заключается в том, что значение типа double, которое мы передали в качестве первого аргумента эллипсиса, занимает 8 байт, тогда как va_arg(list, int) возвращает только 4 байта данных при каждом вызове (тип int занимает 4 байта). Следовательно, первый вызов va_arg возвращает первую часть типа double (4 байта), а второй вызов va_arg возвращает вторую часть типа double (еще 4 байта). Итого, в результате получаем мусор.

Поскольку проверка типов пропущена, то компилятор даже не будет жаловаться, если мы сделаем что-то вообще дикое, например:

Верите или нет, но это действительно скомпилировалось без ошибок и выдало следующий результат на моем компьютере:

1.56805e+08

Этот результат подтверждает фразу: «Мусор на входе, мусор на выходе».

Мало того, что эллипсис отбрасывает тип параметров, он также отбрасывает и количество этих параметров. Это означает, что нам нужно будет самим разработать решение для отслеживания количества параметров, передаваемых в эллипсис. Как правило, это делается одним из следующих трех способов:

Способ №1: Передать параметр-длину. Нужно, чтобы один из фиксированных параметров, не входящих в эллипсис, отображал количество переданных параметров. Это решение использовалось в программе, приведенной выше. Однако даже здесь мы столкнемся с проблемами, например:

Результат на моем компьютере:

4.16667

Что случилось? Мы сообщили findAverage(), что собираемся передать 6 значений, но фактически передали только 5. Следовательно, с первыми пятью значениями, возвращаемыми va_arg() — всё ок. Но вот 6-е значение, которое возвращает va_arg() — это просто мусор из стека, так как мы его не передавали. Следовательно, таков и результат. По крайней мере, здесь очевидно, что это значение является мусором. А вот рассмотрим более коварный случай:

Результат:

3.5

На первый взгляд всё корректно, но последнее число (7) в списке аргументов игнорируется, так как мы сообщили, что собираемся предоставить 6 параметров (а предоставили 7). Такие ошибки бывает довольно-таки трудно обнаружить.

Способ №2: Использовать контрольное значение. Контрольное значение — это специальное значение, которое используется для завершения цикла при его обнаружении. Например, нуль-терминатор используется в строках для обозначения конца строки. В эллипсисе контрольное значение передается последним из аргументов. Вот программа, приведенная выше, но уже с использованием контрольного значения -1:

Обратите внимание, нам уже не нужно явно передавать длину в качестве первого параметра. Вместо этого мы передаем контрольное значение в качестве последнего параметра.

Однако здесь также есть нюансы. Во-первых, язык C++ требует, чтобы мы передавали хотя бы один фиксированный параметр. В предыдущем примере для этого использовалась переменная count. В этом примере первое значение является частью чисел, используемых в вычислении. Поэтому, вместо обработки первого значения в паре с другими параметрами эллипсиса, мы явно объявляем его как обычный параметр. Затем нам нужно это обработать внутри функции (мы присваиваем переменной sum значение first, а не 0, как в предыдущей программе).

Во-вторых, требуется, чтобы пользователь передал контрольное значение последним в списке. Если пользователь забудет передать контрольное значение (или передаст неправильное), то функция будет циклически работать до тех пор, пока не дойдет до значения, которое будет соответствовать контрольному (которое не было указано), т.е. мусору (или произойдет сбой).

Способ №3: Использовать строку-декодер. Передайте строку-декодер в функцию, чтобы сообщить, как правильно интерпретировать параметры:

В этом примере мы передаем строку, в которой указывается как количество передаваемых аргументов, так и их типы (i = int, d = double). Таким образом, мы можем работать с параметрами разных типов. Однако следует помнить, что если число или типы передаваемых параметров не будут в точности соответствовать тому, что указано в строке-декодере, то могут произойти плохие вещи.

Рекомендации по безопасному использованию эллипсиса

Во-первых, если это возможно, не используйте эллипсис вообще! Часто доступны другие разумные решения, даже если они требуют немного больше работы и времени. Например, в функции findAverage() в вышеприведенной программе мы могли бы передать динамически выделенный массив целых чисел, вместо использования эллипсиса. Это бы обеспечило проверку типов (гарантируя, что caller не попытается сделать что-то бессмысленное), сохраняя при этом возможность передавать переменную-длину, которая бы указывала на количество всех передаваемых значений.

Во-вторых, если вы используете эллипсис, не смешивайте разные типы аргументов в пределах вашего эллипсиса, если это возможно. Это уменьшит вероятность того, что caller случайно передаст данные не того типа, а va_arg() произведет результат-мусор.

В-третьих, использование параметра count или строки-декодера в качестве список_аргументов обычно безопаснее, чем использование контрольного значения. Это гарантирует, что цикл эллипсиса будет завершен после четко определенного количества итераций.


Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (252 оценок, среднее: 4,92 из 5)
Загрузка...

Комментариев: 13

  1. Дмитрий:

    Однако даже здесь мы столкнемся с проблемами, например:

    Результат на моем компьютере:

    4.16667

    Что случилось? Мы сообщили findAverage(), что собираемся передать 6 значений, но фактически передали только 5.

    Каким образом мы передали только 5 значений?

    1. Павел:

      В данном способе первое число (которое принимается в int count) означает количество аргументов, которое мы собираемся использовать нашим эллипсисом.

      Сам же эллипсис (…) в свой list принимает все что идет после.

      Таким образом здесь имеется ввиду что мы собирались передать 6 значений для эллипсиса.

      1. Дмитрий:

        Спасибо за пояснения. Автору этих уроков замечание — такие вещи надо объяснять.

        1. 4b6rat0r:

          Приветствую, пояснение как раз описано выше строчек с кодом.

          Способ №1: Передать параметр-длину. Нужно, чтобы один из фиксированных параметров, не входящих в эллипсис, отображал количество переданных параметров. Это решение использовалось в программе, приведенной выше.

          Таким образом автор показал, что первый параметр — длина входящего эллипса.

  2. Ольга:

    В третьем способе строку-декодер не хочет типа std::string (код ошибки С4840), с const char* работает.

  3. Валера:

    Аналог функции printf:

  4. Евгений:

    непонятно, как мы итерируем по аргументам через va arg — как мы получаем следующий элемент? второй, третий

    1. Максим:

      "Чтобы получить значение параметра, на который указывает va_list, нужно использовать va_arg(), который также имеет два параметра: va_list и тип данных параметра, к которому мы пытаемся получить доступ.
      Обратите внимание, с помощью va_arg() мы также переходим к следующему параметру va_list!"

      1. Fray:

        Полагаю он имел ввиду вопрос, связанный со свободным доступом к списку параметров, которые находятся в эллипсисе. А раз эллипсис это некий массив и переменная arg никак не задействована в отношении доступа к этому массиву, невольно задаешься вопросом: а смысл тогда этой переменной?

  5. Алексей:

    Статья хорошая, но стоило бы ее продолжить и написать, что одним из самых частых примером эллипсиса является printf, sprintf и др. И что их можно сделать безопасными с помощью __attribute__ ((format (printf, x, y)))

  6. Алексей:

    Я так понимаю, если эллипсис существует, следовательно он нужен для каких-то конкретных ситуаций, где динамические массивы использовать затруднительно или невозможно? Можете, пожалуйста привести примеры такого использования?

    1. Евгений Павлов:

      Эллипсис появился еще до C++, еще до создания динамических массивов. В C++11 появилась чуть более безопасная версия эллипсиса: Вариативный шаблон.

    2. Steindvart:

      Как верно было подмечено в комментарии выше — эллипсис был задолго до С++. А именно в языке Си. Там нет шаблонов, классов, перегрузки функций и прочих прелестей. Но наличие функций общего назначения, которые смогут работать с переменным количеством параметров было необходимо, поэтому и придумали такую конструкцию. Для обратной совместимости, С++ сохранил эту конструкцию.

      Самый распространённый пример использования — это Сишные функции ввода-вывода:
      int printf(const char *format, ...)
      int sprintf(char *buf, const char *format, ...)
      int scanf(const char *format, ...)

Добавить комментарий

Ваш E-mail не будет опубликован. Обязательные поля помечены *