Списки инициализации в C++: хороший, плохой, злой

в 7:58, , рубрики: braces, c++, c++11, initialization, С++
Списки инициализации в C++: хороший, плохой, злой - 1

В этой статье я бы хотел рассказать о том, как работают списки инициализации (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

  1. Эта статья ознакомительная, не претендует на полноту и часто будет жертвовать корректностью в угоду понятности. С другой стороны, у читателя предполагается базовое знание C++.
  2. Я пытался придумывать разумные переводы на русский для англоязычных терминов, но с некоторыми я потерпел полное фиаско. Синтаксические конструкции вида {...} я буду называть 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++: хороший, плохой, злой - 2

В 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 происходит в два этапа:

  1. Сначала рассматриваются только конструкторы с единственным параметром типа std::initializer_list (это один из главных моментов, когда компилятор таки генерирует std::initializer_list по содержимому фигурных скобочек). Разрешение перегрузок происходит между ними.
  2. Если ни один конструктор не подходит, то дальше все как обычно — разворачиваем 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};? Можно придумать несколько разумных стратегий:

  1. Запретить такую инициализацию вообще (в самом деле, что программист хочет этим сказать?)
  2. Вывести тип первой переменной как int, а второй вариант запретить
  3. Сделать так, чтобы и 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}) оставляет все по-старому.
Списки инициализации в C++: хороший, плохой, злой - 3

Я не могу это комментирвать. По-моему, это один из очень редких случаев, когда здравый смысл временно покинул авторов языка. Если у вас есть, что сказать по этому поводу, сообщите мне об этом в комментариях.

Промежуточные итоги

Хотя универсальный синтаксис инициализации и std::initializer_list — возможности языка, добавленные из благих и правильных побуждений, мне кажется, что из-за извечной необходимости в обратной совместимости и не всегда дальновидных решениях на ранних этапах вся ситуация вокруг них на данный момент излишне сложная, вымученная и не самая приятная для всех вовлеченных сторон — авторов стандарта, компиляторов, библиотек и прикладных разработчиков. Хотели как лучше, а получилось, как в известном комиксе:

Списки инициализации в C++: хороший, плохой, злой - 4

В качестве примера возьмем, например, историю с [over.best.ics]/4.5, который сначала добавили в стандарт, потом, не подумав, удалили, как избыточный, а потом добавили обратно в измененном виде — как описание крайнего случая с пятью (!) условиями.

Тем не менее, возможность полезная и облегчающая жизнь, поэтому здесь я приведу небольшой и не претендующий на объективность список того, как не выстрелить себе в ногу:

  1. Потратьте некоторое время на то, чтобы ознакомиться с тем, что на самом деле происходит (я рекомендую прочитать параграф стандарта — на удивление понятный и не слишком зависимый от остальных)
  2. Не используйте std::initializer_list, кроме как в параметре конструктора
  3. Да и в параметре конструктора используйте, только если вы понимаете, что происходит (если не уверены — сконструируйтесь лучше от вектора, пары итераторов или range-а)
  4. Не используйте классы-агрегаты без крайней необходимости, напишите лучше конструктор, инициализирующий все поля
  5. Не используйте braced-init-list в сочетании с auto
  6. Прочитайте эту статью про то, что делать с пустыми списками инициализации (у меня руки чешутся ее перевести и запостить, может быть, вскоре займусь)
  7. И, как я уже писал в самом начале, имейте в виду, что 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

Источник

Поделиться

* - обязательные к заполнению поля