Pull to refresh

C++: сеанс спонтанной археологии и почему не стоит использовать вариативные функции в стиле C

Reading time18 min
Views23K
Началось все, как водится, с ошибки. Я первый раз работал с Java Native Interface и делал в C++ части обертку над функцией, создающей Java объект. Эта функция — CallVoidMethod — вариативна, т.е. помимо указателя на среду JNI, указателя на тип создаваемого объекта и идентификатора вызываемого метода (в данном случае конструктора), она принимает произвольное число других аргументов. Что логично, т.к. эти другие аргументы передаются вызываемому методу на стороне Java, а методы могут быть разные, с разным числом аргументов любых типов.

Соответственно и свою обертку я тоже сделал вариативной. Для передачи произвольного числа аргументов в CallVoidMethod использовал va_list, потому что по-другому в данном случае никак. Да, так и отправил va_list в CallVoidMethod. И уронил JVM банальным segmentation fault.

За 2 часа я успел перепробовать несколько версий JVM, от 8-ой до 11-ой, потому что: во-первых это мой первый опыт с JVM, и в этом вопросе я StackOverflow доверял больше, чем себе, а во-вторых кто-то на StackOverflow посоветовал в таком случае использовать не OpenJDK, а OracleJDK, и не 8, а 10. И лишь потом я наконец заметил, что помимо вариативной CallVoidMethod есть CallVoidMethodV, которая произвольное число аргументов принимает через va_list.

Что мне больше всего не понравилось в этой истории, так это то, что я не сразу заметил разницу между эллипсисом (многоточием) и va_list. А заметив, не смог объяснить себе, в чем принципиальное отличие. Значит, надо разобраться и с эллипсисом, и с va_list, и (поскольку речь все-таки о C++) с вариативными шаблонами.

Что про эллипсис и va_list сказано в Стандарте


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

  • Можно объявить функцию, принимающую произвольное число аргументов. Т.е. аргументов у функции может быть больше, чем параметров. Для этого список ее параметров должен заканчиваться эллипсисом, но так же должен присутствовать хотя бы один фиксированный параметр [C11 6.9.1/8]:

    void foo(int parm1, int parm2, ...);
  • В саму функцию не передается информация о количестве и типах аргументов, соответствующих эллипсису. Т.е. после последнего именованного параметра (parm2 в примере выше) [C11 6.7.6.3/9].
  • Для доступа к этим аргументам надо использовать объявленные в заголовке <stdarg.h> тип va_list и 4 (3 до стандарта C11) макроса: va_start, va_arg, va_end и va_copy (начиная с C11) [C11 7.16].

    Например
    int add(int count, ...) 
    {
        int result = 0;
        va_list args;
        va_start(args, count);
        for (int i = 0; i < count; ++i)
        {
            result += va_arg(args, int);
        }
        va_end(args);
        return result;
    }

    Да, функция не знает, сколько у нее аргументов. Ей надо как-то передать это число. В данном случае — через единственный именованный аргумент (другой распространенный вариант — передавать последним аргументом NULL, как в execl, или 0).
  • Последний именованный аргумент не может иметь класс хранения register, не может быть функцией или массивом. Иначе — неопределенное поведение [C11 7.16.1.4/4].
  • Причем к последнему именованному аргументу и всем безымянным применяется «повышение типа аргумента по умолчанию» (default argument promotion; если есть хороший перевод этого понятия на русский, я с радостью его использую). Это значит, что если у аргумента тип char, short (со знаком или без) или float, то к соответствующим параметрам надо обращаться как к int, int (со знаком или без) или double. Иначе — неопределенное поведение [C11 7.16.1.1/2].
  • Про тип va_list сказано только то, что он объявлен в <stdarg.h> и является полным (т.е. размер объекта этого типа известен) [C11 7.16/3].

Почему? А потому что!


В C не так уж много типов. Почему va_list заявлен в Стандарте, но ничего не сказано о его внутреннем устройстве?

