Стандарт C++17 добавил в язык новую фичу: Class Template Argument Deduction (CTAD). Вместе с новыми возможностями в C++ традиционно добавились и новые способы отстрела собственных конечностей. В этой статье мы будем разбираться, что из себя представляет CTAD, для чего используется, как упрощает жизнь, и какие в нём есть подводные камни.


Начнём издалека


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


До C++17 вывод параметров шаблона относился только к шаблонам функций. При инстанцировании шаблона функции можно явно не указывать те аргументы шаблона, которые могут быть выведены из типов фактических аргументов функции. Правила выведения довольно сложны, им посвящён целый раздел 17.9.2 в Стандарте [temp.deduct] (здесь и далее я ссылаюсь на свободно доступную версию драфта Стандарта; в будущих версиях нумерация разделов может измениться, поэтому я рекомендую искать по мнемоническому коду, указанному в квадратных скобках).


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


template <typename T>
void func(T t) {
    // ...
}

int some_func(double d) {
  return static_cast<int>(d);
}

int main() {
    const int i = 123;
    func(i);  // func<int>

    char arr[] = "Some text";
    func(arr);  // func<char *>

    func(some_func);  // func<int (*)(double)>

    return 0;
}

Всё это упрощает использование шаблонов функций, но, увы, совсем неприменимо к шаблонам классов. При инстанциировании шаблонов классов все недефолтные параметры шаблонов приходилось указывать явно. В связи с этим неприятным свойством в стандартной библиотеке появилось целое семейство свободных функций с префиксом make_: make_unique, make_shared, make_pair, make_tuple и т.д.


// Вместо
auto tup1 = std::tuple<int, char, double>(123, 'a', 40.0);
// можно использовать
auto tup2 = std::make_tuple(123, 'a', 40.0);

Новое в C++17


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


std::pair pr(false, 45.67);      // std::pair<bool, double>
std::tuple tup(123, 'a', 40.0);  // std::tuple<int, char, double>
std::less l;                     // std::less<void>, больше не надо писать std::less<> l

template <typename T> struct A { A(T,T); };
auto y = new A{1, 2};                // выводится A<int>

auto lck = std::lock_guard(mtx);     // std::lock_guard<std::mutex>
std::copy_n(vi1, 3, std::back_insert_iterator(vi2)); // не надо явно указывать тип итератора

template <typename T> struct F { F(T); }
std::for_each(vi.begin(), vi.end(), Foo([&](int i) {...})); // F<lambda>

Сразу стоит упомянуть об ограничениях CTAD, которые действуют на момент C++17 (возможно, эти ограничения уберут в будущих версиях Стандарта):


  • CTAD не работает с алиасами шаблонов:

template <typename X>
using PairIntX = std::pair<int, X>;

PairIntX p{1, true}; // не компилируется

  • CTAD не позволяет частично выводить аргументы (как это работает для обычного Template Argument Deduction):

std::pair p{1, 5};              // OK
std::pair<double> q{1, 5};      // ошибка, так нельзя
std::pair<double, int> r{1, 5}; // OK

Также компилятор не сможет вывести типы параметров шаблона, которые явно не связаны с типами аргументов конструктора. Простейший пример – конструктор контейнера, принимающий пару итераторов:


template <typename T>
struct MyVector {
  template <typename It>
  MyVector(It from, It to);
};

std::vector<double> dv = {1.0, 3.0, 5.0, 7.0};
MyVector v2{dv.begin(), dv.end()}; // не могу вывести тип T из типа It

Тип It не связан напрямую с T, хотя мы, разработчики, совершенно точно знаем, как его можно получить. Для того, чтобы подсказать компилятору, как выводить несвязанные напрямую типы, в C++17 появилась новая языковая конструкция – deduction guide, которую мы рассмотрим в следующем разделе.


Deduction guides


Для примера выше deduction guide будет выглядеть так:


template <typename It>
MyVector(It, It) -> MyVector<typename std::iterator_traits<It>::value_type>;

Здесь мы подсказываем компилятору, что для конструктора с двумя параметрами одинакового типа можно определить тип T с помощью конструкции std::iterator_traits<It>::value_type. Обратите внимание, что deduction guides находятся вне определения класса, это позволяет настраивать поведение внешних классов, в том числе и классов из Стандартной библиотеки C++.


