11.12 – Многоточия (и почему их следует избегать)

Добавлено 17 июня 2021 в 05:56

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

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

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

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

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

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

Пример многоточия

Лучший способ узнать о многоточии – это рассмотреть пример. Итак, давайте напишем простую программу, в которой используется многоточие. Допустим, мы хотим написать функцию, которая вычисляет среднее значение группы целых чисел. Мы бы сделали это так:

#include <iostream>
#include <cstdarg> // необходимо для использования многоточия
 
// Многоточие должно быть последним параметром
// count - это количество дополнительных аргументов, которые мы передаем
double findAverage(int count, ...)
{
    double sum{ 0 };
 
    // Мы получаем доступ к многоточию через va_list, поэтому давайте объявим его
    va_list list;
 
    // Мы инициализируем va_list с помощью va_start. Первый параметр
    // - список для инициализации. Второй параметр - это последний
    // параметр перед многоточием.
    va_start(list, count);
 
    // Перебираем все аргументы многоточия
    for (int arg{ 0 }; arg < count; ++arg)
    {
         // Мы используем va_arg для получения параметров из нашего многоточия
         // Первый параметр - это va_list, который мы используем
         // Второй параметр - это тип параметра
         sum += va_arg(list, int);
    }
 
    // Очищаем va_list, когда закончили.
    va_end(list);
 
    return sum / count;
}
 
int main()
{
    std::cout << findAverage(5, 1, 2, 3, 4, 5) << '\n';
    std::cout << findAverage(6, 1, 2, 3, 4, 5, 6) << '\n';
}

Этот код печатает:

3
3.5

Как видите, эта функция принимает переменное количество параметров! Теперь давайте посмотрим на компоненты, из которых состоит этот пример.

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

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

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

Следующее, что нам нужно, это сделать так, чтобы список указывал на наши параметры многоточия. Мы делаем это, вызывая va_start(). va_start() принимает два параметра: сам va_list и имя последнего параметра функции, идущего перед многоточием. После вызова va_start() va_list указывает на первый параметр в многоточии.

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

Наконец, для очистки после окончания работы мы вызываем va_end() с va_list в качестве параметра.

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

Чем опасно многоточие: проверка типов приостановлена

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

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

Давайте посмотрим на пример довольно трудноуловимой ошибки:

std::cout << findAverage(6, 1.0, 2, 3, 4, 5, 6) << '\n';

Хотя на первый взгляд это может показаться достаточно безобидным, обратите внимание, что второй аргумент (первый аргумент многоточия) принадлежит типу double, а не int. Этот код прекрасно компилируется и дает несколько неожиданный результат:

1.78782e+008

Это ДЕЙСТВИТЕЛЬНО большое число. Как так случилось?

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

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

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

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

int value{ 7 };
std::cout << findAverage(6, 1.0, 2, "Hello, world!", 'G', &value, &findAverage) << '\n';

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

1.79766e+008

Этот результат воплощает фразу «Мусор на входе, мусор на выходе», которая является популярной в информатике фразой, «используемой в первую очередь для привлечения внимания к тому факту, что компьютеры, в отличие от людей, безоговорочно обрабатывают самые бессмысленные входные данные, создавая бессмысленные выходные данные». (Википедия).

Итак, в общем, проверка типа параметров приостановлена, и мы должны доверять вызывающему в передаче параметров правильного типа. Если он этого не сделает, компилятор не будет жаловаться – наша программа просто выдаст мусор (или, возможно, завершится со сбоем).

Чем опасно многоточие: многоточие не знает, сколько параметров было передано

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

Метод 1. Передача параметра длины

Метод 1 состоит в том, чтобы один из фиксированных параметров представлял количество переданных необязательных параметров. Это решение, которое мы использовали в приведенном выше примере findAverage().

Однако и здесь мы сталкиваемся с неприятностями. Например, рассмотрим следующий вызов:

std::cout << findAverage(6, 1, 2, 3, 4, 5) << '\n';

На машине автора на момент написания это дало следующий результат:

699773

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

Более коварный случай:

std::cout << findAverage(6, 1, 2, 3, 4, 5, 6, 7) << '\n';