Зачем нужен эллипсис, если произвольное число аргументов в функцию можно передать через va_list? Это сейчас можно было бы сказать: «в качестве синтаксического сахара», но 40 лет назад, я уверен, было не до сахара.

Филип Джеймс Плаугер (Phillip James Plauger) в книге «Стандартная библиотека Си» (The Standard C library) — 1992 год — рассказывает, что изначально C создавался исключительно для компьютеров PDP-11. А там перебрать все аргументы функции можно было с помощью простой арифметики указателей. Проблема появилась с ростом популярности C и переноса компилятора на другие архитектуры. В первом издании «Языка программирования Си» (The C Programming Language) Брайана Кернигана (Brian Kernighan) и Денниса Ритчи (Dennis Ritchie) — 1978 год — прямо сказано:
Кстати, не существует приемлемого способа написать переносимую функцию от произвольного числа аргументов, т.к. нет переносимого способа для вызванной функции узнать, сколько же аргументов ей передано при вызове. … printf, наиболее типичная функция языка C от произвольного числа аргументов, … не является переносимой и должна быть реализована для каждой системы.
В этой книге описывается printf, но еще нет vprintf, и не упоминаются тип и макросы va_*. Они появляются во втором издании «Языка программирования Си» (1988 год), и это — заслуга комитета по разработке первого Стандарта Си (C89, он же ANSI C). Комитет добавил в Стандарт заголовок <stdarg.h>, взяв за основу <varargs.h>, созданный Эндрю Кёнигом (Andrew Koenig) с целью повысить переносимость ОС UNIX. Макросы va_* было решено оставить макросами, чтобы существующим компиляторам было проще поддержать новый Стандарт.

Теперь, с появлением С89 и семейства va_*, стало возможным создавать переносимые вариативные функции. И хотя внутреннее устройство этого семейства по-прежнему никак не описывается, и никаких требований к нему не предъявляется, но уже понятно, почему.

Из чистого любопытства можно найти примеры реализации <stdarg.h>. Например, в той же «Стандартной библиотеке Си» приводится пример для Borland Turbo C++:

<stdarg.h> из Borland Turbo C++
#ifndef _STADARG
#define _STADARG

#define _AUPBND 1
#define _ADNBND 1

typedef char* va_list

#define va_arg(ap, T) \
 (*(T*)(((ap) += _Bnd(T, _AUPBND)) - _Bnd(T, _ADNBND)))

#define va_end(ap) \
 (void)0

#define va_start(ap, A) \
 (void)((ap) = (char*)&(A) + _Bnd(A, _AUPBND))

#define _Bnd(X, bnd) \
 (sizeof(X)  + (bnd) & ~(bnd))

#endif


Гораздо более новый SystemV ABI для AMD64 использует такой тип для va_list:

va_list из SystemV ABI AMD64
typedef struct
{
    unsigned int gp_offset;
    unsigned int fp_offset;
    void *overflow_arg_area;
    void *reg_save_area;
} va_list[1];


В целом можно сказать, что тип и макросы va_* предоставляют стандартный интерфейс обхода аргументов вариативной функции, а их реализация по историческим причинам зависит от компилятора, целевых платформы и архитектуры. Причем эллипсис (т.е. вариативные функции вообще) появился в C раньше, чем va_list (т.е. заголовок <stdarg.h>). И va_list создавался не для замены эллипсиса, а для возможности разработчикам писать свои переносимые вариативные функции.

С++ во многом сохраняет обратную совместимость с C, поэтому все вышесказанное относится и к нему. Но есть и свои особенности.

Вариативные функции в C++