Формальное описание синтаксиса deduction guides приводится в Стандарте C++17 в разделе 17.10 [temp.deduct.guide]:


[explicit] template-name (parameter-declaration-clause) -> simple-template-id;

Ключевое слово explicit перед deduction guide запрещает применять его при copy-list-initialization:


template <typename It>
explicit MyVector(It, It) -> MyVector<typename std::iterator_traits<It>::value_type>;

std::vector<double> dv = {1.0, 3.0, 5.0, 7.0};
MyVector v2{dv.begin(), dv.end()};    // ОК
MyVector v3 = {dv.begin(), dv.end()}; // ошибка компиляции

Кстати, deduction guide не обязательно должен быть шаблоном:


template<class T> struct S { S(T); };
S(char const*) -> S<std::string>;
S s{"hello"}; // S<std::string>

Подробный алгоритм работы CTAD


Формальные правила выведения аргументов шаблонов классов подробно описаны в пункте 16.3.1.8 [over.match.class.deduct] Стандарта C++17. Попробуем в них разобраться.


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


  • Для каждого конструктора Ci генерируется фиктивная шаблонная функция Fi. Шаблонные параметры Fi – это параметры C, за которыми следуют шаблонные параметры Ci (если они имеются), включая параметры со значениями по умолчанию. Типы параметров функции Fi соответствуют типам параметров конструктора Ci. Возвращает фиктивная функция Fi тип C с аргументами, соответствующими шаблонным параметрам C.

Псевдокод:


template <typename T, typename U>
class C {
 public:
  template <typename V, typename W = A>
  C(V, W);
};
// генерирует фиктивную функцию
template <typename T, typename U, typename V, typename W = A>
C<T, U> Fi(V, W);

  • Если тип C не определён, или конструкторов в нём не задано, вышеописанные правила применяются для гипотетического конструктора C().
  • Дополнительная фиктивная функция генерируется для конструктора , для неё даже придумали специальное название: copy deduction candidate.
  • Для каждого deduction guide также генерируется фиктивная функция Fi с шаблонными параметрами и аргументами deduction guide и возвращаемым значением, соответствующим типу справа от -> в deduction guide (в формальном определении он называется simple-template-id).

Псевдокод:


template <typename T, typename V>
C(T, V) -> C<typename DT<T>, typename DT<V>>;
// генерирует фиктивную функцию
template <typename T, typename V>
C<typename DT<T>, typename DT<V>> Fi(T,V);

Далее, для полученного набора фиктивных функций Fi применяются обычные правила вывода шаблонных параметров и разрешения перегрузок с единственным исключением: когда фиктивная функция вызвана со списком инициализации, состоящим из единственного параметра с типом cv U, где U – специализация C или тип, унаследованный от специализации C (на всякий случай уточню, что cv == const volatile; такая запись означает, типы U, const U, volatile U и const volatile U трактуются в одинаково), пропускается правило, дающее приоритет конструктору C(std::initializer_list<>) (за подробностями list initialization можно обратиться к пункту 16.3.1.7 [over.match.list] Стандарта C++17). Пример:


std::vector v1{1, 2};   // std::vector<int>
std::vector v2{v1};     // std::vector<int>, а не std::vector<std::vector<int>>

Наконец, если удалось выбрать единственную наиболее подходящую фиктивную функцию, то выбирается соответствующий ей конструктор или deduction guide. Если же подходящих нет, либо есть несколько одинаково хорошо подходящих, компилятор сообщает об ошибке.


Подводные камни


CTAD применяется при инициализации объектов, а инициализация традиционно очень запутанная часть языка C++. С добавлением в C++11 универсальной инициализации (uniform initialization) способов отстрелить себе ногу только прибавилось. Теперь вызвать конструктор для объекта можно как с круглыми, так и с фигурными скобками. Во многих случаях оба этих варианта работают одинаково, но далеко не всегда:


std::vector v1{8, 15}; // [8, 15]
std::vector v2(8, 15); // [15, 15, … 15] (8 раз)
std::vector v3{8};     // [8]
std::vector v4(8);     // не компилируется

Пока всё вроде бы достаточно логично: v1 и v3 вызывают конструктор, принимающий std::initializer_list<int>, int выводится из параметров; v4 не может найти конструктор, принимающий всего один параметр типа int. Но это ещё цветочки, ягодки впереди:


