В данной статье мы рассмотрим, зачем в стандартной библиотеке нужна конструкция для вывода общего типа, как она реализована и как она работает.
В начале я хотел бы сказать спасибо своему коллеге khandeliants, который помог прояснить некоторые неочевидные для меня моменты относительно трактовки стандарта С++, а также доработать примеры кода.
Зачем это пригодилось нам
Всё началось с того, что команда PVS-Studio начала активно дорабатывать ядро С++ анализатора. Одна из больших задач – внедрение новой системы типов. Сейчас она состоит из специально закодированных строчек, мы же хотим заменить её на иерархическую систему. Подробно на новой системе типов я останавливаться не буду. В общих чертах мы пытаемся сделать из этого:
это:
Подробно и со смешными картинками о нашей новой и старой системе типов не так давно рассказывал мой коллега Юрий на конференции itCppCon21. Сейчас, как мне кажется, у него набралось материала на два или три новых доклада. Будем с нетерпением их ждать :)
У новой системы типов есть аналоги type_traits, они так же, как и их прародители, помогают модифицировать тип или получить о нём нужную информацию.
Недавно я писал реализацию std::common_type для нашей системы типов. Этот трейт часто применяется в метапрограммировании, когда требуется вывести общий тип для произвольного числа переданных типов. Нам же подобный трейт оказался удобен для вывода результирующего типа, например, когда мы обходим бинарное выражение и встречаем арифметическую операцию:
if (operationInfo->m_type == OperatorType::Arithmetic)
{
auto leftType = TypeTraits::ExtractMemberType
(result->GetLeftOperand().GetType());
auto rightType = TypeTraits::ExtractMemberType
(result->GetRightOperand().GetType());
auto resType = Types::Traits::CommonType(leftType, rightType);
....
}
Код, который нужно было задействовать для такой операции, раньше был более громоздким и сложным, теперь же он выглядит довольно изящно.
Зачем он вообще нужен C++ разработчикам
Допустим, что мы хотим написать наивную реализацию шаблона функции для скалярного произведения двух векторов, причем векторы могут быть инстанцированы различными типами. Требуется, чтобы тип скалярного произведения выводился автоматически. Такой шаблон функции начиная с C++14 можно реализовать как-то так:
#include <vector>
template <typename T, typename U>
auto dot_product(const std::vector<T> &a, const std::vector<U> &b)
{
// some bounds checks
??? result {};
auto a_it = a.begin();
auto b_it = b.begin();
while (a_it != a.end())
{
result += static_cast<???>(*a_it++) * static_cast<???>(*b_it++);
}
return result;
}
Подразумевается, что в функцию приходят векторы одинакового размера. В противном случае мы не сможем посчитать скалярное произведение и получим выход за границу массива.
Итак, функция делает именно то, что мы и хотели. Компилятор сам выводит результирующий тип из return statement за нас. Осталась одна проблема – для переменной result нужно как-то вывести общий тип.
Но перед тем, как писать код, давайте рассмотрим такую интересную языковую конструкцию, как тернарный оператор. Возможно, он сможет помочь нам в этом непростом деле.
Conditional operator
Как мне кажется, приводить описание работы тернарного оператора из стандарта будет лишним, так как оно довольно большое. Поэтому вкратце рассмотрим, как происходит вывод типа в основных случаях.
Для наглядности будем визуализировать результаты с помощью двух вещей:
- вариативный шаблон класса tp без определения, который позволит узнать имя результирующего типа при помощи ошибки компиляции;
- Clang AST: покажет абстрактное синтаксическое дерево программы.
Перейдём к случаям:
Случай 1
Если и второй, и третий операнд имеют тип void, то результат также имеет тип void. Такое возможно, например, если оба выражения содержат throw, либо вызовы функций, возвращающих void, либо явное преобразование к типу void. Пример кода с выводами сообщений компиляторов:
void foo();
void bar();
int foobar();
float barfoo();
template <typename ...>
struct tp; // type printer
void examples(bool flag)
{
tp<decltype(flag ? foo() : bar()), // void
decltype(flag ? (void) foobar() : (void) barfoo()), // void
decltype(flag ? throw 0 : throw 3.14)> _; // void
}
Если второй или третий операнд – выражение throw, то результирующий тип выводится из другого. Другой операнд при этом не должен быть типа void. Пример кода с выводами сообщений компиляторов:
char arr[16];
template <typename ...>
struct tp; // type printer
void examples(bool flag)
{
tp<decltype(flag ? nullptr : throw "abs"), // nullptr_t
decltype(flag ? 3.14 : throw 3.14), // double
decltype(flag ? arr : throw 3.14)> _; // char (&)[16]
}
Случай 2
Если второй и третий операнд имеют разные типы, и при этом один из них классового типа, то выбирается такая перегрузка, которая сформирует операнды одинаковых типов. Например, может быть выбран конвертирующий конструктор или оператор неявного преобразования. Пример кода c выводами сообщений компиляторов:
template <typename ...>
struct tp; // type printer
struct IntWrapper
{
IntWrapper(int)
{
// ....
}
};
void examples(bool flag)
{
tp<decltype(flag ? IntWrapper {42} : 42)> _;
}
Если посмотреть Clang AST этого кода, то можно заметить:
....
-FunctionDecl <line:9:1, line:12:1> line:9:6 foo 'IntWrapper (bool)'
|-ParmVarDecl <col:10, col:15> col:15 used b 'bool'
`-CompoundStmt <line:10:1, line:12:1>
`-ReturnStmt <line:11:3, col:34>
`-ConditionalOperator <col:10, col:34> 'IntWrapper'
|-ImplicitCastExpr <col:10> 'bool' <LValueToRValue>
| `-DeclRefExpr <col:10> 'bool' lvalue ParmVar 0x558edcfc99d8 'b' 'bool'
|-CXXTemporaryObjectExpr <col:14, col:30> 'IntWrapper' 'void (int)' list
| `-IntegerLiteral <col:27> 'int' 42
`-ImplicitCastExpr <col:34> 'IntWrapper' <ConstructorConversion> // <=
`-CXXConstructExpr <col:34> 'IntWrapper' 'void (int)'
`-IntegerLiteral <col:34> 'int' 42 // <=
Здесь Clang неявно вызвал конвертирующий конструктор для третьего операнда, отчего оба операнда стали типа IntWrapper.
Случай 3
Над вторым и третьим операндом применяются standard conversions: lvalue-to-rvalue, array-to-pointer и function-to-pointer. После этих конверсий возможны несколько ситуаций:
Второй и третий операнды имеют одинаковый тип, тогда таким же будет и результирующий. Пример кода c выводами сообщений компиляторов:
template <typename ...>
struct tp; // type printer
struct MyClass
{
// ....
};
void examples(bool flag)
{
tp<decltype(flag ? MyClass {} : MyClass {})> _;
}
Также второй и третий операнды могут иметь арифметический тип или тип перечисления. Для арифметических типов и перечислений будут применяться usual arithmetic conversions для формирования общего типа, который и будет результирующим. Пример кода c выводами сообщений компиляторов:
template <typename ...>
struct tp; // type printer
void examples(bool flag)
{
char ch = 1;
short sh = 2;
double d = 3;
float f = 4;
unsigned long long ull = 5;
long double ld = 6;
tp<decltype(flag ? ch : sh),
decltype(flag ? f : d),
decltype(flag ? ull : ld) > _;
}
Также один или оба операнда могут иметь тип указателя или указателя на член класса. Тогда применяются правила pointer conversions/pointer-to-member conversions, function pointer conversions и qualification conversions для формирования композитного типа указателя, который и будет результирующим. Пример кода c выводами сообщений компиляторов:
template <typename ...>
struct tp; // type printer
struct MyBaseClass
{
// ....
};
struct MyClass : MyBaseClass
{
// ....
};
void examples(bool flag)
{
auto a = new MyClass();
auto b = new MyBaseClass();
tp<decltype(flag ? a : b)> _;
}
Также оба операнда могут иметь тип std::nullptr_t, либо один из них std::nullptr_t, а другой – константа нулевого указателя. Тогда результирующий тип – std::nullptr_t. Пример кода c выводами сообщений компиляторов:
#include <cstddef>
template <typename ...>
struct tp; // type printer
void examples(bool flag)
{
tp<decltype(flag ? std::nullptr_t {} : nullptr )> _;
}
Теперь мы видим, что вывести общий тип очень просто: в большинстве случаев достаточно воспользоваться тернарным оператором. Давайте отвлечемся от теории и попробуем применить только что приобретённые знания и написать обобщённый код, который будет это делать.
P.S.: Для того, чтобы написать нужный нам аналог std::common_type для новой системы типов (TypeTraits::CommonType), нам пришлось реализовать все вышеописанные и некоторые нерассмотренные правила вывода общего типа.
Пишем свой common_type
Вернёмся к нашей функции скалярного произведения векторов. Начиная с C++11 в нашем распоряжении есть спецификатор decltype, который возвращает тип переданного в него выражения. Мы уже использовали его выше для работы с type_printer. Из прошлого абзаца мы знаем, что если протолкнуть в него вызов тернарного оператора с объектами двух наших типов, то компилятор сделает за нас вывод общего типа.
Попробуем применить сказанное в действии:
#include <vector>
template <typename T, typename U>
auto dot_product(const std::vector<T> &a, const std::vector<U> &b)
{
// ....
decltype(true ? std::declval<T>() : std::declval<U>()) result {};
// ....
}
Давайте подробно разберем, что делает этот код:
- При помощи спецификатора decltype мы возвращаем тип выражения в скобках.
- Внутри decltype применяем тернарный оператор. Первым операндом можно сделать любое выражение, например true.
- Во второй и третий операнды располагаем переданные шаблонные типы. Только есть одна проблема – тернарный оператор оперирует выражениями. Поэтому "создадим" объекты через std::declval.
std::declval<T> – это шаблон функции без реализации, который возвращает rvalue-ссылку на тип T. При типе T = void возвращает тип void. Этот шаблон чаще всего применяется внутри невычисляемого контекста (decltype, sizeof, requires,....) и позволяет как бы работать с объектом переданного типа, обходя вызов конструктора. Это особенно полезно, если тип T не имеет публичного конструктора по умолчанию либо он удален.
Не забываем, что нам также могут передать ссылки в качестве типа, поэтому стоит применить std::decay. Он уберет CV-квалификаторы, ссылки, добавит указатели функциям (function-to-pointer conversion) и преобразует массивы в указатели (array-to-pointer conversion):
#include <vector>
template <typename T, typename U>
auto dot_product(const std::vector<T> &a, const std::vector<U> &b)
{
// ....
std::decay_t<
decltype( true ? std::declval<typename std::decay<T>::type>()
: std::declval<typename std::decay<U>::type>()
)
> result {};
// ....
}
Согласитесь – писать такое в коде совсем не хочется. Попробуем немного причесать код, для этого нам надо написать пару вспомогательных шаблонов классов для удобства. Во-первых, попробуем написать класс для вывода общего типа из двух переданных:
template <class T, class U>
struct common_type
{
using type = std::decay_t<
decltype( true ? std::declval< std::decay_t<T> >()
: std::declval< std::decay_t<U> >() ) >;
};
Теперь можем применить этот common_type в нашем коде:
#include <vector>
template <typename T, typename U>
auto dot_product(const std::vector<T> &a, const std::vector<U> &b)
{
// ....
common_type<T, U>::type result {};
// ....
}
Отлично, мы избавились от всей этой страшной портянки, код стал лаконичным. Но хочется теперь сделать common_type таким, чтобы он умел работать с любым числом переданных типов – от нуля до произвольного. Тогда немного поменяем наш основной шаблон класса и его специализации:
#include <type_traits>
template <typename ...>
struct common_type; // (1)
template <typename ...Ts>
using common_type_t = typename common_type<Ts...>::type;
template <>
struct common_type<> // (2)
{
};
template <class T>
struct common_type<T> // (3)
{
using type = std::decay_t<T>;
};
template <class T, class U>
struct common_type<T, U> // (4)
{
using type = std::decay_t<
decltype( true ? std::declval< std::decay_t<T> >()
: std::declval< std::decay_t<U> >() ) >;
};
template <class T, class U, class... V>
struct common_type<T, U, V...> // (5)
{
using type = typename common_type
<typename common_type<T,U>::type, V...>::type;
};
Стоит отметить, что примерно так common_type и реализован в стандартной библиотеке. Теперь, давайте подробно разберём, что тут происходит:
- Объявляется основной вариативный шаблон класса.
- Для пустого списка шаблонных аргументов сделаем явную специализацию шаблона, которая ничего не содержит.
- Для одного шаблонного аргумента сделаем частичную специализацию, внутри которой будет лежать этот же тип после std::decay, который уберёт CV-квалификаторы, ссылки, добавит указатели функциям (function-to-pointer conversion) и преобразует массивы в указатели (array-to-pointer conversion).
- Для двух шаблонных аргументов сделаем частичную специализацию, которая выведет результирующий тип на основе правила вывода общего типа тернарного оператора, применив перед этим std::decay на переданные аргументы.
- Для трех и более шаблонных аргументов сделаем частичную специализацию, которая сначала посчитает общий тип для первых двух аргументов при помощи специализации для 2 аргументов. Затем она рекурсивно инстанцирует себя, передав в качестве шаблонных аргументов общий тип для первой пары типов и оставшийся пакет шаблонных параметров. По сути, common_type<a, b, c, d> эквивалентно common_type<common_type<common_type<a, b>, c>, d>. Пример на C++ Insights.
Как я уже говорил выше, более подробно, с правилами вывода типа для самого тернарного оператора можно ознакомиться в стандарте. Я использовал последний актуальный рабочий черновик, там их можно найти в главе 7.6.16. Сами черновики можно посмотреть, например, здесь. Также можно воспользоваться документацией на cppreference.
Заключение
Мы посмотрели, как работает std::common_type и, чтоб более подробно в нём разобраться, написали его реализацию, почитав стандарт, и даже немного затронули логику работы тернарного оператора. Надеюсь, эта статья оказалась полезна. Всем спасибо за внимание!
Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Vladislav Stolyarov, Phillip Khandeliants. An article for those who, like me, do not understand the purpose of std::common_type.