Разработкой Стандарта C++ занималась и занимается рабочая группа WG21. За основу еще в 1989 году был взял только что созданный Стандарт С89, который постепенно менялся, чтобы описывать собственно C++. В 1995 году поступило предложение N0695 от Джона Микко (John Micco), в котором автор предлагал изменить ограничения для макросов va_*:

  • Т.к. C++ в отличие от С позволяет получать адрес register переменных, то последний именованный аргумент вариативной функции может иметь этот класс хранения.
  • Т.к. появившиеся в C++ ссылки нарушают негласное правило вариативных функций С — размер параметра должен совпадать с размером его объявленного типа — то последний именованный аргумент не может быть ссылкой. Иначе — неопределенное поведение.
  • Т.к. в C++ нет понятия «повышения типа аргумента по умолчанию», то фразу
    If the parameter parmN is declared with… a type that is not compatible with the type that results after application of the default argument promotions, the behavior is undefined
    надо заменить на
    If the parameter parmN is declared with… a type that is not compatible with the type that results when passing an argument for which there is no parameter, the behavior is undefined
Последний пункт я даже переводить не стал, чтобы поделиться своей болью. Во-первых, «повышение типа аргумента по умолчанию» в Стандарте C++ осталось [C++17 8.2.2/9]. И во-вторых, я долго ломал голову над смыслом этой фразы, сравнивал со Стандартом С, где все понятно. Только после прочтения N0695 я наконец понял: тут имеется в виду то же самое.

Тем не менее, все 3 изменения были приняты [C++98 18.7/3]. Еще в C++ исчезло требование вариативной функции иметь хотя бы один именованный параметр (в таком случае нельзя получить доступ и к остальным, но об этом позже), и список допустимых типов неименованных аргументов дополнился указателями на члены класса и POD типами.

Стандарт C++03 не принес вариативным функциям никаких изменений. С++11 стал конвертировать неименованный аргумент типа std::nullptr_t в void* и разрешил компиляторам на свое усмотрение поддерживать типы с нетривиальными конструкторами и деструкторами [C++11 5.2.2/7]. C++14 разрешил использовать в качестве последнего именованного параметра функции и массивы [C++14 18.10/3], а C++17 запретил — раскрытия пакета параметров (pack expansion) и захваченные лямбдой переменные [C++17 21.10.1/1].

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

