От явных аргументов шаблона к CTAD
До C++17 аргументы шаблона класса обычно приходилось указывать явно:
Stack<int> stack;
Начиная с C++17 компилятор часто может вывести эти аргументы из инициализатора:
Stack stack{42}; // Stack<int>
Этот механизм называется Class Template Argument Deduction, или CTAD. Он применяется, когда имя шаблона класса используется без списка аргументов, а доступные кандидаты вывода содержат достаточно информации для определения всех обязательных аргументов.
Вывод из конструктора
Рассмотрим стек, который хранит элементы в std::vector и может быть инициализирован одним значением:
#include <vector>
template<typename T>
class Stack
{
std::vector<T> elems;
public:
Stack() = default;
Stack(const T& elem)
: elems{elem}
{
}
void push(const T& elem)
{
elems.push_back(elem);
}
const T& top() const
{
return elems.back();
}
};
Параметр конструктора содержит достаточно информации для вывода T:
Stack stack{42}; // T = int, тип Stack<int>
Отдельного внимания заслуживает явно заданный конструктор по умолчанию:
Stack() = default;
После объявления другого конструктора компилятор больше не объявляет обычный конструктор по умолчанию автоматически. Запись = default запрашивает его явно. Однако такой конструктор не помогает CTAD, потому что его список параметров не содержит информации, из которой можно вывести T:
Stack<int> first; // корректно: специализация известна
Stack second; // ошибка: T невозможно вывести
Почему фигурные скобки имеют значение
В инициализаторе поля используются фигурные скобки:
Stack(const T& elem)
: elems{elem}
{
}
При T = int выражение elems{elem} инициализирует вектор списком из одного элемента. Запись elems(elem) вместо этого могла бы выбрать конструктор вектора, который интерпретирует целое число как размер:
std::vector<int> oneElement{5}; // один элемент со значением 5
std::vector<int> fiveElements(5); // пять элементов, инициализированных значением по умолчанию
Как стандарт описывает CTAD
Стандарт C++ описывает CTAD с помощью набора воображаемых функций, называемых guides. Для каждого конструктора шаблона класса компилятор формирует кандидат с тем же списком параметров и возвращаемым типом, обозначающим соответствующую специализацию класса.
Для объяснения можно представить, что конструктор
template<typename T>
class Stack
{
public:
Stack(const T&);
};
создаёт кандидат, похожий на следующую функцию:
// Только для объяснения: эта функция в программе не объявляется.
template<typename T>
auto F(const T&) -> Stack<T>;
Для инициализатора 42 вывод аргументов шаблона даёт T = int. Следовательно, возвращаемым типом выбранного кандидата становится Stack<int>.
Стандарт указывает, что placeholder шаблона класса заменяется возвращаемым типом, выбранным посредством overload resolution для вывода аргументов шаблона класса. Концептуально объявление можно прочитать следующим образом:
// Записано программистом:
Stack stack{42};
// Тип выбран посредством CTAD:
Stack<int> stack{42};
После определения специализации объявление всё ещё должно успешно инициализировать объект полученного типа. Кандидат, сформированный из конструктора, может одновременно определить специализацию и указать конструктор, который выполнит инициализацию. Пользовательский deduction guide, напротив, может определить специализацию, тогда как объект будет инициализирован другим, настоящим конструктором.
Эта модель описана в разделах [dcl.type.class.deduct] и [over.match.class.deduct]. Она определяет семантику языка, а не обязательную последовательность внутренних проходов компилятора.
Строковые литералы: ссылка и передача по значению
Строковые литералы демонстрируют важную особенность вывода аргументов шаблона. Литерал "hello" имеет тип массива const char[6]: пять символов и завершающий нулевой символ.
Когда конструктор принимает аргумент по ссылке, массивный тип сохраняется:
Stack(const T& elem);
Для следующего объявления вывод даёт T = char[6]. Квалификатор const задаётся шаблоном параметра const T&:
Stack stack{"hello"}; // попытка сформировать Stack<char[6]>
Эта специализация не подходит для показанной реализации. Обычный массив не удовлетворяет требованиям, предъявляемым к стандартному элементу std::vector, поэтому попытка инстанцировать std::vector<char[6]> делает объявление некорректным.
Одно из возможных решений — принимать аргумент конструктора по значению:
#include <utility>
#include <vector>
template<typename T>
class Stack
{
std::vector<T> elems;
public:
Stack() = default;
Stack(T elem)
: elems{std::move(elem)}
{
}
};
При выводе для параметра, принимаемого по значению, аргумент-массив проходит стандартное преобразование массива в указатель:
Stack stack{"hello"}; // Stack<const char*>
Теперь объявление корректно, но стек хранит указатели, а не строки. Он не владеет данными, на которые указывают эти указатели. Хотя строковый литерал имеет статическую продолжительность хранения, тот же тип стека впоследствии может получить указатели на буферы с меньшим временем жизни.
Deduction guide для строк с владением данными
Пользовательский deduction guide может сопоставить определённую форму входных аргументов с другой специализацией. Чтобы строковые литералы хранились как владеющие строки, после определения шаблона класса нужно объявить следующее правило:
Stack(const char*) -> Stack<std::string>;
Список параметров слева участвует в overload resolution. Тип справа является специализацией, которая выбирается при победе этого кандидата.
Полный пример с владеющими строками
#include <string>
#include <utility>
#include <vector>
template<typename T>
class Stack
{
std::vector<T> elems;
public:
Stack() = default;
Stack(T elem)
: elems{std::move(elem)}
{
}
const T& top() const
{
return elems.back();
}
};
Stack(const char*) -> Stack<std::string>;
int main()
{
Stack integers{42}; // Stack<int>
Stack text{"hello"}; // Stack<std::string>
}
Для text deduction guide выбирает Stack<std::string>. Затем компилятор инициализирует объект этой специализации, вызывая её конструктор. Преобразование "hello" в std::string происходит при инициализации параметра конструктора, а не при применении deduction guide.
Различие становится заметным при использовании разных форм инициализации:
Stack invalid = "hello"; // вывод успешен, инициализация завершается ошибкой
Stack valid{"hello"}; // Stack<std::string>
Для invalid deduction guide всё ещё может определить Stack<std::string>. Однако copy-initialization потребовала бы двух пользовательских преобразований в одной последовательности:
const char* -> std::string -> Stack<std::string>
Такая последовательность недопустима. При прямой инициализации списком компилятор непосредственно выбирает конструктор Stack<std::string>, поэтому требуется только преобразование аргумента в std::string.
Вывод при копировании
В набор кандидатов CTAD входит специальный copy deduction candidate. Он позволяет вывести специализацию из другого объекта того же шаблона класса:
Stack first{"hello"}; // Stack<std::string>
Stack second{first}; // Stack<std::string>
Stack third(first); // Stack<std::string>
Stack fourth = {first}; // Stack<std::string>
Тип first уже содержит аргумент шаблона std::string. Поэтому copy deduction candidate выбирает ту же специализацию для нового объекта. При этом выбранный объект по-прежнему должен допускать копирование или другую соответствующую форму инициализации.
Существование этого кандидата определено в разделе [over.match.class.deduct] вместе с кандидатами, сформированными из конструкторов и пользовательских deduction guides.
Шаблонные агрегаты: C++17 и C++20
Шаблоны классов также могут быть агрегатами. Рассмотрим простую структуру, которая хранит значение и комментарий:
#include <string>
template<typename T>
struct ValueWithComment
{
T value;
std::string comment;
};
Правила CTAD для агрегатов различаются между C++17 и C++20:
| Стандарт | Вывод для этого агрегата |
|---|---|
| C++17 | Требуются явные аргументы шаблона или пользовательский deduction guide. |
| C++20 | Компилятор может сформировать aggregate deduction candidate из элементов агрегата. |
В C++17 недостающее правило вывода можно задать явно:
template<typename T>
ValueWithComment(T, const char*)
-> ValueWithComment<T>;
ValueWithComment item{42, "initial value"};
// ValueWithComment<int>
Deduction guide выбирает ValueWithComment<int>. После этого два поля полученной специализации инициализируются значениями 42 и "initial value".
Deduction guide также может намеренно изменить тип, выбранный для аргумента:
ValueWithComment(const char*, const char*)
-> ValueWithComment<std::string>;
ValueWithComment item{"hello", "initial value"};
// ValueWithComment<std::string>
Начиная с C++20 простой пример с целым числом может использовать автоматический вывод для агрегата:
ValueWithComment item{42, "initial value"};
// C++20: ValueWithComment<int>
Компилятор формирует aggregate deduction candidate, когда класс удовлетворяет соответствующим требованиям к агрегату, инициализатор подходит для такого вывода и для шаблона класса не объявлены пользовательские deduction guides. Для более сложных агрегатов могут действовать дополнительные ограничения, связанные с базовыми классами, массивами и пакетами параметров.
Что нужно запомнить
- CTAD появился в C++17 и позволяет выбрать специализацию шаблона класса по инициализатору.
- Конструкторы создают неявные кандидаты вывода с соответствующими списками параметров.
- Возвращаемый тип кандидата, выбранного посредством overload resolution, заменяет placeholder шаблона класса.
- После вывода выбранная специализация всё ещё должна быть успешно инициализирована.
- Deduction guide выбирает специализацию, но не является конструктором и сам не выполняет преобразования.
- Параметры-ссылки сохраняют массивный тип строковых литералов, а при передаче по значению массив преобразуется в указатель.
- Deduction guide может сопоставить
const char*сstd::string, если контейнер должен владеть текстом. - Copy deduction candidate переносит аргументы шаблона из другого объекта того же шаблона класса.
- В C++17 для CTAD агрегата требуется пользовательский guide, а в C++20 для подходящих агрегатов может формироваться автоматический aggregate deduction candidate.