Вывод типов и работа с типами в языке C++ Метаданные автор: Владимиров Константин Игоревич email: konstantin.vladimirov@gmail.com rsdn nickname: Tilir company: SMWare Inc. ключевые слова: C++, C++14, type inference, decltype, auto, type traits Аннотация В этой статье освещены вопросы вывода типов в языке C++ с учетом дополнений стандарта 2014-го года. Также рассмотрены новые средства для работы с типами: определители типов, хелперы, новый механизм синонимов типов. Annotation This article presents the new type inference features of the C++ standard, regarding addendum from 2014 standard. Also considered new type traits, helpers and type synonims. 1. Введение Стандарт C++98 [1] требовал явного указания типа для каждой переменной (7.1.5.2). Из этого правила не предусматривалось исключений, и это создавало опасные ситуации, особенно, когда тип переменной сам по себе записывался сложным или неочевидным выражением. map>::iterator i = m.begin(); map>::iterator j = i++; Очевидно, что в этом примере компилятор и так знает всё о статическом типе правой части (по крайней мере, грубая ошибка в левой части вызовет ошибку компиляции), так что необходимость писать тип сомнительна. Это было учтено и в 2011 году в стандарт языка C++ ввели новые ключевые слова auto и decltype для указания компилятору сделать в месте их использования вывод типа автоматически. Интересно, что модель вывода типов нигде в стандарте специфицирована не была -- разработчик компилятора до сих пор может реализовать любую модель, лишь бы она удовлетворяла описанию соответствующих возможностей в стандарте. По мере накопления опыта использования вывода типов, в 2014 году стандарт был пересмотрен и в декабре 2014 года был опубликован стандарт С++14 [2] на который здесь будут все ссылки, если не указано обратное. Корректным кодом на C++14 является следующий: map > m {{10, {"Hello", "World"}}, {20, {"Eric", "BloodAxe"}}}; auto i = m.begin (); auto j = i++; Здесь ключевое слово auto позволяет существенно сократить запись объявлений для переменных i и j. Кстати, в инициализации отображения m применено ещё одно нововведение -- списки инициализации, которое само по себе достойно отдельного изучения, скажем по [3]. Здесь и ниже предполагается что читатель знаком с идеей списков инициализации, если нет, то сейчас самое время сделать это. Для подробного знакомства с идеями, предшествующими введению вывода типов в C++ можно порекомендовать прекрасную статью Фрилингхауса [4]. Прекрасный обзор по возможностям вывода типов в C++11 содержится в книге Строструпа [3] и в статье Беккера [5], многие идеи и материалы этой статьи взяты из этих двух источников. Новые возможности стандарта C++14 по выводу типов частично изложены в традиционно хорошей книге Майерса [6], где кроме них изложена еще масса просветляющих вещей. 2. Auto и decltype Ключевое слово auto было проиллюстрировано выше. Оно на самом деле достаточно мощно, чтобы замещать тип во всех контекстах (пример из стандарта C++14 раздел 5.3.4.2) auto x = new auto('a'); выведет тип char справа и char* слева. Несколько контринтуитивно, что в следующем случае: auto x1 = { 'a', 'b' }; Будет выведен std::initializer_list, а вовсе не массив. Этот пример так же показывает, что на самом деле вывод типов различается для auto и для параметров шаблонов. В случае попытки использовать такое выражение как параметр шаблона, это будет ошибка. Также запрещено смешивать объявления разных типов. Таким образом вот это допустимо: auto x = 1; auto y = 1.0; А вот это нет: auto x = 1, y = 1.0; /* ERROR */ Хотя на первый взгляд написано одно и то же. Возможно это будет исправлено в более поздних версиях стандарта. Decltype подобно auto в том смысле, что вывод типов тоже происходит, только объект для его вывода непосредственно указывается программистом, а не выводится компилятором из правой части выражения: auto i = m.begin(); decltype(i) j = i++; Эти два способа вывода типа не изоморфны, потому что само наличие decltype может быть сопряжено с некоторыми концептуальными проблемами и самое интересное начинается внутри скобок. 2.1. Две формы decltype Рассмотрим тип Point, в котором поле x объявлено как int, а объект этого типа p объявлен как const Point&. Тогда возникает вопрос что должен вывести decltype, см. комментарий в следующем коде: struct Point { int x, y; }; Point porig {1, 2}; /* ... */ const Point &p = porig; decltype(p.x) x; /* здесь x это int& или const int&? */ Это действительно предмест для спора, так как опытный программист может аргументировать и ту и другую позицию. Автор проводил лекции на эту тему и мнения аудитории обычно разделялись в соотношении примерно 60/40. Для разрешения таких ситуаций, стандарт вводит (7.1.6.2) форму decltype с двумя круглыми скобками: decltype(p.x) x; /* int */ decltype((p.x)) x; /* const int& */ Кстати, вторая строчка является ошибкой компиляции, так как объявляет неинициализированную ссылку. На самом деле это различие более тонкое чем просто ещё одни скобки. Речь идёт о разнице между decltype(id-expr) и decltype(expr). Первое возвращает тип, с которым было объявлено имя, второе -- тип, который могло бы приобрести выражение при его вычислении. Как сделать имя выражением? Обернуть его скобками, очевидно. Это кажется некоторым переусложнением, но это на самом деле единственный разумный выход из ситуации. При этом форма decltype(expr) имеет особенность -- если тип под ним это lvalue, то он добавит ссылку (если её не было). Эта особенность имеет свое формальное объяснение, но интуитивно ясно, что подчеркивается возможность присваивания. int x; typedef decltype(x) xval; /* int */ typedef decltype((x)) xref; /* int& */ typedef decltype((x+1)) xval_again; /* int */ xval r0; xref r1 = x; xval_again r2; Между прочим выражение ``могло бы быть'' зафиксировано в стандарте (5.1.2/19) и это даёт забавные возможности. Скажем \lstinline!decltype(10000000)! это ``целый тип в котором могло бы поместиться 10000000''. Компилятор не обязан выводить наименьший целый тип, но обычно это происходит (в GCC это происходит всегда). 2.2. Вывод типа и cv-квалификация Работа с CV-квалифицированными типами для auto и decltype отличается. decltype обращается с ними гораздо более бережно, в то время как auto снимает самую внешнюю cv-квалификацию: const int s = 5; auto s1 = s; decltype (s) s2 = 3; s1 += 1; /* OK */ s2 += 1; /* ERROR, s2 is const int */ Впрочем, auto тоже сохранит cv-квалификацию, если будет использовано в уточненной форме: const int c = 0; auto& rc = c; rc = 44; // error: const qualifier was not removed Здесь странная форма auto& означает "автоматически вывести тип и добавить ссылку". Уточненная форма auto может содержать любые спецификаторы, допустимые для типов. В случае если в результате автоматического вывода будет выведена в свою очередь ссылка, они просто будут свернуты по правилам свертки ссылок (предполагается, что читатель знаком с этой концепцией, если нет, то пора прочитать об этом в [3]). Также auto не снимает константность если она относится не к верхнему уровню выведенного типа. int x = 42; const int* p1 = &x; auto p2 = p1; /* p2 is const int* */ p2 = &y; /* OK */ *p2 = 3; /* ERROR */ Это вполне логично: константность данных под указателем это слишком важная характеристика типа. В отличии от этого, константность самого указателя может быть сброшена легко: const int* const p1 = &x; auto p2 = p1; /* p2 is const int* */ p2 = &y; /* ok */ 2.3. Decltype(auto) Разные правила для decltype и auto привели в стандарте C++14 к введению идиомы decltype(auto), которая позволяет вывести тип из правой части автоматически, но именно так, как его вывел бы decltype. const int i; auto j = i; /* typeof(j) == int */ decltype (auto) k = i; /* typeof (k) == const int */ Очень интересный случай это выражение справа от decltype, заключенное в круглые скобки. int i; auto x4a = (i); /* decltype(x4a) is int */ decltype(auto) x4d = (i); /* decltype(x4d) is int& */ Таким образом моделируется поведение двух круглых скобок у decltype(expr). 3. Расширенный синтаксис функций Возможности автоматического вывода типов подразумевают построение абстракций с зависимыми типами. Ниже будет показано, что построение таких абстракций часто накладывает неразрешимые требования по выводу типов в старом синтаксисе и введен новый синтаксис функций. 3.1. Зависимые типы и необходимость расширенного синтаксиса Пусть необходим статический шаблонный контракт на любой тип T, поддерживающий функцию T::makeobject, возвращающую некий известный ей тип. Стандарт C++11 запрещает просто написать: template auto /* Error in C++11 */ makeAndProcessObject (const T& builder) { auto val = builder.makeObject(); /* do stuff with val */ return val; } Это запрещено потому что компилятор в точке объявления функции не обладает информацией о типе, который вернет T::makeobject. Забегая вперед -- иногда обладает, скажем в C++14 тут все хорошо. Точно так же не сработает вот такой выход: template decltype(builder.makeObject()) /* Error again! */ makeAndProcessObject (const T& builder) Здесь ошибка очевидна, потому что builder не может быть использован до точки своего объявления (которой является список аргументов функции). Конечно опытного программиста с опытом C++ это не остановит. Он использует тот факт, что значение под decltype не вычисляется, и сделает тонко: template decltype (((T*)0)->makeObject()) /* painfull but works */ makeAndProcessObject (const T& builder) Также возможно использование здесь declval если вы в курсе этой особенности языка. Но нельзя требовать от программистов такое всерьез, всегда и везде. Комитет по стандартизации решил эту проблему изящно, предложив расширенный синтаксис для обобщённых функций, возвращающих зависимые типы: template auto makeAndProcessObject (const T& builder) -> decltype (builder.makeObject()) { auto val = builder.makeObject(); /* do stuff with val */ return val; } Внутри скобок decltype в данном случае вычисление выражения (в том числе вызов функции) не происходит -- происходит только вывод типа. Конечно, здесь есть возможные ошибки и засады. В качестве примера можно рассмотреть следующую попытку написать type-generic минимум. \begin{lstlisting} template auto min(T x, S y) -> decltype(x < y ? x : y) { return x < y ? x : y; } \end{lstlisting} Увы, этот вариант небезопасен, поскольку decltype вокруг выражения работает как decltype(expr), а значит результат может быть выведен как ссылка, что чревато. Так как же все таки правильно написать type-generic minimum? Для этого требуется работа с функтором remove_reference, что выходит за пределы этой статьи. Зато теперь можно понять почему же такое поведение decltype(expr) было выбрано комитетом по стандартизации. Рассмотрим упрощенную задачу -- допустим речь идет о выводе типа для доступа к элементу массива: template auto array_access(T& array, size_t pos) -> decltype(array[pos]) { return array[pos]; } С текущим подходом можно использовать такую обертку прозрачно как если бы это действительно был доступ к элементу массива: std::vector vect = {42, 43, 44}; int* p = &vect[0]; array_access(vect, 2) = 45; array_access(p, 2) = 46; В противном случае пришлось бы идти на разнообразные хаки. 3.2. Новая форма функции main У автора этой статьи есть свои любимые особенности языка и в их числе, несомненно тот факт, что новый синтаксис объявления функций введен ортогонально старому и может быть последовательно использован везде, даже там, где никакого содержательного вывода нет. В том числе для функции main новая допустимая форма выглядит так: auto main () -> int По сравнению со стандартной int main () такая запись выглядит настоящим шагом вперед. Например она может быть использована в качестве compile-time assertion того факта, что ваш компилятор действительно поддерживает C++14. Увы, я пока не нашел иных содержательных аргументов в пользу новой формы функции main. 4. Коррективы в вывод типов для C++14 В некоторых простых случаях компилятору действительно не составляет проблем вывести тип функции: auto isquare (int x) -> decltype (x) { return x*x; } Здесь указание decltype выглядит просто излишним и C++14 разрешает его убрать: auto isquare (int x) { return x*x; } Для таких простых вариантов все хорошо, но как быть с рекурсией? Здесь возникает проблема: тип должен быть выведен до того, как рекурсивный вызов произошел: auto sum_to (int i) { if (i < 2) return i; // return type deduced as int else return sum_to (i-1) + i; // ok to call it now } cout << sum_to (10) << endl; Но если переставить возвраты в вышеприведенном коде, он не будет скомпилирован. auto bad_sum_to (int i) { if (i > 2) return bad_sum_to (i-1) + i; else return i; } Впрочем GCC 4.9.2 возвращает вполне человечное описание ошибки: error: use of ‘auto bad_sum_to(int)’ before deduction of ‘auto’ return bad_sum_to (i-1) + i; Увы, как уже было сказано, auto может убрать cv-квалификацию типа. auto Example(int const& i) { return i; } Здесь возвращаемый тип int. Конечно, в конкретном коде несложно вернуть квалификацию типа: auto const& Example(int const& i) { return i; } Но что делать в обобщенном коде? В обобщенном коде для точного вывода возвращаемого типа может быть использован decltype (auto) например так: template decltype(auto) example(Fun fun, Arg arg) { return fun(arg); } Теперь тип возвращаемого значения будет проброшен точно. С помощью техники perfect forwarding, которая выходит за рамки этой статьи (см. [6], где эта техника изложена отлично), можно написать совершенно прозрачную обертку: пробросив не только возвращаемое значение но и произвольные аргументы. В новом стандарте также нет проблем с тем, чтобы шаблонные методы выводили свой тип непосредственно из других методов того же класса без явного this (пример взят из стандарта C++14, 5.1.1.3) struct A { char g(); template auto f(T t) -> decltype(t + g()) { return t + g(); } }; Здесь вызов g() внутри скобок decltype не происходит, а возвращаемый тип разрешается в char. 5. Decaying и минимальные общие типы Всем знакома ситуация, когда более сложные типы могут быть использованы в качестве более простых (массив передан туда где нужен указатель, квалифицированный тип или ссылка -- туда, где ожидается значение, etc). Простой пример: int foo (const int &s) { return s + 2; } Здесь в выражении s + 2, s ведёт себя так, как будто его тип int. Тогда можно сказать, что const int & деградирует (decaying) к int в том же смысле, в каком массив деградирует к указателю, etc. Новый стандарт позволяет вручную ``деградировать'' тип: const int &i; std::decay::type j; /* int j */ /* and btw */ auto k = i; /* int k = i; */ Поскольку auto также осуществляет деградацию, можно считать decay + decltype способом вывести тот тип, который вывело бы auto. На механизме decay неявно построен механизм common_type, позволяющий вывести минимальный общий тип: template void foo(T lhs, S rhs) { { std::common_type::type k; // ... } В принципе минимальные общие типы не так уже и нужны. Обычное использование auto над любой смешанной операцией, как показано ниже, устроит не менее качественную деградацию: template void foo(T lhs, S rhs) { auto prod = lhs * rhs; //... } Мало того, такое расширение GCC давно известное и во многих других компиляторах как typeof, позволяло делать это и в старом стандарте. В новом же можно использовать decltype. typedef typeof(lhs * rhs) product_type; typedef decltype(lhs * rhs) product_type; В качестве полезного примера, можно привести смешанную арифметику для числового класса: template struct Number { T n; }; template Number::type> operator+(const Number& lhs, const Number& rhs) { return {lhs.n + rhs.n}; } int main() { Number i1 = {1}, i2 = {2}; Number d1 = {2.3}, d2 = {3.5}; std::cout << "i1i2: " << (i1 + i2).n << "\ni1d2: " << (i1 + d2).n << "\nd1i2: " << (d1 + i2).n << "\nd1d2: " << (d1 + d2).n << '\n'; } Пример взят из [7]. Гораздо более сложный пример в MSDN кажется существенно менее убедительным. 6. Определители типов Новый стандарт предлагает большое количество удобных стандартных шаблонов для получения более детальной информации о типах на этапе выполнения. Большинство из них должны как-то сообщать ответы ``да'' и ``нет'' на вопросы ``является ли это тем-то и тем-то?'', скажем: ``является ли тип аргумента указателем на функцию-член класса X с такой-то сигнатурой?''. Чтобы закодировать ответы, используется обертка над интегральными константами времени компиляции: template struct integral_constant; Теперь можно определить true_type как integral_constant и false_type как integral_constant. Все это уже объявлено в стандарте [2] и здесь и далее будет опускаться префикс std для более аккуратного представления кода. Можно продемонстрировать создание пользовательских констант: typedef integral_constant two_t; typedef integral_constant four_t; static_assert (two_t::value * two_t::value == four_t::value, "2*2 != 4"); Используя закодированные таким образом истину и ложь, можно определить простейший из определителей типов: является ли анализируемый тип интегральным (это такие типы как bool, char, short, int, long, long long и все их cv-квалификации). Как пример использования: можно потребовать интегрального T в шаблонной функции (есть надежда, что концепты в будущих стандартах дадут возможность сделать это более общим способом). template T f(T i) { static_assert(std::is_integral::value, "Integer required"); return i; } Имея два и более определителя, можно скомбинировать из них производные, скажем: template struct is_arithmetic : integral_constant::value || is_floating_point::value> {}; Можно потренироваться и определить является ли нечто указателем: template struct is_pointer_helper : false_type {}; template struct is_pointer_helper : true_type {}; template struct is_pointer : is_pointer_helper ::type> {}; Особое внимание здесь следует обратить на использование remove_cv, который сам является композитным хелпером из remove_const и remove_volatile которые по отдельности тоже не составляют проблем. Реализация remove_const также очевидна: template struct remove_const { typedef T type; }; template struct remove_const { typedef T type; }; Полное рассмотрение всех возможных traits не нужно -- они перечислены в стандарте и их несложно конструировать по мере необходимости. Можно запомнить (это примерно столь же полезная для запоминания информация как первый 21 знак числа пи), что все что угодно, что встречается в корректной программе на C++14 может быть отнесено к одному из 14 базовых классов traits и только к нему одному. Прекрасным и гораздо более подробным введением в использование определителей типов для метапрограммирования является презентация Уолтера Брауна [8] на CppCon'14. 7. Использование using Новое ключевое слово using было введено довольно давно для включения имен из пространств имен. Сейчас семантика его использования расширена до полной альтернативы typedef typedef int MyInt; using MyInt = int; Эти две строчки в C++14 эквивалентны. Зачем же нужно было перегружать новым смыслом иное ключевое слово, а не оставить и расширить typedef? Потому что using умеет больше, чем просто объявить тип. Речь о введении синонима для целого семейства типов. template using MyType = AnotherType< T, MyAllocatorType >; template using ptr = T*; ... MyType a; ptr x; Очень удобно совмещать новый using с определителями типов. Например такое переопределение, как приведенное ниже: template using decay_t = typename decay::type; Позволяет сделать in-place decayer для типов. Синонимы для всех распространенных определителей уже включены в стандарт. 8. Заключение Были рассмотрены основные возможности по выводу типов и по работе с типами в новом стандарте C++14. Основные концепции, заложившие основу языка в 2011 году сейчас выглядят гораздо более зрелыми и проработанными, механизм decltype(auto) решил проблему проброски возвращаемых значений, новые type traits и ключевое слово using позволяют изящное манипулирование пользовательским кодом на этапе компиляции. Разумеется, новые возможности таят в себе новые опасности, но тем более приятно, что развитие языка все ещё способно удивлять даже людей, посвятивших его изучению не один десяток лет. Развитая система типов в компилируемом языке без рантайм-поддержки является в современном мире условием sine qua non и то, что C++ стал в этом отношении намного более удобен в использовании, является непосредственной заслугой всего коммьюнити, которое начиная с 2009-го года активно работало над будущим языка. За кадром этой статьи остались новые потрясающие возможности C++, такие как rvalue references, variadic templates, lambda expressions, constant expressions и прочие, несомненно ожидающие своего рассмотрения. 9. Список литературы [1] ISO/IEC, "Information technology -- Programming languages -- C++", ISO/IEC 14882:1998, 1998 [2] ISO/IEC, "Information technology -- Programming languages -- C++", ISO/IEC 14882:2014, 2014 [3] The C++ Programming Language (4th Edition) Addison-Wesley ISBN 978-0321563842, 2013 [4] Stefan Schulze Frielinghaus "C++0x Type Inference", 2009 [5] Thomas Becker "C++ auto and decltype Explained", 2013, http://thbecker.net/articles/auto_and_decltype/section_01.html [6] Scott Meyers, Effective Modern C++: 42 Specific Ways to Improve Your Use of C++11 and C++14, 2014 [7] http://en.cppreference.com/w/cpp/types/common_type [8] Walter Brown, "Modern Template Metaprogramming: A Compendium", CppCon 2014