Как легко и неправильно использовать вариативные функции


  1. Неправильно объявлять последний именованный аргумент с повышаемым типом, т.е. char, signed char, unsigned char, singed short, unsigned short или float. Результатом согласно Стандарту будет неопределенное поведение.

    Неправильный код
    void foo(float n, ...)
    {
        va_list va;
        va_start(va, n);
        std::cout << va_arg(va, int) << std::endl;
        va_end(va);
    }


    Из всех компиляторов, которые были у меня под рукой (gcc, clang, MSVC), только clang выдал предупреждение.

    Предупреждение от clang
    ./test.cpp:7:18: warning: passing an object that undergoes default argument promotion to 'va_start' has undefined behavior [-Wvarargs]
        va_start(va, n);
                     ^

    И хотя во всех случаях скомпилированный код вел себя корректно, рассчитывать на это не стоит.

    Правильно будет так
    void foo(double n, ...)
    {
        va_list va;
        va_start(va, n);
        std::cout << va_arg(va, int) << std::endl;
        va_end(va);
    }

  2. Неправильно объявлять последний именованный аргумент ссылкой. Любой ссылкой. Стандарт и в этом случае обещает неопределенное поведение.

    Неправильный код
    void foo(int& n, ...)
    {
        va_list va;
        va_start(va, n);
        std::cout << va_arg(va, int) << std::endl;
        va_end(va);
    }

    gcc 7.3.0 скомпилировал этот код без единого замечания. сlang 6.0.0 выдал предупреждение, но все-таки компиляцию выполнил.

    Предупреждение от clang
    ./test.cpp:7:18: warning: passing an object of reference type to 'va_start' has undefined behavior [-Wvarargs]
        va_start(va, n);
                     ^

    В обоих случаях программа отработала корректно (повезло, нельзя на это полагаться). А вот MSVC 19.15.26730 отличился — он отказался компилировать код, т.к. аргумент va_start не должен быть ссылкой.

    Ошибка от MSVC
    c:\program files (x86)\microsoft visual studio\2017\community\vc\tools\msvc\14.15.26726\include\vadefs.h(151): error C2338: va_start argument must not have reference type and must not be parenthesized

    Ну а правильный вариант выглядит, например, так
    void foo(int* n, ...)
    {
        va_list va;
        va_start(va, n);
        std::cout << va_arg(va, int) << std::endl;
        va_end(va);
    }

  3. Неправильно запрашивать у va_arg повышаемый тип — char, short или float.

    Неправильный код
    #include <cstdarg>
    #include <iostream>
    
    void foo(int n, ...)
    {
        va_list va;
        va_start(va, n);
        std::cout << va_arg(va, int) << std::endl;
        std::cout << va_arg(va, float) << std::endl;
        std::cout << va_arg(va, int) << std::endl;
        va_end(va);
    }
    
    int main()
    {
        foo(0, 1, 2.0f, 3);
        return 0;
    }

    Здесь интереснее. gcc при компиляции выдает предупреждение, что надо использовать double вместо float, а если этот код все-таки будет выполнен, то программа завершится с ошибкой.

    Предупреждение от gcc
    ./test.cpp:9:15: warning: ‘float’ is promoted to ‘double’ when passed through ‘...’
      std::cout << va_arg(va, float) << std::endl;
                   ^~~~~~
    ./test.cpp:9:15: note: (so you should pass ‘double’ not ‘float’ to ‘va_arg’)
    ./test.cpp:9:15: note: if this code is reached, the program will abort

    И действительно, программа аварийно завершается с жалобой на недопустимую инструкцию.
    Анализ дампа показывает, что программа получила сигнал SIGILL. И еще показывает структуру va_list. Для 32-х бит это

    va = 0xfffc6918 ""

    т.е. va_list — просто char*. Для 64-х бит:

    va = {{gp_offset = 16, fp_offset = 48, overflow_arg_area = 0x7ffef147e7e0, reg_save_area = 0x7ffef147e720}}

    т.е. ровно то, что описано в SystemV ABI AMD64.

    clang при компиляции предупреждает о неопределенном поведении и тоже предлагает заменить float на double.

    Предупреждение от clang
    ./test.cpp:9:26: warning: second argument to 'va_arg' is of promotable type 'float'; this va_arg has undefined behavior because arguments will be promoted to 'double' [-Wvarargs]
            std::cout << va_arg(va, float) << std::endl;
                                    ^~~~~

    Но программа уже не падает, 32-х разрядная версия выдает:

    1
    0
    1073741824

    64-х разрядная:

    1
    0
    3

    MSVC выдает точно такие же результаты, только без предупреждений, даже с /Wall.

    Тут можно было бы предположить, что разница между 32 и 64 битами обусловлена тем, что в первом случае ABI передает вызванной функции все аргументы через стек, а во втором первые четыре (Windows) или шесть (Linux) аргументов через регистры процессора, остальные — через стек [wiki]. Но нет, если вызывать foo не с 4 аргументами, а с 19, и так же выводить их, то результат будет прежний: полное месиво в 32-х разрядном варианте, и нули для всех float в 64-х разрядном. Т.е. дело-то конечно в ABI, но не в использовании регистров для передачи аргументов.

    Ну а правильно, конечно, делать так
    void foo(int n, ...)
    {
        va_list va;
        va_start(va, n);
        std::cout << va_arg(va, int) << std::endl;
        std::cout << va_arg(va, double) << std::endl;
        std::cout << va_arg(va, int) << std::endl;
        va_end(va);
    }

  4. Неправильно передавать в качестве безымянного аргумента экземпляр класса с нетривиальным конструктором или деструктором. Если, конечно, судьба этого кода волнует вас хоть немного больше, чем «скомпилировать и запустить здесь и сейчас».

    Неправильный код
    #include <cstdarg>
    #include <iostream>
    
    struct Bar
    {
        Bar() { std::cout << "Bar default ctor" << std::endl; }
        Bar(const Bar&) { std::cout << "Bar copy ctor" << std::endl; }
        ~Bar() { std::cout << "Bar dtor" << std::endl; }
    };
    
    struct Cafe
    {
        Cafe() { std::cout << "Cafe default ctor" << std::endl; }
        Cafe(const Cafe&) { std::cout << "Cafe copy ctor" << std::endl; }
        ~Cafe() { std::cout << "Cafe dtor" << std::endl; }
    };
    
    void foo(int n, ...)
    {
        va_list va;
        va_start(va, n);
        std::cout << "Before va_arg" << std::endl;
        const auto b = va_arg(va, Bar);
        va_end(va);
    }
    
    int main()
    {
        Bar b;
        Cafe c;
        foo(1, b, c);
        return 0;
    }

    Строже всех опять clang. Он просто отказывается компилировать этот код из-за того, что второй аргумент va_arg не является POD типом, и предупреждает, что при запуске программа упадет.

    Предупреждение от clang
    ./test.cpp:23:31: error: second argument to 'va_arg' is of non-POD type 'Bar' [-Wnon-pod-varargs]
        const auto b = va_arg(va, Bar);
                                  ^~~
    ./test.cpp:31:12: error: cannot pass object of non-trivial type 'Bar' through variadic function; call will abort at runtime [-Wnon-pod-varargs]
        foo(1, b, c);
               ^

    Так и будет, если все-таки выполнить компиляцию с флагом -Wno-non-pod-varargs.

    MSVC предупреждает, что использование в данном случае типов с нетривиальным конструкторов непереносимо.

    Предупреждение от MSVC
    d:\my documents\visual studio 2017\projects\test\test\main.cpp(31): warning C4840: непереносимое использование класса "Bar" в качестве аргумента в функции с переменным числом аргументов

    Но код компилируется и выполняется корректно. В консоли получается следующее:

    Результат запуска
    Bar default ctor
    Cafe default ctor
    Before va_arg
    Bar copy ctor
    Bar dtor
    Cafe dtor
    Bar dtor

    Т.е. копия создается только в момент вызова va_arg, а аргумент, получается, передается по ссылке. Как-то не очевидно, но Стандарт разрешает.

    gcc 6.3.0 компилирует без единого замечания. На выходе имеем то же самое:

    Результат запуска
    Bar default ctor
    Cafe default ctor
    Before va_arg
    Bar copy ctor
    Bar dtor
    Cafe dtor
    Bar dtor

    gcc 7.3.0 тоже ни о чем не предупреждает, но вот поведение меняется:

    Результат запуска
    Bar default ctor
    Cafe default ctor
    Cafe copy ctor
    Bar copy ctor
    Before va_arg
    Bar copy ctor
    Bar dtor
    Bar dtor
    Cafe dtor
    Cafe dtor
    Bar dtor

    Т.е. эта версия компилятора передает аргументы по значению, а при вызове va_arg делает еще одну копию. Весело было бы искать эту разницу при переходе с 6-ой на 7-ую версию gcc, если у конструкторов/деструкторов есть побочные эффекты.

    Кстати, если явно передавать и запрашивать ссылку на класс:

    Еще один неправильный код
    void foo(int n, ...)
    {
        va_list va;
        va_start(va, n);
        std::cout << "Before va_arg" << std::endl;
        const auto& b = va_arg(va, Bar&);
        va_end(va);
    }
    
    int main()
    {
        Bar b;
        Cafe c;
        foo(1, std::ref(b), c);
        return 0;
    }

    то все компиляторы выдадут ошибку. Как того и требует Стандарт.

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

    Вот так
    void foo(int n, ...)
    {
        va_list va;
        va_start(va, n);
        std::cout << "Before va_arg" << std::endl;
        const auto* b = va_arg(va, Bar*);
        va_end(va);
    }
    
    int main()
    {
        Bar b;
        Cafe c;
        foo(1, &b, &c);
        return 0;
    }