Это дает ответ 3.5, который на первый взгляд может показаться правильным, но не учитывает последнее число в среднем, потому что мы сказали функции, что собираемся предоставить только 6 параметров (а затем предоставили 7). Подобные ошибки бывает очень сложно поймать.

Метод 2: использование контрольного значения

Метод 2 – использовать контрольное значение. Контрольное значение – это специальное значение, которое используется для завершения цикла при его обнаружении. Например, у строк в качестве контрольного значения используется нулевой терминатор для обозначения конца строки. При использовании многоточия контрольное значение обычно передается в качестве последнего параметра. Вот пример функции findAverage(), переписанной с использованием контрольного значения -1:

#include <iostream>
#include <cstdarg> // необходимо для использования многоточия
 
// Многоточие должно быть последним параметром
double findAverage(int first, ...)
{
	// Нам нужно отдельно обработать первое число
	double sum{ static_cast<double>(first) };
 
	// Мы получаем доступ к многоточию через va_list, поэтому давайте объявим его
	va_list list;
 
	// Мы инициализируем va_list с помощью va_start. Первый параметр
    // - список для инициализации. Второй параметр - это последний
    // параметр перед многоточием
	va_start(list, first);
 
	int count{ 1 };
	// Бесконечный цикл
	while (true)
	{
		// Мы используем va_arg для получения параметров из нашего многоточия
        // Первый параметр - это va_list, который мы используем
        // Второй параметр - это тип параметра
		int arg{ va_arg(list, int) };
 
		// Если этот параметр равен нашему контрольному значению, прерываем цикл
		if (arg == -1)
			break;
 
		sum += arg;
		count++;
	}
 
	// Очищаем va_list, когда закончили.
	va_end(list);
 
	return sum / count;
}
 
int main()
{
	std::cout << findAverage(1, 2, 3, 4, 5, -1) << '\n';
	std::cout << findAverage(1, 2, 3, 4, 5, 6, -1) << '\n';
}

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

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

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

Наконец, обратите внимание, что мы выбрали -1 в качестве нашего контрольного значения. Это нормально, если мы хотим найти только среднее значение положительных чисел, но что, если мы хотим включить сюда и отрицательные числа? Контрольные значения работают хорошо, только если для задачи, которую вы пытаетесь решить, есть значение, выходящее за пределы допустимого набора значений.

Метод 3: использование строки декодера

Метод 3 включает передачу «строки декодера», которая сообщает программе, как интерпретировать параметры.

#include <iostream>
#include <string>
#include <cstdarg> // необходимо для использования многоточия
 
// Многоточие должно быть последним параметром
double findAverage(std::string decoder, ...)
{
	double sum{ 0 };
 
	// Мы получаем доступ к многоточию через va_list, поэтому давайте объявим его
	va_list list;
 
	// Мы инициализируем va_list с помощью va_start. Первый параметр
    // - список для инициализации. Второй параметр - это последний
    // параметр перед многоточием
	va_start(list, decoder);
 
	int count = 0;
	// Бесконечный цикл
	while (true)
	{
		char codetype{ decoder[count] };
		switch (codetype)
		{
		default:
		case '\0':
			// Очищаем va_list, когда закончили.
			va_end(list);
			return sum / count;
 
		case 'i':
			sum += va_arg(list, int);
			count++;
			break;
 
		case 'd':
			sum += va_arg(list, double);
			count++;
			break;
		}
	}
}
	
 
int main()
{
	std::cout << findAverage("iiiii", 1, 2, 3, 4, 5) << '\n';
	std::cout << findAverage("iiiiii", 1, 2, 3, 4, 5, 6) << '\n';
	std::cout << findAverage("iiddi", 1, 2, 3.5, 4.5, 5) << '\n';
}

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

Для тех, кто пришел из C, это то, что делает printf!

Рекомендации по безопасному использованию многоточия

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

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

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

Для продвинутых читателей


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

В C++17 были добавлены выражения свертки, что значительно улучшает удобство использования пакетов параметров до такой степени, что теперь они стали жизнеспособным вариантом.

Мы надеемся в будущем представить уроки по этим темам.

Теги

C++ / CppcstdargLearnCppva_argva_endva_listva_startАргументДля начинающихМноготочие / EllipsisОбучениеПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.