Стандарт 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().
- Дополнительная фиктивная функция генерируется для конструктора 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"