Разрешение перегрузки и вариативные функции


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

Пример перегрузки
#include <iostream>

void foo(...)
{
    std::cout << "C variadic function" << std::endl;
}

void foo(int)
{
    std::cout << "Ordinary function" << std::endl;
}

int main()
{
    foo(1);
    foo(1ul);
    foo();

    return 0;
}


Результат запуска
$ ./test 
Ordinary function
Ordinary function
C variadic function

Но это работает только до тех пор, пока вызов foo без аргументов не надо рассматривать отдельно.

Вызов foo без аргументов
#include <iostream>

void foo(...)
{
    std::cout << "C variadic function" << std::endl;
}

void foo()
{
    std::cout << "Ordinary function without arguments" << std::endl;
}

int main()
{
    foo(1);
    foo();

    return 0;
}

Вывод компилятора
./test.cpp:16:9: error: call of overloaded ‘foo()’ is ambiguous
     foo();
         ^
./test.cpp:3:6: note: candidate: void foo(...)
 void foo(...)
      ^~~
./test.cpp:8:6: note: candidate: void foo()
 void foo()
      ^~~

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

Когда же все-таки стоит использовать вариативные функции


Ну хорошо, вариативные функции местами не очень очевидно себя ведут и в контексте C++ легко могут оказаться плохо переносимыми. В Интернете есть множество советов вида «Не создавайте и не используйте вариативные С функции», но из Стандарта C++ их поддержку убирать не собираются. Значит, есть какая-то польза от этих функций? Ну есть.

  • Самый частый и очевидный случай — обратная совместимость. Сюда я отнесу как использование сторонних C библиотек (мой случай с JNI), так и предоставление C API к C++ реализации.
  • SFINAE. Тут очень кстати и то, что в C++ вариативная функция не обязана иметь именованных аргументов, и то, что при разрешении перегруженных функций вариативная функция рассматривается в последнюю очередь (при наличии хотя бы одного аргумента). И как любую другую функцию вариативную функцию можно только объявить, но никогда не вызывать.

    Пример
    template <class T> 
    struct HasFoo
    {
    private:
        template <class U, class = decltype(std::declval<U>().foo())>
        static void detect(const U&);
    
        static int detect(...);
    
    public:
        static constexpr bool value = std::is_same<void, decltype(detect(std::declval<T>()))>::value;
    };

    Хотя в C++14 можно сделать немного по-другому.

    Другой пример
    template <class T> 
    struct HasFoo
    {
    private:
        template <class U, class = decltype(std::declval<U>().foo())>
        static constexpr bool detect(const U*)
        {
            return true;
        }
    
        template <class U>
        static constexpr bool detect(...)
        {
            return false;
        }
    
    public:
        static constexpr bool value = detect<T>(nullptr);
    };

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

