Class Template Argument Deduction
Стандарт C++17 добавил в язык новую фичу: Class Template Argument Deduction (CTAD). Вместе с новыми возможностями в C++ традиционно добавились и новые способы отстрела собственных конечностей. В этой статье мы будем разбираться, что из себя представляет CTAD, для чего используется, как упрощает жизнь, и какие в нём есть подводные камни.
Начнём издалека
Вспомним, что такое вообще Template Argument Deduction, и для чего он нужен. Если вы достаточно уверенно чувствуете себя с шаблонами C++, этот раздел можно пропустить и сразу переходить к следующему.
До C++17 вывод параметров шаблона относился только к шаблонам функций. При инстанцировании шаблона функции можно явно не указывать те аргументы шаблона, которые могут быть выведены из типов фактических аргументов функции. Правила выведения довольно сложны, им посвящён целый раздел 17.9.2 в Стандарте [temp.deduct] (здесь и далее я ссылаюсь на свободно доступную версию драфта Стандарта; в будущих версиях нумерация разделов может измениться, поэтому я рекомендую искать по мнемоническому коду, указанному в квадратных скобках).
Мы не будем подробно разбирать все тонкости этих правил, они нужны разве что разработчикам компиляторов. Для практического применения достаточно запомнить простое правило: компилятор может самостоятельно вывести аргументы шаблона функции, если это можно сделать однозначно на основании имеющейся информации. При выведении типов параметров шаблона применяются стандартные преобразования как при вызове обычной функции (отбрасывается const у литеральных типов, массивы сводятся к указателям, ссылки на функции приводятся к указателям на функции и т.д.).
template
void func(T t) {
// ...
}
int some_func(double d) {
return static_cast(d);
}
int main() {
const int i = 123;
func(i); // func
char arr[] = "Some text";
func(arr); // func
func(some_func); // func
return 0;
}
Всё это упрощает использование шаблонов функций, но, увы, совсем неприменимо к шаблонам классов. При инстанциировании шаблонов классов все недефолтные параметры шаблонов приходилось указывать явно. В связи с этим неприятным свойством в стандартной библиотеке появилось целое семейство свободных функций с префиксом make_: make_unique, make_shared, make_pair, make_tuple и т.д.
// Вместо
auto tup1 = std::tuple(123, 'a', 40.0);
// можно использовать
auto tup2 = std::make_tuple(123, 'a', 40.0);
Новое в C++17
В новом Стандарте по аналогии с параметрами шаблонов функций параметры шаблонов классов выводятся из аргументов вызываемых конструкторов:
std::pair pr(false, 45.67); // std::pair
std::tuple tup(123, 'a', 40.0); // std::tuple
std::less l; // std::less, больше не надо писать std::less<> l
template struct A { A(T,T); };
auto y = new A{1, 2}; // выводится A
auto lck = std::lock_guard(mtx); // std::lock_guard
std::copy_n(vi1, 3, std::back_insert_iterator(vi2)); // не надо явно указывать тип итератора
template struct F { F(T); }
std::for_each(vi.begin(), vi.end(), Foo([&](int i) {...})); // F
Сразу стоит упомянуть об ограничениях CTAD, которые действуют на момент C++17 (возможно, эти ограничения уберут в будущих версиях Стандарта):
- CTAD не работает с алиасами шаблонов:
template
using PairIntX = std::pair;
PairIntX p{1, true}; // не компилируется
- CTAD не позволяет частично выводить аргументы (как это работает для обычного Template Argument Deduction):
std::pair p{1, 5}; // OK
std::pair q{1, 5}; // ошибка, так нельзя
std::pair r{1, 5}; // OK
Также компилятор не сможет вывести типы параметров шаблона, которые явно не связаны с типами аргументов конструктора. Простейший пример — конструктор контейнера, принимающий пару итераторов:
template
struct MyVector {
template
MyVector(It from, It to);
};
std::vector 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
MyVector(It, It) -> MyVector::value_type>;
Здесь мы подсказываем компилятору, что для конструктора с двумя параметрами одинакового типа можно определить тип T с помощью конструкции std::iterator_traits
. Обратите внимание, что 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
explicit MyVector(It, It) -> MyVector::value_type>;
std::vector dv = {1.0, 3.0, 5.0, 7.0};
MyVector v2{dv.begin(), dv.end()}; // ОК
MyVector v3 = {dv.begin(), dv.end()}; // ошибка компиляции
Кстати, deduction guide не обязательно должен быть шаблоном:
template struct S { S(T); };
S(char const*) -> S;
S s{"hello"}; // S
Подробный алгоритм работы 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
class C {
public:
template
C(V, W);
};
// генерирует фиктивную функцию
template
C Fi(V, W);
- Если тип C не определён, или конструкторов в нём не задано, вышеописанные правила применяются для гипотетического конструктора C ().
- Дополнительная фиктивная функция генерируется для конструктора C©, для неё даже придумали специальное название: copy deduction candidate.
- Для каждого deduction guide также генерируется фиктивная функция Fi с шаблонными параметрами и аргументами deduction guide и возвращаемым значением, соответствующим типу справа от → в deduction guide (в формальном определении он называется simple-template-id).
Псевдокод:
template
C(T, V) -> C, typename DT>;
// генерирует фиктивную функцию
template
C, typename DT> 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
std::vector v2{v1}; // std::vector, а не std::vector>
Наконец, если удалось выбрать единственную наиболее подходящую фиктивную функцию, то выбирается соответствующий ей конструктор или 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 выводится из параметров; v4 не может найти конструктор, принимающий всего один параметр типа int. Но это ещё цветочки, ягодки впереди:
std::vector v5{"hi", "world"}; // ["hi”, "world”]
std::vector v6("hi", "world"); // ??
v5, как и ожидается, будет типа std::vector
и инициализируется двумя элементами, а вот следующая строка делает нечто совсем другое. Для вектора есть всего один конструктор, принимающий два параметра одного типа:
template< class InputIt >
vector( InputIt first, InputIt last,
const Allocator& alloc = Allocator() );
благодаря deduction guide для std::vector
«hi» и «world» будут трактоваться как итераторы, и в вектор типа std::vector
будут добавлены все элементы, лежащие «между» ними. Если нам повезёт и эти две строковые константы находятся в памяти подряд, то в вектор попадут три элемента: 'h', 'i', '\x00', но, скорее всего, такой код приведёт к нарушению защиты памяти и аварийному завершению программы.
Используемые материалы
Драфт Стандарта C++17
CTAD
CppCon 2018: Stephan T. Lavavej «Class Template Argument Deduction for Everyone»