В этой статье я бы хотел рассказать о том, как работают списки инициализации (braced initializer lists) в C++, какие проблемы они были призваны решать, какие проблемы, в свою очередь, вызвали и как не попасть в просак.
Первым делом предлагаю почувствовать себя компилятором (или language lawyer-ом) и понять, компилируются ли следующие примеры, почему, и что они делают:
Классика:
std::vector<int> v1{5};
std::vector<int> v2(5);
std::vector<int> v3({5});
std::vector<int> v4{5};
std::vector<int> v5 = 5;
Современный C++ — безопасный язык, я никогда не выстрелю себе в ногу:
std::vector<std::string> x( {"a", "b"} );
std::vector<std::string> y{ {"a", "b"} };
Больше скобочек богу скобочек!
// Почему их тут пять, скомпилируется ли программа и почему?
std::vector<std::vector<int>> v1{{{{{}}}}};
Если один конструктор не подходит, мы берем второй, правильно?
struct T{};
struct S {
S(std::initializer_list<int>);
S(double, double);
S(T, T);
};
int main() {
S{T{}, T{}}; // Работает ли вот так?
S{1., 2.}; // а так?
}
Almost Always Auto, говорили они. Это повышает читабельность, говорили они:
auto x = {0}; // какой тут тип у x?
auto y{0}; // а у y?
// вы уверены? попробуйте другую версию вашего компилятора
Привет из древних времен:
struct S {
std::vector<int> a, b;
};
struct T {
std::array<int, 2> a, b;
};
int main() {
T t1{{1, 2}, {3, 4}};
T t2{1, 2, 3, 4};
T t3{1, 2};
S s1{{1, 2}, {3, 4}};
S s2{1, 2, 3, 4};
S s3{1, 2};
}
Все понятно? Или ничего не ясно? Добро пожаловать под кат.
Disclaimers
- Эта статья ознакомительная, не претендует на полноту и часто будет жертвовать корректностью в угоду понятности. С другой стороны, у читателя предполагается базовое знание C++.
- Я пытался придумывать разумные переводы на русский для англоязычных терминов, но с некоторыми я потерпел полное фиаско. Синтаксические конструкции вида
{...}
я буду называть braced-init-lists, тип из стандартной библиотеки —std::initializer_list
, а вид инициализации, когда мы пишем как-то так:int x{5}
— это list-init, также известная как uniform initialization syntax, или универсальный синтаксис инициализации.
Attention!
Первым делом обращу внимание на важное наблюдение. Даже если вы из всей статьи вынесете только его, а дальше читать станет лень, моя миссия здесь будет исполнена.
Итак, braced-init-lists (штуки с фигурными скобками, {1, 2, 3}, uniform initialization syntax) и std::initializer_list
— разные вещи! Они сильно связаны, между ними происходят всякие тонкие взаимодействия, но любое из них вполне может существовать без другого.
Но сначала — немного предыстории.
Unicorn initialization syntax
В C++98 (и его bugfix-update, C++03) существовало достаточно проблем и непоследовательностей, связанных с инициализацией. Вот некоторые из них:
- Из C пришел синтаксис инициализации переменных (в том числе, массивов и структур) с использованием фигурных скобок, но он не очень хорошо взаимодействовал с возможностями C++ (например, инициализация структур не была доступна для C++-классов)
- Часто хочется соорудить какой-нибудь контейнер (например,
std::vector
) из заранее известных элементов — в языке не было встроенной возможности для этого, а библиотечные решения (Boost.Assign
) не отличались изящностью синтаксиса, были не бесплатны с точки зрения скорости работы и не слишком хорошо влияли на время компиляции - При инициализации примитивных типов легко случайно потерять информацию при сужающем преобразовании (narrowing conversion) — например, случайно присвоить
double
вint
- Most vexing parse, которым любят пугать начинающих C++-ников.
Поэтому во время разработки C++11 родилась такая идея: давайте мы дадим возможность проинициализировать что угодно с помощью фигурных скобок:
- Для случаев, где это применимо в C, новый синтаксис будет работать так же, только лучше
- Сужающие преобразования при этом мы запретим
- А если мы пытаемся проинициализировать класс с конструкторами, то мы и конструктор сможем вызывать, с переданными параметрами
Pitfalls
Казалось бы, на этом можно и закончить: инициализация контейнеров должна получиться сама собой, ведь в C++11 появились еще и шаблоны с переменным числом параметров, так что если мы напишем variadic-конструктор… на самом деле, нет, так не получится:
- Такой конструктор должен быть шаблоном, что часто нежелательно
- Придется инстанцировать конструкторы со всевозможным числом параметров, что приведет к раздуванию кода и замедлению компиляции
- Эффективность инициализации, например, для
std::vector
-а будет все равно не идеальная
Для решения этих проблем придумали std::initializer_list
— "магический класс", который представляет собой очень легкую обертку для массива элементов известного размера, а так же умеет конструироваться от braced-init-list-а.
Почему же он "магический"? Как раз по описанным выше причинам его невозможно эффективно сконструировать в пользовательском коде, поэтому компилятор создает его специальным образом.
Зачем же он нужен? Главным образом, чтобы пользовательские классы могли сказать: "я хочу конструироваться от braced-init-list-а элементов такого-то типа", и им не требовался бы для этого шаблонный конструктор.
(Кстати, к этому моменту должно стать понятно, что std::initializer_list
и braced-init-list это разные понятия)
Теперь-то все хорошо? Мы просто добавим в наш контейнер конструктор вида vector(std::initializer_list<T>)
и все заработает? Почти.
Рассмотрим такую запись:
std::vector<int> v{5};
Что имелось в виду, v(5)
или v({5})
? Другими словами, хотим ли мы сконструировать вектор из 5 элементов, или из одного элемента со значением 5
?
Для решения этого конфликта разрешение перегрузок (overload resolution, выбор нужной функции по переданным аргументам) в случае list-initialization происходит в два этапа:
- Сначала рассматриваются только конструкторы с единственным параметром типа
std::initializer_list
(это один из главных моментов, когда компилятор таки генерируетstd::initializer_list
по содержимому фигурных скобочек). Разрешение перегрузок происходит между ними. - Если ни один конструктор не подходит, то дальше все как обычно — разворачиваем braced-init-list в список аргументов и проводим разрешение перегрузок среди всех доступных конструкторов.
Отметим, что конструктор, который проиграл на первом этапе, вполне может подойти на втором. Это объясняет пример с избытком скобочек для инициализации вектора из начала статьи. Для понятности удалим один из вложенных шаблонов, а также заменим std::vector
на свой класс:
template<typename T> struct vec {
vec(std::initializer_list<T>);
};
int main() {
vec<int> v1{{{}}};
}
Под пункт 1 наш конструктор не подходит — {{{}}}
не похож на std::initializer_list<int>
, потому что int
нельзя проинициализировать с помощью {{}}
. Однако {}
— вполне себе zero-initialization, поэтому конструктор принимается на втором шаге.
Забавно, однако, что сужающее преобразование не является достаточным поводом для того, чтобы выкинуть конструктор — в следующем примере первый конструктор принимается на первом шаге разрешения перегрузок, и потом вызывает ошибку компилятора. Хорошо это или плохо — я не знаю, для меня это просто удивительно.
struct S {
S(std::initializer_list<int>);
S(double, double);
};
int main() {
S{1., 2.};
}
Похожая проблема с довольно страшным результатом получается и в примере с вектором строк из начала статьи. К несчастью, у std::string
есть конструктор, который трактует два переданных указателя как начало и конец строки. Последствия такого поведения для строковых литералов, очевидно, плачевны, при этом синтаксически запись выглядит довольно похоже на корректный вариант и вполне может появиться, например, в обобщенном коде.
Классы-агрегаты
Ну теперь-то все? Не совсем. Старый синтаксис инициализации структур, доставшийся нам от C, никуда не делся, и можно делать так:
struct A { int i, j; };
struct B { A a1, a2; };
int main() {
B b1 = {{1, 2}, {3, 4}};
B b2 = {1, 2, 3, 4}; // brace elision
B b3 = {{1, 2}}; // clause omission
}
Как видим, при иницализации агрегатов (грубо говоря, C-подобных структур, не путать с POD, POD — это про другое) можно и пропускать вложенные скобочки, и выкидывать часть инициализаторов. Все это поведение было аккуратно перенесено в C++.
Казалось бы, какой бред, зачем это в современном языке? Давайте хотя бы предупреждения компилятора будем на это выводить, подумали разработчики GCC и clang, и были бы правы, не будь std::array
классом-агрегатом, содержащим внутри себя массив. Таким образом, предупреждение про выкидывание вложенных скобок по понятным причинам срабатывает на вот таком невинном коде:
int main() {
std::array<int, 3> a = {1,2,3};
}
Проблему эту GCC "решил" выключением соответствующего предупреждения в режиме -Wall
, в clang-е же уже три года все по-прежнему.
Кстати, тот факт, что std::array
— агрегат, не прихоть безумных авторов стандарта или ленивых разработчиков стандартных библиотек: достичь требуемой семантики этого класса просто невозможно средствами языка, не теряя в эффективности. Еще один привет от C и его странных массивов.
Возможно, большая проблема с классами-агрегатами — это не самое удачное взаимодействие с обобщенными функциями (в том числе) из стандартной библиотеки. На данный момент функции, которые конструируют объект из переданных параметров (например, vector::emplace_back
или make_unique
), вызывают обычную инициализацию, не "универсальную". Вызвано это тем, что использование list-initialization не позволяет никаким нормальным способом вызвать "обычный" контруктор вместо принимающего std::initializer_list
(примерно та же проблема, что и с инициализацией в не-шаблонном коде, только тут пользователь не может обойти ее вызовом другого конструктора). Работа в этом направлении ведется, но пока мы имеем то, что имеем.
Almost Always Auto
Как же braced-init-list-ы ведут себя в сочетании с выводом типов? Что будет, если я напишу auto x = {0}; auto y = {1, 2};
? Можно придумать несколько разумных стратегий:
- Запретить такую инициализацию вообще (в самом деле, что программист хочет этим сказать?)
- Вывести тип первой переменной как
int
, а второй вариант запретить - Сделать так, чтобы и x, и y имели тип
std::initializer_lits<int>
Последний вариант нравится мне меньше всего (мало кому в реальной жизни заводить локальные переменные типа std::initializer_list
), но в стандарт С++11 попал именно он. Постепенно стало выясняться, что это вызывает проблемы у программистов (кто бы мог подумать), поэтому в стандарт добавили патч http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n3922.html, который реализует поведение №2… только в случае copy-list-initialization (auto x = {5}
), а в случае direct-list-initialization (auto x{5}
) оставляет все по-старому.
Я не могу это комментирвать. По-моему, это один из очень редких случаев, когда здравый смысл временно покинул авторов языка. Если у вас есть, что сказать по этому поводу, сообщите мне об этом в комментариях.
Промежуточные итоги
Хотя универсальный синтаксис инициализации и std::initializer_list
— возможности языка, добавленные из благих и правильных побуждений, мне кажется, что из-за извечной необходимости в обратной совместимости и не всегда дальновидных решениях на ранних этапах вся ситуация вокруг них на данный момент излишне сложная, вымученная и не самая приятная для всех вовлеченных сторон — авторов стандарта, компиляторов, библиотек и прикладных разработчиков. Хотели как лучше, а получилось, как в известном комиксе:
В качестве примера возьмем, например, историю с [over.best.ics]/4.5, который сначала добавили в стандарт, потом, не подумав, удалили, как избыточный, а потом добавили обратно в измененном виде — как описание крайнего случая с пятью (!) условиями.
Тем не менее, возможность полезная и облегчающая жизнь, поэтому здесь я приведу небольшой и не претендующий на объективность список того, как не выстрелить себе в ногу:
- Потратьте некоторое время на то, чтобы ознакомиться с тем, что на самом деле происходит (я рекомендую прочитать параграф стандарта — на удивление понятный и не слишком зависимый от остальных)
- Не используйте
std::initializer_list
, кроме как в параметре конструктора - Да и в параметре конструктора используйте, только если вы понимаете, что происходит (если не уверены — сконструируйтесь лучше от вектора, пары итераторов или range-а)
- Не используйте классы-агрегаты без крайней необходимости, напишите лучше конструктор, инициализирующий все поля
- Не используйте braced-init-list в сочетании с
auto
- Прочитайте эту статью про то, что делать с пустыми списками инициализации (у меня руки чешутся ее перевести и запостить, может быть, вскоре займусь)
- И, как я уже писал в самом начале, имейте в виду, что braced-init-list и
std::initializer_list
— это разные концепции, весьма хитро взаимодействующие друг с другом
Давайте помечтаем
Тут я заканчиваю свое введение в существующее положение дел, и хочу вбросить помечтать, как все могло бы быть, если бы мы жили в идеальном мире.
Мне кажется, что переиспользовать фигурные скобки для создания std::initializer_list
во время инициализации — ошибка дизайна языка. Я был бы очень рад, если бы вместо этого мы получили бы более явный и отдельный синтаксис (пусть и более уродливый, например, какие-нибудь странные скобки типа <$...$>
или встроенный интринзик вроде std::of(...)
). То есть инициализируем вектор как-то так: std::vector<std::vector<int>> x = std::of(std::of(1, 2), std::of(3, 4));
Что бы это дало? Новый способ инициализации (с защитой от most vexing parse и сужающих преобразований) оказался бы отвязан от std::initializer_list
, не потребовалось бы вводить отдельный шаг для разрешения перегрузок, ушла бы проблема с конструктором vector<int>
или vector<string>
, новый синтаксис инициализации можно было бы использовать в обобщенном коде безо всяких проблем.
Конечно, недостатки у такого подхода довольно серьезные: более уродливый синтаксис в простейших случаях и уход от цели сделать синтаксис более унифицированным с инициализацией в стиле C (к такой унификации я отношусь довольно скептически, но это тема для отдельного разговора).
Еще я недолюбливаю классы-агрегаты. Если оставить за скобками проблему с std::array
, я не вижу достойного обоснования для существования такой большой и особенной возможности языка. Проблему с тем, что программистам не хочется писать тривиальные конструкторы для простых классов, можно было бы решить менее инвазивными способами, например, дать возможность генерировать конструктор, который бы инициализировал все поля по очереди:
struct S {
int a, b;
S(...) = aggregate;
};
Заключение
Напоследок повторюсь еще раз, что я не претендую на 100% корректность или на истину в последней инстанции. Добро пожаловать в комментарии, если что-то осталось непонятным, или если есть что сказать по этой довольно специфической теме.
Автор: dkozh