std::vector v5{"hi", "world"}; // [“hi”, “world”]
std::vector v6("hi", "world"); // ??

v5, как и ожидается, будет типа std::vector<const char*> и инициализируется двумя элементами, а вот следующая строка делает нечто совсем другое. Для вектора есть всего один конструктор, принимающий два параметра одного типа:


template< class InputIt >
vector( InputIt first, InputIt last,
        const Allocator& alloc = Allocator() );

благодаря deduction guide для std::vector "hi" и "world" будут трактоваться как итераторы, и в вектор типа std::vector<char> будут добавлены все элементы, лежащие "между" ними. Если нам повезёт и эти две строковые константы находятся в памяти подряд, то в вектор попадут три элемента: 'h', 'i', '\x00', но, скорее всего, такой код приведёт к нарушению защиты памяти и аварийному завершению программы.


Используемые материалы


Драфт Стандарта C++17
CTAD
CppCon 2018: Stephan T. Lavavej "Class Template Argument Deduction for Everyone"

Комментарии (17)


  1. MooNDeaR
    31.07.2019 08:04
    +1

    Плюсы такие плюсы) Не, в целом мне С++17 нравится, но лично я не использую deduction guides. Не потому, что не понимаю, а потому, что понимаю, что:


    1) Использование функций-генераторов куда прозрачнее и понятнее. К тому же, во многих случаях когда приходится писать даже меньше.


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


    3) Код использующий deduction guides невозможно читать. Ты никогда не уверен в том, какой у тебя тут конкретно тип, потому что кто угодно в каком-нибудь заголовочнике написал свой deduction guide и ты охренеешь искать проблему.


    1. 0xd34df00d
      31.07.2019 14:42

      Третий и, в меньшей степени, второй аргумент применимы и к функциям. Первый — свбъективщина, в тех контекстах, где надо использовать CTAD, прозрачность одинаковая ИМХО.


      1. MooNDeaR
        31.07.2019 15:15

        3) Вот только find definition для функции — стандартная фича в любой IDE и мало-мальски адекватном редакторе. Найти где же этот чертов гайд я не знаю как))


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


        1) Может и субъективщина, но мне лично приятней было писать std::back_inserter, чем std::back_insert_iterator. Тем более, что в функции можно спрятать логику и посложнее тупого вывода шаблонов, взять хоть тот же std::make_shared, который позволяет сократить количество аллокаций.


        1. 0xd34df00d
          31.07.2019 17:42

          3. И она работает с кучей перегрузок, обмазанных sfinae и подобным? Покажите мне такую IDE.


          2. Упомянутые в статье проблемы — это вариации на тему, аналогичная ерунда с const char* была давно. У CTAD есть свои проблемы, и я для себя сделал вывод, например, не использовать их в дженерик-коде (в темплейтах), но это совсем другая история.


          1. А вам приятнее писать std::tuple или std::make_tuple?


          1. MooNDeaR
            31.07.2019 17:55

            1. Мне в целом не критично. std::make_tuple всего на 5 символов длиннее, зато я на 100% уверен, что на выходе получу кортеж именно точно из тех аргументов, что передал. И если мне нужен кортеж итераторов, я его получу, а не получу, скажем, кортеж значений в этих итераторах.


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


            3. CLion неплохо справляется, даже на больших объемах кода. Если не может найти нужную перегрузку по Ctrl+Click на функции/методе, вывалит весь список со всеми перегрузками, нужно просто выбрать. Без плагинов, прямо из коробки умеет. Не идеально, но всё же это хоть можно как-то исправить. Как сделать go to deduction guide, я если честно не особо представляю. Не говорю невозможно, просто честно не знаю.



            1. 0xd34df00d
              01.08.2019 19:53

              1. Почему? Кто гарантирует отсутствие наркоманских перегрузок make_tuple или наркоманских if constexpr внутри make_tuple? У вас же теперь даже возвращаемое значение по одному взгляду на сигнатуру функции определить нельзя.


              2. А, ну а я вот ленивый и терпеть не могу писать то, что можно не писать. Субъективное дело :)


              3. Ну да, «просто выбрать» в случае SFINAE и тому подобного действительно просто. Deduction guide можно сделать похоже. Точно так же будете контрол-кликать на имени шаблонного класса без указанных аргументов шаблона в соответствующем контексте. В конце концов, deduction guide'ы дешугарятся во что-то очень похожее на инстанциирование и вызов функций.



              1. MooNDeaR
                01.08.2019 21:48

                1. Стандарт гарантирует. А делать перегрузки, да или хоть что в std — это UB. Если речь о собственных функциях, то ССЗБ :)


                2. Ну, в целом, у меня кроме лени был аргумент про бОльшую прозрачность и более простую навигацию.


                3. Я прямо сейчас работаю с большим проектом в котором есть части с просто охрененной вложенностью шаблонов) Когда я не могу понять какая из 5-15 специализаций используются, я просто ставлю брейкпоинт везде, благо найти все места очень просто.



                По поводу "дешугаринга" это понятно) Но на дворе 2019, а я до сих пор не вижу этой фичи :)


                1. dmxvlx
                  02.08.2019 12:03

                  3) Киньте кусок кода глянуть (ту самую охрененную вложенность). Мне кажется проблема в дизайне..


                  1. MooNDeaR
                    02.08.2019 12:17

                    Не могу :) NDA ж :)


                    Проблемы в дизайне хоть и есть, но они не большие и в основном состоят не в шаблонах. "охрененная" — я скорее преувеличил. Глубина заканчивается обычно где-то на уровне 5, но есть структуры, имеющие больше 20 специализаций. И CLion успешно справляется с поиском.


                    Такой код в целом оправдан, потому что я работаю в сфере, где задержка в 2 лишние миллисекунды может (а может и нет) принести миллионные убытки. Тут такой код оправдан.


    1. Antervis
      02.08.2019 16:18

      3) Код использующий deduction guides невозможно читать. Ты никогда не уверен в том, какой у тебя тут конкретно тип, потому что кто угодно в каком-нибудь заголовочнике написал свой deduction guide и ты охренеешь искать проблему.

      это является проблемой только если deduction guide реализует совершенно нелогичный вывод типов. От этого абсолютно аналогично не застрахованы ни make-функции, ни разработчики, эти make-функции использующие. Deduction guides призваны уменьшить шум, и увеличить устойчивость кода к изменениям. «Поменял что-то (в нашем случае тип) в одном месте и забыл поменять в другом» — одна из самых распространенных причин ошибок.


  1. dmxvlx
    31.07.2019 13:52

    Очень даже ничего, этот наш deduction guides !


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


    Конечно, пригодится не всем и не для повседневного программирования, больше для разработчиков всяких там библиотек...


    Но это реально классная фича !


  1. ss-nopol
    31.07.2019 14:40

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

    Да это же почти что теорема Гёделя для языков программирования! Можно попробовать вывести закономерности «Развитие языка программирования (ЯП) приводит к его усложнению», «В усложняющемся ЯП возникает всё большее количество способов отстрелить себе ногу» и, наконец, если устремить время в бесконечность, то «Со временем вероятность отстрелить себе ногу в усложняющемся ЯП будет стремиться к единице»


  1. vagran
    31.07.2019 21:59
    +2

    Пример с make_shared некорректен. CTAD его не заменит, т.к. там фишка в том, что аллоцируется одним блоком и объект и внутренний тэг shared_ptr.


    1. monah_tuk
      02.08.2019 05:13
      +1

      Хорошо, что коммент этот поискал поиском. А то в тексте резануло.


  1. netch80
    01.08.2019 00:02

    Интересно, почему не сделали, как в Java?

    std::pair<> p{1, 5};


  1. oktonion
    01.08.2019 08:20

    То есть как я понимаю классы перегружать по шаблонным параметрам всё ещё не получится? Но выглядеть будет как будто есть куча классов с одинаковыми именами, но "перегруженных" по шаблонным параметрам.
    Интересно как такие нововведения в итоге внедрения в конкретные компиляторы повлияют на время сборки.
    В целом выглядит довольно красиво конечно, учитывая на сколько уже можно захламить с помощью SFINAE + type_traits любую функцию или класс.


  1. svr_91
    01.08.2019 10:37

    Мне кажется писать что-то типа
    std::vector v2(8, 15);
    преступление.
    Обычно более важен тип в контейнере (или итераторе), чем сам контейнер (или итератор).
    Было бы неплохо писать что-то типа
    auto iter = v.begin();