Вариативные шаблоны или как правильно создавать функции от произвольного числа аргументов в современном C++


Идея вариативных шаблонов была предложена Дугласом Грегором (Douglas Gregor), Яакко Ярви (Jaakko Järvi) и Гэри Пауэллом (Gary Powell) еще в 2004 году, т.е. за 7 лет до принятия стандарта C++11, в котором эти вариативные шаблоны и были официально поддержаны. В Стандарт вошла третья ревизия их предложения за номером N2080.

С самого начала вариативные шаблоны создавались, чтобы у программистов была возможность создавать типобезопасные (и переносимые!) функции от произвольного числа аргументов. Другая цель — упростить поддержку шаблонов классов с переменным числом параметров, но сейчас речь идет только о вариативных функциях.

Вариативные шаблоны принесли в C++ три новых понятия [C++17 17.5.3]:

  • пакет параметров шаблона (template parameter pack) — это такой параметр шаблона, вместо которого можно передать любое (включая 0) количество аргументов шаблона;
  • пакет параметров функции (function parameter pack) — соответственно, это параметр функции, принимающий любое (в т.ч. 0) количество аргументов функции;
  • и раскрытие пакета (pack expansion) — это единственное, что можно сделать с пакетом параметров.

Пример
template <class ... Args>
void foo(const std::string& format, Args ... args)
{
    printf(format.c_str(), args...);
}

