Процесс добавления и корректировки функционала, что должен составить C++17 наконец завершён, а посему можно приступать к его обзору. Т.к. нововведений, которые касаются непосредственно языка, в C++17 не так уж и много, а сами нововведения не слишком значительны, я решил объединить их все в одной статье. Решить то я решил, а потом начал писать. В результате, в этой статье описаны всего две новых функциональности: выражение свёртки и выведение шаблонных параметров класса. Поэтому это станет первой частью цикла, размер которого сейчас мне сложно оценить. Хочу лишь уточнить, что досконально и скрупулёзно все нововведения C++17 я разбирать не стану, т.к. некоторые вещи я считаю настолько незначительными, что не вижу смысла их вообще упоминать. Если же вам интересен полный список изменений, вы можете найти его по этой ссылке. Сразу хочется предупредить, что на момент написания статьи ни один выпущенный компилятор не поддерживает всех вещей, описанных в оной, поэтому это скорее задел на хоть и скорое, но всё-таки будущее.
Сворачиваемся
Свёртка это устоявшееся понятие в программировании о котором вы можете прочитать в википедии.
Когда с выходом стандарта 2011 года у нас появилась возможность использовать шаблоны с переменным количеством параметров, очень быстро стало понятно, что работать с ними не так удобно, как хотелось бы. Так, для развёртывания пачки параметров, нам приходилось идти на различные ухищрения, которые включали как рекурсивное инстанциирование шаблонов, так и откровенное «злоупотребление» различными частями языка, которые позволяли нам достичь желаемого результата. Различные примеры решений и ухищрений я уже приводил в статье, посвящённой нововведениям в шаблонах 11-го года.
К сожалению, подобные решения хотя и выполняли то, что от них требовалось, давали на выходе код, который было весьма сложно читать. Делая восприятие результата метапрограммирование ещё более сложным. Но по прошествии 6-и лет комитет по стандартизации всё-таки пришёл к некоторому консенсусу и добавил новый функционал в язык, который позволяет упростить работу с пачками шаблонных параметров.
Давайте рассмотрим несколько модифицированный (для удобства) пример из ранее упомянутой статьи:
#include <iostream>
#include <string>
template <bool Bit>
std::string bitToString()
{
return Bit ? "1" : "0";
}
template<typename... Args>
void stub(Args&&...) {}
template <bool... Bits>
std::string linearBitsUnrolling()
{
std::string bits;
stub((bits += bitToString<Bits>())...);
return bits;
}
int main()
{
std::cout << linearBitsUnrolling<1, 0, 1, 0, 0, 0>() << "\n";
return 0;
}
Где я сокрушаюсь, мол надо создавать какие-то непонятные функции stub, чтобы достичь нужного результата, так ещё и результат неизвестно каким получится, ведь использование аргументов функций для раскрытия пачки не даёт нам никаких гарантий, что мы получим ожидаемый результат — порядок вычисления аргументов функций стандартом не гарантируется! И мечтаю, что было бы классно, если бы можно было писать вот так:
template <bool... Bits>
std::string linearBitsUnrolling()
{
std::string bits;
(bits += bitToString())...;
return bits;
}
Помимо того, что это выглядит проще и не заставляет нас создавать лишних функций, это ещё и даёт предсказуемый результат (конструкция вымышленная, и она вычисляет свои аргументы последовательно слева направо). Раз уж я затронул это тему, очевидно, что моя «мечта» всё-таки сбылась. Но прежде чем мы перепишем этот старый пример согласно новым реалиям, предлагаю эти новые реалии рассмотреть.
Унарные выражения
Итак, в C++, редакции 2017 года, появляется новый тип выражения, который называется выражением свёртки (fold expression). Такое выражение имеет смысл лишь вкупе с нераскрытой пачкой шаблонных параметров и не существует вне оной. Любое выражение свёртки должно быть заключено в скобки и в общем виде выглядит так: (выражение содержащее пачку аргументов). Выражение внутри скобок, как мы уже сказали, должно содержать в себе нераскрытую пачку параметров и один из следующих операторов:
+ - * / % ^ & | = < > << >>
+= -= *= /= %= ^= &= |= <<= >>=
== != <= >= && || , .* ->*
Т.е. фактически любой бинарный оператор, доступный в других C++ выражениях, также является оператором свёртки. Хотя все эти операторы и являются бинарными, выражения свёртки могут быть как унарными, так и бинарными. Унарным выражением свёртки является такое выражение, в котором из операндов присутствует только нераскрытая пачка и один из вышеперечисленных операторов, пример:
#include <iostream>
template <int... values>
constexpr int sum()
{
return (values + ...);
}
int main()
{
std::cout << sum<1, 2, 3, 4, 5>() << "\n";
return 0;
}
Как вы можете видеть, в нашем выражении свёртки слева стоит нераскрытая пачка values, потом идёт желаемый оператор (плюс) и, наконец, давно знакомое нам троеточие, которое говорит о том, что здесь будет происходить раскрытие пачки. Выражение, приведённое в примере, называется правым унарным выражением свёртки, но раз есть правое, значит есть и левое: (… + values). В чём же разница? Разница в том, как это выражение будет раскрываться. Чтобы упростить дальнейшее рассмотрение, давайте введём несколько сокращений:
- ПП — [нераскрытая] пачка параметров
- ПN — один из параметров [раскрытой] пачки, где N может быть от 0 до (sizeof…(ПП) – 1)
- # — оператор свёртки
- X — операнд, не являющийся пачкой параметров, т.е. любой простой объект C++ (или выражение).
Так, если у нас есть правое унарное выражение свёртки типа (ПП # …), оно будет раскрыто следующий образом: П0 # (П1 # (П2 # ( … ПN) …). Что для нашего примера с суммой даст следующую последовательность: (1 + (2 + (3 + (4 + 5)))). Если же у нас есть левое унарное выражение типа (… # ПП), то раскрываться оно будет по другому: ( … ((П0 # П1) # П2) # П3) # … ПN). Изменив наш пример на (… + values), получим следующую цепочку: ((((1 + 2) + 3) + 4) + 5). Конечно, в примере с оператором + этот порядок совершенно не важен, но можно предположить, что такие случаи, в которых порядок будет важен, найдут своих пользователей. Мы же, вооружившись полученными знаниями, перепишем мой пример из грёз:
template <bool... Bits>
std::string linearBitsUnrolling()
{
std::string bits;
((bits += bitToString<Bits>()), ...);
return bits;
}
Пример перекочевал почти без изменений! Да, он не настолько интересен, как мне того хотелось 4 года назад, но сейчас, смотря на него, я понимаю, что то о чём я мечтал (буквально тот синтаксис) породило бы одну серьёзную проблему: что делать с результатами выражений, коих может быть множество? Оператор запятая решает это проблему, имея чёткие правила, прописанные в языке. Поэтому можно считать, что «мечта» сбылась.
Бинарные выражения
Ранее рассмотренные нами выражения сплошь являются унарными, хотя и используют бинарные операторы. Но унарные выражения являются не единственными допустимыми выражениями свёртки — есть и их бинарные собратья. Бинарные выражения отличаются тем, что помимо пачки параметров и оператора свёртки, в выражении присутствует ещё один операнд, который не является пачкой параметров; т.е. обычный объект или выражение. К примеру, следующее выражение (внутри функции) является левым бинарным выражением свёртки:
#include <iostream>
template <size_t... flags>
constexpr size_t setFlags(size_t existingMask)
{
return (existingMask |= ... |= flags);
}
int main()
{
std::cout << setFlags<1, 2, 4>(0) << "\n";
return 0;
}
Давайте разберём вышеприведённое выражение. Как вы видите, оно похоже на унарного собрата, но с некоторыми отличиями. В общем виде (приняв ранее представленные обозначения), левое бинарное выражение свертки может быть записано так: (X # … # ПП), которое будет раскрываться по следующей схеме: (…(X # П1) # П2) # … ПN-1) # ПN), что для нашего примера даст: (((existingMask |= 1) |= 2) |= 4). Теперь давайте рассмотрим, как будет выглядеть правое бинарное выражение:
(flags |= ... |= existingMask)
Теперь давайте запишем правое бинарное выражение свертки в общем виде: (ПП # … # X). Раскрываться оно будет следующим образом: (П1 # (П2 # … # (ПN-1 # (ПN # X)) …), и вот как это будет выглядеть с нашим примером: (1 |= (2 |= (4 |= existingMask))). Не нужно быть экспертом в языке, чтобы заметить, что здесь явно что-то не так: как модифицирующий оператор |= может быть применён к константе, коей является 1? Никак, поэтому вышеприведённый пример с маской работает исключительно с левым бинарным выражением свёртки и не может быть собран с правым — компилятор не позволит. Все эти левые/правые выражения и куча скобок могут выглядеть запутанно, но главное, что глядя на выражение, вы, в общем-то, можете интуитивно понять, что будет происходить: у нас слева стоит маска, а справа набор флагов, значит все эти флаги будут логически сложены с маской. Т.е. вопреки сложности общей записи, функционал довольно логичен и интуитивно понятен.
Рассмотрев синтаксис бинарных и унарных выражений, можно заметить следующее: чтобы определить, является выражение свёртки правым или левым, нужно посмотреть на положение многоточия, относительно нераскрытой пачки параметров.
Познакомившись с новым функционалом, предлагаю переписать мой изначальный пример, с использованием более подходящих средств:
template <bool... Bits>
std::string linearBitsUnrolling()
{
std::string bits;
(bits += ... += bitToString<Bits>());
return bits;
}
Т.е. мы перешли от использования универсального оператора запятая, к более подходящему, в данном случае, присваивающему оператору сложения.
Кстати, если вы не просто просмотрели описание этого нового функционала, а поразмыслили над ним, то у вас вероятно возник вопрос, а что делать, если пачка параметров пустая, что тогда произойдёт? Этот момент был учтён комитетом и были введены следующие правила: пустая пачка параметров заменяется на предопределённое значение, в зависимости от оператора свёртки. Вот какие значения определены в стандарте:
Оператор
|
Значение
|
&&
|
true
|
||
|
false
|
,
|
void()
|
Эти значения довольно логичны, правда, как вы можете видеть, далеко не все операторы имеют значения по умолчанию, поэтому с большинством операторов пустая пачка работать просто не будет. И это понятно — что должно быть поставлено по умолчанию в пример выше, если Bits является пустой пачкой?
В целом, некоторые операторы у меня вызывают вопросы: не совсем понятно как использовать те же операторы сравнения, какой в них прок? Нет, я не призываю их убрать — пусть будут, может где и пригодятся, но лично я им применения пока не вижу. Да и вообще, весь этот новый функционал выглядит удобно, интересно но … как-то непродуманно что ли. Да, с помощью выражений свёртки некоторые вещи, которые требовали «костылей», теперь делаются проще, но возможностей у них не так много. К примеру, если у нас есть две нераскрытых пачки, которые мы как-то хотели бы объединить — мы не можем сделать этого с помощью этого функционала. То, что сначала выглядело как многообещающая и полезная функциональность, на деле оказалась довольно простенькой добавкой в арсенале метапрограммиста. Я нисколько не сомневаюсь, что умельцы смогут выжать из неё довольно интересные результаты, но будут ли они проще существующих решений?
Долой аргументы шаблонов
Я думаю, что все программисты C++ хоть раз встречали такие функции как std::make_pair или же std::make_tuple, единственное назначение которых создание std::pair и std::tuple, соответственно. Их наличие не имеет под собой каких-либо других оснований — в отличии от, к примеру, std::make_shared и std::make_unique — помимо невозможности в С++, вида 2014 года, создания объекта класса-шаблона, без явного указания аргументов шаблона. Поэтому удобство этих функций сложно переоценить — они позволяют избежать постоянного повторения аргументов шаблонов.
К примеру, есть у нас следующие 5 элементов:
std::vector<int> vector;
int i = 5;
auto begin = vector.begin();
auto end = vector.end();
std::string string{"me"};
И мы хотим создать std::tuple из них, какой вариант выберете вы? Этот:
std::tuple<std::vector<int>,
int,
std::vector<int>::iterator,
std::vector<int>::iterator,
std::string> tuple{vector, i, begin, end, string};
, или этот:
auto tuple = std::make_tuple(vector, i, begin, end, string);
И даже если какой-то любитель больших конструкций всё-таки забрёл в мой блог и искренне желает выбрать первый вариант, я хочу ему напомнить, что любое изменение типов исходных объектов повлечёт за собой изменение декларации нашего tuple. К примеру, вместо begin/end решили использовать cbegin/cend. Я же буду исходить из того, что любой программист предпочтёт второй вариант первому.
Честно говоря, меня, как программиста, вышеупомянутые случаи с pair и tuple не очень волновали, т.к. я практически их не использую. Есть у меня другой пример, который меня довольно сильно раздражает и волнует:
std::mutex guard;
std::lock_guard<std::mutex> lock{guard};
Постоянное повторение типа мьютекса при создание lock_guard удручало меня со времени появлении оного в стандартной библиотеке. К счастью, это проблема решена в C++17, и в нашем арсенале появилось выведение аргументов шаблона класса из конструирования объекта. Т.е. теперь мы можем писать вот так:
std::tuple tuple{vector, i, begin, end, string};
И это будет работать точно так же(*), как если бы мы использовали make_tuple!
(*) Это не совсем так, потому что std::make_tuple и std::make_pair имеют специальные правила для std::reference_wrapper, которых нет для той записи, что мы использовали. Но, во-первых, я не считаю это различие существенным, во-вторых, эти правила можно внедрить, при желании, и тогда эти различия пропадут совсем. Поэтому, я буду исходит из того, что эти записи равноценны и необходимости в функциях make_*, для pair и tuple, больше нет.
Всё, больше никаких повторений типов там, где это не нужно:
std::mutex guard;
std::lock_guard lock{guard};
Или вот такой пример:
std::array array = {1,2,3,4};
Теперь std::array не уступает в краткости записи своему предтече из C!
Наконец, вот такой пример: до C++14, мы вынуждены были явно указывать аргументы компараторов и наша запись могла выглядеть как-то так:
std::sort(vector.begin(), vector.end(), std::greater<int>())
После предложения принятого в C++14:
std::sort(vector.begin(), vector.end(), std::greater<>{})
С C++17 всё это дошло до логичной и наиболее приятной записи:
std::sort(vector.begin(), vector.end(), std::greater{});
Думаю, что примеров достаточно, чтобы понять насколько меньше мы теперь будем вынуждены писать кода, и как наш будущий код станет чище. Это, безусловно, одно из наиболее интересных нововведений C++17 в части так называемого «синтаксического сахара». Но посмотрев на это нововведение со стороны пользователя, мы не должны обойти стороной и то, как это работает, ведь мы не только пользуемся готовыми типами — иногда мы пишем свои типы и даже библиотеки.
По локоть в шаблонах
Итак, что же стоит за этой новой функциональностью? Как происходит вывод аргументов шаблонов и нужно ли что-то изменять в существующих классах-шаблонах, чтобы насладиться упрощённым синтаксисом?
Давайте разбираться постепенно и для этого мы создадим некоторую [бестолковую] обёртку над std::vector:
template <typename T>
class DummyVector
{
private:
std::vector<T> m_Storage;
};
Полный код примера может быть найден в этой фиксации.
Таким образом, у нас есть шаблонный класс, который мы хотим создавать без явного указания аргументов шаблона. Обратимся к стандарту и попробуем разобраться, как нам это сделать. Согласно стандарту ([over.match.class.deduct]), для выведения аргументов классов-шаблонов используется следующая техника:
- Для каждого конструктора исходного класса-шаблона (рассматривается только основной (primary) шаблон, никаких специализаций!), у нас это DummyVector, генерируется шаблонная функция, у которой параметрами шаблона выступают параметры класса-шаблона, а аргументами функции выступают аргументы соответствующего конструктора. Возвращаемым же значением такой функции, будет ни что иное как исходный шаблонный класс с соответствующими аргументами шаблона.
- Вне зависимости от того, какие конструкторы есть у класса, добавляется ещё одна функция, выведенная из гипотетического конструктора вида DummyVector(DummyVector).
- Полученные ранее функции становятся конструкторами гипотетического нешаблонного класса, посредством создания объекта которого и будет производиться конечное определение аргументов шаблона.
Не волнуйтесь, если вы мало что поняли, из того, что написано выше — сейчас мы всё разберём на примере. Т.к. первым шагом у нас идёт генерация функций из конструкторов, сделаем это для нашего DummyVector:
template <typename T>
DummyVector<T> DummyVector();
template <typename T>
DummyVector<T> DummyVector(const DummyVector<T>&);
template <typename T>
DummyVector<T> DummyVector(DummyVector<T>&&);
Три конструктора, что генерируются компилятором по умолчанию, дали нам 3 гипотетических функции. Теперь перейдём к шагу 2 и добавим ещё одну функцию:
template <typename T>
DummyVector<T> DummyVector(DummyVector<T>);
Теперь у нас всего 4 функции. Затем, мы берём все эти функции и делаем их конструкторами гипотетического класса:
class HypotheticalDummyVector
{
public:
template <typename T>
HypotheticalDummyVector();
template <typename T>
HypotheticalDummyVector(const DummyVector<T>&);
template <typename T>
HypotheticalDummyVector(DummyVector<T>&&);
template <typename T>
HypotheticalDummyVector(DummyVector<T>);
};
Т.к. у нас всё гипотетическое, то у нас есть гипотетическая связь между функциями указанными выше и конструкторами гипотетического класса — блеск. Другими словами, при гипотетическом создании объекта, происходит определение того, какой же из этих конструкторов выбрать, и когда он выбран, активируется вышеозначенная гипотетическая связь между этим конструктором и функцией-прообразом, и уже исходя из типа возвращаемого значения этой функции мы определяем, что за тип будет у DummyVector.
К примеру, пусть мы создаём объект нашего DummyVector следующим образом:
DummyVector copyInit = DummyVector<int>{};
Вступая во владения гипотетических построений, мы получим следующую вереницу событий. Сначала происходит создание объекта HypotheticalDummyVector:
HypotheticalDummyVector hdv{DummyVector<int>{}};
И тут у знатока правил разрешения перегрузки в C++ могут вылезти на лоб глаза, и он воскликнет «этот код не соберётся, тут явная двусмысленность!». Он будет совершенно прав, но я не зря потчевал вас словом «гипотетический» всё это время — класс гипотетический, поэтому и правила перегрузки для него тоже свои (пока оставим их). Так, для вышеприведённого кода будет однозначно выбран вот этот конструктор: HypotheticalDummyVector(DummyVector<T>). После того, как этот конструктор победил в перегрузке, и из оного было определено, что T у нас будет int, «активируется» его гипотетическая связь с соответствующей функцией DummyVector<T> DummyVector(DummyVector<T>), благодаря которой становится ясно, что результирующим типом у нас будет DummyVector<int>. Всё! Надеюсь, этим объяснением я прояснил ситуацию, а не усугубил непонимание ещё больше.
Согласитесь, что инициализация копированием, в строке которого явно указан аргумент, не может считаться интересным примером — мы могли явно его указать у объекта, который мы создаём и писанины было бы меньше. Так что давайте создадим конструктор, который позволит нам опустить явное указание аргумента:
template <typename T>
class DummyVector
{
public:
DummyVector(std::initializer_list<T> list):
m_Storage{list}
{
}
<...>
};
После выполнения шагов 1-3 это добавит вот такой конструктор в наш гипотетический класс:
class HypotheticalDummyVector
{
public:
<...>
template <typename T>
HypotheticalDummyVector(std::initializer_list<T> list);
};
И теперь мы можем создавать объекты нашего DummyVector следующим образом:
DummyVector initList{5.0, .3, .1, 5.67};
Это уже интереснее, не правда ли?. Всё здесь происходит ровно по той же схеме, что мы рассмотрели в случае копирующей инициализации, поэтому повторяться не стану. Важно извлечь отсюда то, что имея обычный набор конструкторов для класса-шаблона, вы на выходе автоматически получаете интуитивно понятное поведение при определении объекта без явного указания аргументов шаблона — код ведёт себя так, как пользователь того ожидает. Но всё вышеописанное далеко не является концом истории, нам нужно пойти дальше.
Погружаемся глубже
Рассмотренные нами ранее случаи можно считать простейшими, но есть такие ситуации, в которых обойтись простой генерацией «выводящих функций» из имеющихся конструкторов не получится. Давайте рассмотрим такой пример: пусть у нашего DummyVector будет конструктор, который будет принимать 2 итератора, из которых объект и будет создаваться:
template <typename T>
class DummyVector
{
public:
template<typename Iter>
DummyVector(Iter begin, Iter end):
m_Storage{begin, end}
{
}
private:
std::vector<T> m_Storage;
};
Это вполне себе обычный конструктор, который вы можете обнаружить у контейнеров стандартной библиотеки C++ — пример не высосан из пальца! Теперь мы хотим создать объект с помощью такого конструктора, но не указывая аргумент шаблона:
std::vector<int> vector{1, 2, 3, 4, 5};
DummyVector initFromVec{vector.begin(), vector.end()};
Если вы были внимательны, то должны понимать, что этот пример никак не соберётся, т.к. в вышеприведённом конструкторе нет никаких указаний на то, откуда можно вывести тип T. В нашем конструкторе есть тип Iter, но T нет! Что же делать? Здесь не обойтись без новой сущности, которая появляется в C++ 17 как часть большого функционала по избавлению нас от тирании аргументов шаблонов. Сущность эта именуется инструкцией выведении [типа] (deduction guide); далее буду именовать её как ИВ. ИВ представляет собой некоторый суррогат, который похож на объявление функции. Чтобы сразу не запутаться, давайте начнём с простейшей записи, объявив ИВ для уже имеющегося у нас функционала: выведение типа T для DummyVector из его копии. Вот как это будет выглядеть:
template <typename T>
DummyVector(const DummyVector<T>&) -> DummyVector<T>;
Первое, что нужно упомянуть это место, где ИВ должны обитать: находится они должны на том же уровне доступа, что и класс-шаблон, для которого ИВ добавляется. Так, к примеру, если у нас DummyVector находится в глобальной области видимости, то и ИВ должна находится там же:
template <typename T>
class DummyVector
{
<...>
};
template <typename T>
DummyVector(const DummyVector<T>&) -> DummyVector<T>;
Если бы DummyVector находился в пространстве имён DummyNamespace, то и ИВ должна была бы находиться там. Если DummyVector является вложенным классом, то и ИВ должна находится рядом и с теми же правами доступа. Вот так будет правильно:
class Outer
{
public:
template <typename T>
class DummyVector
{
<...>
};
template <typename T>
DummyVector(const DummyVector<T>&) -> DummyVector<T>;
};
А так нет:
class Outer
{
public:
template <typename T>
class DummyVector
{
<...>
};
private:
template <typename T>
DummyVector(const DummyVector<T>&) -> DummyVector<T>;
};
Разобравшись с тем, где ИВ должна находится, давайте разберём синтаксис и то, как ИВ соотносится с остальными частями картины. Синтаксис, как вы могли заметить, довольно прост: перечисляем все параметры шаблона для соответствующего конструктора (template <typename T>), затем записываем сам конструктор со всеми аргументами (DummyVector(const DummyVector<T>&)), и добавляем то, что мы хотим получить в результате. Мы можем вернуть в результате всё, что угодно если это будет DummyVector, т.е. DummyVector это обязательно, а всё, что стоит между скобками <> уже на наше усмотрение. Мы использовали простейший пример, который на получение копии выводит точно такой же вектор, с тем же типом. Но никто не мешает нам написать такую инструкцию:
template <typename T>
DummyVector(const DummyVector<T>&) -> DummyVector<double>;
Т.е. какой DummyVector нам не будет передан, мы всегда будем выводить DummyVector<double>. И это, кстати, даст нам весьма интересный результат, который следует упомянуть и жирно подчеркнуть. Пусть мы записали вышеозначенную ИВ и пытаемся создать DummyVector методом, что мы рассматривали ранее:
DummyVector copyInit = DummyVector<int>{};
До того, как конструктор будет вызван происходит выведение шаблона типа для DummyVector, который мы хотим инициализировать посредством DummyVector<int>{}. Т.к. у нас есть на этот счёт специальная инструкция, то получается, что у нас имеет место такая вот ситуация:
DummyVector<double> copyInit = DummyVector<int>{};
А это даст нам ошибку компиляции. Это очевидный, но очень важный момент в понимании всего функционала вывода аргументов шаблона: то, что используется для выведения аргумента шаблона вовсе не обязательно будет использовано при вызове конструктора. Здесь для выведения типа мы использовали инструкцию выведения, а для [попытки] создания уже используется конструктор, который никакого отношения к вышеупомянутой ИВ не имеет. Но мы к этому ещё вернёмся, а сейчас давайте добавим нормальную ИВ, которая решит нашу проблему с созданием объекта из пары итераторов. Для этого мы воспользуемся стандартной библиотекой, а именно std::iterator_traits:
template<typename Iter>
DummyVector(Iter begin, Iter end) ->
DummyVector<typename std::iterator_traits<Iter>::value_type>;
С помощью iterator_traits мы извлекаем информацию о value_type и согласно оному уже создаём DummyVector — всё просто. Но это лишь один из примеров, вы также можете представить себе ситуацию, когда ваш класс создаётся из какого-нибудь объекта-прокси. Либо же представьте себе шаблонный класс, который принимает аргументы любого типа (обёртка), и автор этого класса, хотел бы, чтобы для C++ и C-строк результирующий тип был одинаков — std::string. Здесь ИВ тоже приходят на выручку. В общем, для большинства случаев достаточно того, что автоматически выводится из конструкторов, а для всего остального есть инструкции вывода.
Складывая пазл
Итак, если у вас ещё голова не пошла кругом, пришла пора встроить инструкции выведения в общую картину и рассмотреть всю новую функциональность целиком. Рассматривая новый функционал ранее, после выполнения 2 шагов мы получали некий набор гипотетических функций. Как вы уже возможно догадались, инструкции выведения тоже являются частью этого механизма: когда происходит генерация списка гипотетических функций учитываются не только конструкторы класса, но и все доступный ИВ. Т.е. мы можем добавить шаг 1а: для каждой инструкции вывода данного шаблонного класса, генерируется гипотетическая функция, которая добавляется к набору функций, сгенерированному на шаге 1.
Все эти шаги есть лишь условность, которую я привожу здесь только для упрощения понимания. Не грех будет повторить здесь пример, рассмотренный ранее, но уже с добавлением ИВ. Пусть мы имеем такой класс с ИВ:
template <typename T>
class DummyVector
{
public:
DummyVector(std::initializer_list<T> list):
m_Storage{list}
{
}
template<typename Iter>
DummyVector(Iter begin, Iter end):
m_Storage{begin, end}
{
}
private:
std::vector<T> m_Storage;
};
template<typename Iter>
DummyVector(Iter begin, Iter end) ->
DummyVector<typename std::iterator_traits<Iter>::value_type>;
Который на выходе даст нам такой гипотетический класс:
class HypotheticalDummyVector
{
public:
template <typename T>
HypotheticalDummyVector(const DummyVector<T>&); // #1
template <typename T>
HypotheticalDummyVector(DummyVector<T>&&);// #2
template <typename T>
HypotheticalDummyVector(DummyVector<T>);// #3
template <typename T>
HypotheticalDummyVector(std::initializer_list<T> list);// #4
template<typename T, typename Iter>
HypotheticalDummyVector(Iter begin, Iter end);// #5
};
Хозяйке на заметку: в последнем конструкторе нашего «гипокласса» вы можете наблюдать объединение параметра класса-шаблона с параметром шаблонного конструктора. Так будет происходить всегда: шаблонная шапка «гипофункций» состоит из всех шаблонных параметров класса-шаблона и конструктора-прообраза.
Так вот, имея вышеозначенный «гипокласс», пришло время разобраться с правилами перегрузки для него, а нужно это потому, что для него они будут отличаться от правил перегрузки нормальных конструкторов класса. Так как это не статья о перегрузке, я рассмотрю лишь те случаи, где есть явные отличия, а именно в выборе лучшего кандидата. Для контраста давайте сделаем точную копию нашего гипотетического класса, для которого будут действовать нормальные правила перегрузки (назовём её RealDummy и не будем приводить здесь текст — точная копия). Теперь будем проводить эксперименты, создадим наши объекты так:
HypotheticalDummyVector hypothetical{DummyVector<int>{}}; // вызовет #3
RealDummy real{DummyVector<int>{}};// вызовет #4
Но в этом случае оба вызовут одинаковые конструкторы:
HypotheticalDummyVector hypothetical{1};// вызовет #4
RealDummy real{1};// вызовет #4
Такая разница проистекает из-за специального правила, прописанного в [over.match.class.deduct] стандарта C++. Согласно этому правилу, если у нас имеется инициализация, подходящая для инициализации конструктора с initializer_list, но она состоит лишь из одного элемента, который является специализацией класса для которого наш гипотетический класс был сгенерирован, тогда правило, заставляющее при перегрузке в первую очередь рассматривать конструктор с initializer_list, перестаёт работать. Именно поэтому для настоящего класса выбирается 4-й конструктор, а для вымышленного 3-й.
Разобравшись с этим, мы переходим к ситуации когда конструкция, которая не должна компилироваться в силу двусмысленности перегрузки между #2 и #3, компилируется и работает (гипотетически, конечно же). Мы уже встречали это ранее, но теперь пришло время разобраться почему так происходит. Ответ просто и банален — ещё одно исключение, явно добавленное в стандарт ([over.match.best]): гипофункция сформированная на шаге 2 побеждает в перегрузке все функции, сгенерированные из конструкторов (на первом шаге).
Наконец, давайте разберём, почему работает вот этот пример:
DummyVector initFromVec(vector.begin(), vector.end());
«Как почему? Автор, Вы же сами написали для него ИВ!» — может воскликнуть внимательный читатель и будет прав. Но есть тут одна тонкость, которая не показана в HypotheticalDummyVector: согласно пошаговой инструкции, рассмотренной нами ранее, из всех конструкторов и инструкций выведения буду сгенерированы гипофункции, которые станут конструкторами гипокласса. А это значит, что в нашем гипоклассе должно быть 2 идентичных конструктора (я не стал приводить 2 конструктора — они неотличимы), которые принимают итераторы, но которые связаны с разными гипофункциями своей гипосвязью: одна даёт нам результирующий тип T, другая ошибку компиляции. Значит должно быть какое-то правило, которое позволяет однозначно выбрать нужный нам конструктор. И такое правило, естественно, есть: гипофункции, производные от ИВ, побеждают в перегрузке все другие гипофункции.
Кстати заметьте, что в коде выше я использовал круглые, а не фигурные скобки для создания объекта из итераторов. Это вынужденная мера, т.к. с появлением конструктора, который принимает initializer_list, у нас нет другого выбора, кроме как отказаться от фигурных скобок — иначе он будет побеждать всегда. Увы и ах, появление единообразной инициализации так и не стало сугубо положительным нововведением, т.к. постоянно появляются новые исключения, вызванные только этим нововведением. Весьма противоречивый функционал получился, весьма.
Последним отличием является тот факт, что если конструктор гипокласса является производным от нешаблонного конструктора оригинала, тогда он имеет преимущество над конструктором, производным от шаблонного конструктора. Это правило понятно и присуще обычному C++ классу, просто здесь это разница не очевидна, т.к. все конструкторы гипокласса являются шаблонными, поэтому пришлось добавить явное правило.
Ссылки не то, чем кажутся
Пора уже закончить с этим функционалом, но не могу не упомянуть о ещё одной особенности, которая даёт предсказуемый результат ценой ещё одного исключения. Допустим у нас есть такой класс:
template <typename T>
class Dummy
{
public:
template <typename U>
Dummy(T&& t, U&& u);
};
Для него будет сгенерирован гипокласс, который может выглядеть так:
class HypotheticalDummy
{
public:
<...>
template <typename T, typename U>
HypotheticalDummy(T&& t, U&& u)
{
}
};
В исходном классе мы имели одну rvalue-ссылку на T и одну пробрасывающую ссылку (forwarding reference) на U, но в гипоклассе обе ссылки являются пробрасывающими! Но, как вы уже могли заметить, дизайн этой функциональности подразумевает поведение, которое должно быть интуитивно понятно пользователю, а не то, что имеет смысл при рассмотрении гипокласса как реального класса. Поэтому в нашем гипоклассе U&& является пробрасывающей ссылкой, а T&& — нет. Это исключение описано в [temp.deduct.call] стандарта C++ (на момент написания это находится в пункте 3). Но это, к сожалению, не всё. В этом исключении есть другое исключение (мало нам их что ли?). Если мы напишем для нашего класса инструкции вывода, то ссылка станет пробрасывающей! Вот пример такой ИВ:
template <typename T, typename U>
Dummy(T&&, U&&) -> Dummy<T>;
Так вот, без такой ИВ, следующий код не скомпилируется:
int value = 0;
Dummy dummy{value, 1};
Потому что будет попытка инициализации Dummy<int, int>(int&&, int&&) lvalue первым аргументом. Но с ИВ код скомпилируется нормально, т.к. у нас уже будет такой вызов Dummy<int&, int>(int&, int&&). Интересная разница, о которой необходимо помнить. Зачем это сделано? Видимо затем, чтобы по умолчанию иметь поведение интуитивное, но и иметь возможность переопределять оное для каких-то определённых нужд. Наличие возможности это хорошо, плохо то, что подобные возможности запутывают конечного пользователя.
А ведь в такую ситуацию легко попасть по неосторожности. Представьте себе какой-нибудь стиль кодирования, где есть совет всегда выражать свои намерения явно. Для выведения аргументов шаблона это может означать, что для каждого конструктора, программист должен явно указать инструкцию выведения. И если программист не знает этот функционал досконально, то очень легко попасть в ситуацию, когда ты ожидаешь rvalue аргументом, а приходит вовсе не оно. Как думаете, рад будет программист? На этом исключения наконец-то заканчиваются.
Явное становится явным
Многоступенчатая система, с помощью которой реализуется функционал выведения аргументов шаблона, сделана такой неспроста. Не зря стандарт упоминает некий гипотетический класс, ведь уже существуют определённые правила перегрузки, действующие для конструкторов класса. Конечно, их можно было бы повторить для полностью нового функционала, но комитет пошёл путем переиспользования с некоторыми модификациями. Но правила перегрузки это не всё, что заставило авторов функционала прибегнуть к гипоклассу.
Не будет лишним напомнить, что компилятор вовсе не обязан генерировать какие-то гипофункции и гипоклассы — он просто обязан дать результат, который удобно описывается с помощью оных. Как это будет реализовано — дело десятое.
Ещё одной частью нового функционала является возможность использования ключевого слова explicit, которое, безусловно, может быть применено к конструкторам исходного класса, но не только. Ключевое слово explicit может быть добавлено и к инструкции выведения:
template <typename T, typename U>
explicit Dummy(T&&, U&&) -> Dummy<T>;
В результате этого, все исходные explicit сущности получат в гипоклассе соответствующие explicit конструкторы.
Кстати, с помощью этого, мы можем сделать очень интересную штуку. Оставив ИВ с explicit, мы можем разрешить такой код:
int value = 0;
Dummy dummy{value, 1};
И запретить такой (конструктор гипокласса стал explicit):
int value = 0;
Dummy dummy = {value, 1};
А если сделать наоборот: сделать конструктор класса explicit, а у ИВ explicit убрать. Тогда, в таком коде:
int value = 0;
Dummy dummy = {value, 1};
Процесс выведения типа Dummy<int&> закончится успешно, но компиляция всё равно закончится ошибкой, т.к. конструктор класса является explicit. И это ещё раз напоминает нам о том, что я ранее подчёркивал: процесс выведения типа аргумента шаблона и процесс выбора конструктора это два несвязанных процесса. Да, может так получится, что для создания объекта типа будет использован тот же конструктор, что явился прообразом конструктора гипокласса, который использовался для выведения типа аргумента, но это будет просто совпадением.
Итог
Хотя функционал по автоматическому выведению аргументов шаблона вряд ли можно назвать крупным нововведением, я уверен, что мы всё равно с удовольствием станем использовать упрощённый синтаксис. Невзирая на всю скромность этого функционала (в плане влияния на код), вы могли оценить какой кровью он дался комитету. Я же постарался донести всю подноготную, которая заставляет работать этот функционал так, как он задумывался авторами. Но т.к. всё это завязано на гипотетических вещах, которые в свою очередь пестрят исключениями, понимание всех этих тонкостей может занять некоторое время. И я заранее извиняюсь, если не смог донести описание внутренней кухни максимально доходчиво. Благо, даже без понимания всего устройства нового функционала его можно использовать без каких-либо проблем. Всего этого удалось достичь благодаря тому, что поведение является интуитивно понятным, хотя и ценой различных ухищрений и исключений добавленных в стандарт. Правда, меня не оставляет некоторое чувство смутной тревоги касательно этого нововведения: мне кажется, что лазейки оставленные для хитроумного использования данного функционала ещё покажут себя в будущем коде.
Остальные статьи цикла:
Часть 2. Constexpr и привязки
Часть 3. Порядок и спокойствие
Часть 4. Сборная солянка