Функции с произвольным числом параметров

Практический курс по C/C++: https://stepik.org/course/193691

На этом занятии познакомимся с еще одной возможностью языка Си – объявлять и вызывать функции с произвольным числом аргументов. Такие функции еще называют вариадическими.

Вначале я сразу приведу пример такой функции, которая просто вычисляет сумму, переданных ей целочисленных значений:

#include <stdio.h>
#include <stdarg.h>
 
int sum(int count, ...)
{
            int s = 0;
            va_list arg;            // указатель на параметр
            va_start(arg, count);   // получение адреса первого вариадического параметра 
 
            for(int i=0; i < count; ++i) {
                        s += va_arg(arg, int);  // получение значение вариадического параметра
                                                // и переход к следующему параметру
            }
 
            va_end(arg);               // завершение процедуры перебора вариадических параметров
 
            return s;
}
 
 
int main(void) 
{
            int res = sum(5, 1, 2, 3, 4, 5);
            printf("res = %d\n", res);
 
            return 0;
}

А теперь давайте детально разберемся, как работает эта программа. Вначале нам необходимо подключить заголовочный файл stdarg.h, в котором определен тип va_list для указателя по вариадическим параметрам функции (то есть, переменным параметрам, которые формируются при ее вызове). Кроме того, в файле stdarg.h определены следующие функции:

  • va_start() – для получения указателя на первый вариадический параметр функции (тот, что идет после параметра count в нашем примере);
  • va_arg() – для получения значения текущего параметра и переход к следующему вариадическому параметру;
  • va_end() – для завершения процедуры перебора (это необходимо, если потребуется новый обход параметров, тогда нужно будет снова вызвать va_start() и va_arg() для извлечения значений).

После подключения всех необходимых файлов идет объявление вариадической функции с именем sum(). У таких функций вначале должен быть прописан хотя бы один обычный параметр. В нашем примере – это count. А уже в конце можно поставить многоточие, означающее, что функция может принимать произвольное число аргументов. В теле функции объявляется вспомогательная переменная s с начальным значением 0, в которой будет храниться сумма значений, а далее идет объявление указателя arg для перебора вариадических параметров. С помощью функции va_start() выполняется инициализация этого указателя на первый вариадический параметр. При этом вторым аргументом мы должны указать обычный параметр, стоящий непосредственно перед многоточием при объявлении функции sum(). После этого в цикле происходит перебор count значений (мы полагаем, что число вариадических параметров не менее count, а в идеале равно ему) с помощью функции va_arg(). Этой функции передается указатель arg и тип текущего вариадического параметра. Мы полагаем, что все значения имеют тип int. В конце после цикла вызывается завершающая функция va_end().

После запуска этой программы увидим следующий результат:

res = 15

то есть, были просуммированы переданные числа:

1+2+3+4+5 = 15

Обратите внимание, первый параметр у нас хранит число вариадических параметров и это значение мы прописываем сами. Например, если при вызове функции sum() первым аргументом указать меньшее число:

int res = sum(3, 1, 2, 3, 4, 5);

то будут просуммированы только первые три числа (из вариадических аргументов) и получим значение:

1+2+3 = 6

То есть, внутри функции sum() мы не знаем сколько аргументов в реальности было передано и просто доверяем значению в переменной count. Это один из недостатков вариадических функций. Нам как то нужно дополнительно передавать информацию о числе вариадических параметров. Например, функции:

int printf(const char *format, ...);
int scanf(const char *format, ...);

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

Вариадические функции и стековый фрейм

Вообще механизм работы вариадических функций достаточно прост. Я напомню, что в момент вызова какой-либо функции, все аргументы, указанные при ее вызове, автоматически копируются во фреймовый стек, начиная с последнего. Поэтому вызов функции:

int res = sum(5, 1, 2, 3, 4, 5);

приводит к формированию следующей информации:

Далее, когда выполняется функция va_start(arg, count), то не составляет труда определить адрес первого вариадического параметра, на который будет ссылаться указатель arg. А последующие вызовы функции va_arg(arg, int) с указанием типа данных текущего параметра, позволяют прочитать значения из ячеек памяти, которые он занимает, правильно их интерпретировать (в виде целочисленного значения) и переместить указатель на начало следующего вариадического параметра. И так в цикле прочитать и перебрать все вариадические значения, указанные при вызове функции sum(). Вот общий принцип работы функций с произвольным числом аргументов.

Заключение

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

Дополнение для языка С++

Если вы в будущем планируете программировать на языке С++, то важно знать еще две ключевые его возможности:

  • перегрузка функций;
  • функции с параметрами по умолчанию.

Я здесь рассмотрю их кратко.

Перегрузка функций

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

#include<iostream>
using namespace std;
 
int modul(int x) 
{
         return (x < 0) ? -x : x;
}
 
double modul(double x) 
{
         return (x < 0) ? -x : x;
}
 
int main(void) {
         int a1 = modul(-3);
         int a2 = modul(-3.5);
         double a3 = modul(-3);
         double a4 = modul(-3.5);
 
         return 0;
}

Здесь записаны две функции modul с разными типами параметра x. Затем, в функции main осуществляется их вызов. Как вы думаете, как компилятор определяет какую из двух функций нужно вызвать? Делается это по типу входных аргументов. То есть, если записано -3, значит, будет вызвана функция modul с целочисленным параметром. Если же стоит вещественное число -3.5, то вызовется функция modul с вещественным параметром. При этом возвращаемые типы не играют никакой роли в выборе функции modul. Значение имеют только входные типы.

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

Параметры со значениями по умолчанию

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

#include<iostream>
using namespace std;
 
void show_args(short a=10, double b=20.0, int c=30) 
{
         printf("a = %d, b = %.2f, c = %d\n", a, b, c);
}
 
int main() {
         show_args();
         show_args(1);
         show_args(1, 2);
         show_args(1, 2, 3);
 
         return 0;
}

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

a = 10, b = 20.00, c = 30
a = 1, b = 20.00, c = 30
a = 1, b = 2.00, c = 30
a = 1, b = 2.00, c = 3

Но, обратите внимание, параметры со значениями по умолчанию должны быть записаны в конце списка параметров функции. Вот примеры правильных и неправильных объявлений:

void my_func(int a, int b = 1, int c = 1);    //правильное объявление
void my_func(int a, int b, int c = 1);          //правильное объявление
void my_func(int a=1, int b, int c = 1);      //неправильное объявление
void my_func(int a, int b = 1, int c);          //неправильное объявление

Теперь вы знаете все основные моменты работы с функциями в языке Си и С++.

Практический курс по C/C++: https://stepik.org/course/193691

Видео по теме