Здесь class ... Args — пакет параметров шаблона, Args ... args — пакет параметров функции, и args... — раскрытие пакета параметров функции.

Полный список того, где и как можно раскрывать пакеты параметров, приводится в самом Стандарте [C++17 17.5.3/4]. А в контексте обсуждения вариативных функций достаточно сказать, что:

  • пакет параметров функции можно раскрыть в список аргументов другой функции
    template <class ... Args>
    void bar(const std::string& format, Args ... args)
    {
        foo<Args...>(format.c_str(), args...);
    }

  • или в список инициализации
    template <class ... Args>
    void foo(const std::string& format, Args ... args)
    {
        const auto list = {args...};
    }

  • или в список захвата лямбды
    template <class ... Args>
    void foo(const std::string& format, Args ... args)
    {
        auto lambda = [&format, args...] ()
        {
            printf(format.c_str(), args...);
        };
        lambda();
    }

  • еще пакет параметров функции можно раскрыть в выражении свертки
    template <class ... Args>
    int foo(Args ... args)
    {
        return (0 + ... + args);
    }

    Свертки появились в С++14 и могут быть унарными и бинарными, правыми и левыми. Самое полное описание, как всегда, в Стандарте [C++17 8.1.6].
  • оба типа пакета параметров можно раскрывать в оператор sizeof...
    template <class ... Args>
    void foo(Args ... args)
    {
        const auto size1 = sizeof...(Args);
        const auto size2 = sizeof...(args);
    }


При раскрытии пакета явный эллипсис необходим, чтобы поддержать различные шаблоны (patterns) раскрытия и избежать при этом неоднозначности.

Например
template <class ... Args>
void foo()
{
    using OneTuple = std::tuple<std::tuple<Args>...>;
    using NestTuple = std::tuple<std::tuple<Args...>>;
}

OneTuple — это получается кортеж одноэлементных кортежей (std:tuple<std::tuple<int>>, std::tuple<double>>), а NestTuple — кортеж, состоящий из одного элемента — другого кортежа (std::tuple<std::tuple<int, double>>).

Пример реализации printf с помощью вариативных шаблонов


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

printf на шаблонах
void printf(const char* s)
{
    while (*s)
    {
        if (*s == '%' && *++s != '%')
            throw std::runtime_error("invalid format string: missing arguments");
        std::cout << *s++;
    }
}

template <typename T, typename ... Args>
void printf(const char* s, T value, Args ... args)
{
    while (*s)
    {
        if (*s == '%' && *++s != '%')
        {
            std::cout << value;
            return printf(++s, args...);
        }
        std::cout << *s++;
    }
    throw std::runtime_error("extra arguments provided to printf");
}

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

printf на шаблонах и без рекурсии
template <typename ... Args>
void printf(const std::string& fmt, const Args& ... args)
{
    size_t fmtIndex = 0;
    size_t placeHolders = 0;
    auto printFmt = [&fmt, &fmtIndex, &placeHolders]()
    {
        for (; fmtIndex < fmt.size(); ++fmtIndex)
        {
            if (fmt[fmtIndex] != '%')
                std::cout << fmt[fmtIndex];
            else if (++fmtIndex < fmt.size())
            {
                if (fmt[fmtIndex] == '%')
                    std::cout << '%';
                else
                {
                    ++fmtIndex;
                    ++placeHolders;
                    break;
                }
            }
        }
    };

    ((printFmt(), std::cout << args), ..., (printFmt()));

    if (placeHolders < sizeof...(args))
        throw std::runtime_error("extra arguments provided to printf");
    if (placeHolders > sizeof...(args))
        throw std::runtime_error("invalid format string: missing arguments");
}

Разрешение перегрузки и вариативные шаблонные функции


При разрешении и эти вариативные функции рассматриваются после прочих — как шаблонные и как наименее специализированные. Но нет проблем в случае вызова без аргументов.

Пример перегрузки
#include <iostream>

void foo(int)
{
    std::cout << "Ordinary function" << std::endl;
}

void foo()
{
    std::cout << "Ordinary function without arguments" << std::endl;
}

template <class T>
void foo(T)
{
    std::cout << "Template function" << std::endl;
}

template <class ... Args>
void foo(Args ...)
{
    std::cout << "Template variadic function" << std::endl;
}

int main()
{
    foo(1);
    foo();
    foo(2.0);
    foo(1, 2);

    return 0;
}

Результат запуска
$ ./test 
Ordinary function
Ordinary function without arguments
Template function
Template variadic function

При разрешении перегрузки вариативная шаблонная функция может обойти только вариативную C функцию (хотя зачем их смешивать?). Кроме — конечно же! — вызова без аргументов.

Вызов без аргументов
#include <iostream>

void foo(...)
{
    std::cout << "C variadic function" << std::endl;
}

template <class ... Args>
void foo(Args ...)
{
    std::cout << "Template variadic function" << std::endl;
}

int main()
{
    foo(1);
    foo();

    return 0;
}

Результат запуска
$ ./test 
Template variadic function
C variadic function

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

Небольшое замечание о скорости вариативных шаблонных функций


В 2008 году Лоик Жоли (Loïc Joly) подал в комитет по стандартизации C++ своё предложение N2772, в котором на практике показал, что вариативные шаблонные функции работают медленнее аналогичных функций, аргументом которых является список инициализации (std::initializer_list). И хотя это противоречило теоретическим обоснованиям самого автора, Жоли предложил реализовать std::min, std::max и std::minmax именно с помощью списков инициализации, а не вариативных шаблонов.

Но уже в 2009 году появилось опровержение. В тестах Жоли была обнаружена «серьезная ошибка» (кажется, даже им самим). Новые тесты (см. тут и тут) показали, что вариативные шаблонные функции все-таки быстрее, и иногда значительно. Что не удивительно, т.к. список инициализации делает копии своих элементов, а для вариативных шаблонов можно многое посчитать еще на этапе компиляции.

Тем не менее в C++11 и последующих стандартах std::min, std::max и std::minmax – это обычные шаблонные функции, произвольное число аргументов в которые передается через список инициализации.

Краткое резюме и вывод


Итак, вариативные функции в стиле C:

  • Не знают ни числа своих аргументов, ни их типов. Разработчик должен использовать часть аргументов функции для того, чтобы передать информацию об остальных.
  • Неявно повышают типы неименованных аргументов (и последнего именованного). Если забыть об этом, получится неопределенное поведение.
  • Сохраняют обратную совместимость с чистым C и потому не поддерживают передачу аргументов по ссылке.
  • До C++11 не поддерживали аргументы не POD типов, а начиная с C++11 поддержка нетривиальных типов оставлена на усмотрение компилятора. Т.е. поведение кода зависит от компилятора и его версии.

Единственное допустимое использование вариативных функций — взаимодействие с C API в C++ коде. Для всего остального, включая SFINAE, есть вариативные шаблонные функции, которые:

  • Знают количество и типы всех своих аргументов.
  • Типобезопасны, не меняют типы своих аргументов.
  • Поддерживают передачу аргументов в любом виде — по значению, по указателю, по ссылке, по универсальной ссылке.
  • Как и любые другие C++ функции, не имеют ограничений на типы аргументов.
  • При разрешении перегрузки рассматриваются в последнюю очередь (уступая только вариативным C функциям), как наименее специализированные.

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

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

Литература и источники



P. S.


В сети легко найти и скачать электронные версии упомянутых книг. Но я не уверен, что это будет легально, поэтому ссылок не даю.
Tags:
Hubs:
Total votes 28: ↑27 and ↓1+26
Comments11

Articles