Хабрахабр

Инициализация в современном C++

Существует множество видов инициализации, описываемых разным синтаксисом, и все они взаимодействуют сложным и вызывающим вопросы способом. Общеизвестно, что семантика инициализации — одна из наиболее сложных частей C++. К сожалению, она привнесла еще более сложные правила, и в свою очередь, их перекрыли в C++14, C++17 и снова поменяют в C++20. C++11 принес концепцию «универсальной инициализации».

Тимур вначале подводит исторические итоги эволюции инициализации в С++, дает системный обзор текущего варианта правила инициализации, типичных проблем и сюрпризов, объясняет, как использовать все эти правила эффективно, и, наконец, рассказывает о свежих предложениях в стандарт, которые могут сделать семантику инициализации C++20 немного более удобной. Под катом — видео и перевод доклада Тимура Домлера (Timur Doumler) с конференции C++ Russia. Далее повествование — от его лица.

Table of Contents

Я нашёл её на просторах интернета где-то полгода тому назад, и выложил у себя в твиттере. Гифка, которую вы сейчас видите, отлично доносит основную мысль доклада. Началось обсуждение, в ходе которого мне предложили сделать об этом доклад. В комментариях к ней кто-то сказал, что не хватает ещё трёх типов инициализации. Так всё и началось.

В его докладе был слайд, на котором перечислялись 19 различных способов инициализировать int: Про инициализацию уже рассказывал Николай Йоссутис.

int i1; //undefined value
int i2 = 42; //note: inits with 42
int i3(42); //inits with 42
int i4 = int(); //inits with 42
int i5; //inits with 42
int i6 = {42}; //inits with 42
int i7{}; //inits with 0
int i8 = {}; //inits with 0
auto i9 = 42; //inits with 42
auto i10{42}; //C++11: std::initializer_list<int>, C++14: int
auto i11 = {42}; //inits std::initializer_list<int> with 42
auto i12 = int{42}; //inits int with 42
int i13(); //declares a function
int i14(7, 9); //compile-time error
int i15 = (7, 9); //OK, inits int with 9 (comma operator)
int i16 = int(7, 9); //compile-time error
int i17(7, 9); //compile-time error
auto i18 = (7, 9); //OK, inits int with 9 (comma operator)
auto i19 = int(7, 9); //compile-time error

Инициализация переменной — одно из простейших действий, но в С++ сделать это совсем не просто. Мне кажется, это уникальная ситуация для языка программирования. Правила инициализации меняются от стандарта к стандарту, и в интернете есть бесчисленное количество постов о том, как запутана инициализация в C++. Вряд ли в этом языке есть какая-либо другая область, в которой за последние годы было бы столько же отчётов об отклонениях от стандарта, исправлений и изменений. Поэтому сделать её систематический обзор — задача нетривиальная.

Мы обсудим распространённые ошибки, и я дам свои рекомендации относительно правильной инициализации. Я буду излагать материал в хронологическом порядке: вначале мы поговорим о том, что было унаследовано от С, потом о С++98, затем о С++03, С++11, С++14 и С++17. В самом конце доклада будет представлена обзорная таблица. Также я расскажу о нововведениях в С++20.

В С есть несколько способов инициализации переменных. В С++ очень многое унаследовано от С, поэтому с него мы и начнём. На мой взгляд, это неудачное название. Их можно вообще не инициализировать, и это называется инициализация по умолчанию. Если обратиться к неинициализированной переменной в C++ и в С, возникает неопределённое поведение: Дело в том, что никакого значения по умолчанию переменной не присваивается, она просто не инициализируется.

int main() { int i; return i; // undefined behaviour
}

То же касается пользовательских типов: если в некотором struct есть неинициализированные поля, то при обращении к ним также возникает неопределённое поведение:

struct Widget { int i; int j;
}; int main() { Widget widget; return widget.i; // неопределенное поведение
}

Если в классе некоторый элемент не инициализирован, то при обращении к нему возникает неопределённое поведение: В С++ было добавлено множество новых конструкций: классы, конструкторы, public, private, методы, но ничто из этого не влияет на только что описанное поведение.

class Widget { public: Widget() {} int get_i() const noexcept { return i; } int get_j() const noexcept { return j; } private: int i; int j;
}; int main() { Widget widget; return widget.get_i(); // Undefined behaviour!
}

Это интересный момент, и в течение первых нескольких лет моей карьеры с С++ я этого не знал. Никакого волшебного способа инициализировать по умолчанию элемент класса в С++ нет. Мои коллеги не обращали внимания на эту особенность при проверке кода. Ни компилятор, ни IDE, которой я тогда пользовался, об этом никак не напоминали. Мне казалось очевидным, что классы должны инициализировать свои переменные. Я почти уверен, что из-за неё в моём коде, написанном в эти годы, есть довольно странные баги.

Но такое решение проблемы не оптимальное, поскольку это необходимо делать в каждом конструкторе, и об этом легко забыть. В C++98 можно инициализировать переменные при помощи member initializer list. Кроме того, инициализация идёт в порядке, в котором переменные объявлены, а не в порядке member initializer list:

// C++98: member initialiser list class Widget { public: Widget() : i(0), j(0) {} // member initialiser list int get_i() const noexcept { return i; } int get_j() const noexcept { return j; } private: int i; int j;
}; int main() { Widget widget; return widget.get_i();
}

Они позволяют инициализировать все переменные одновременно, и это даёт уверенность, что все элементы инициализированы: В C++11 были добавлены инициализаторы элементов по умолчанию (direct member initializers), которыми пользоваться значительно удобнее.

// C++11: default member initialisers class Widget { public: Widget() {} int get_i() const noexcept { return i; } int get_j() const noexcept { return j; } private: int i = 0; // default member initialisers int j = 0;
}; int main() { Widget widget; return widget.get_i();
}

Их можно использовать как со встроенными типами (float и int), так и с объектами. Моя первая рекомендация: когда можете, всегда используйте DMI (direct member initializers). Привычка инициализировать элементы заставляет подходить к этому вопросу более осознанно.

Второй способ — копирующая инициализация. Итак, первый унаследованный от С способ инициализации — инициализация по умолчанию, и ей пользоваться не следует. В этом случае мы указываем переменную и через знак равенства — её значение:

// copy initialization
int main() { int i = 2;
}

Копирующая инициализация также используется, когда аргумент передаётся в функцию по значению, или когда происходит возврат объекта из функции по значению:

// copy initialization
int square(int i) { return i * i;
}

Копирующая инициализация — это не присвоение значения. Знак равенства может создать впечатление, что происходит присвоение значения, но это не так. В этом докладе вообще ничего не будет про присвоение.

У последовательности преобразования есть определенные правила, например, она не вызывает explicit конструкторов, поскольку они не являются преобразующими конструкторами. Другое важное свойство копирующей инициализации: если типы значений не совпадают, то выполняется последовательность преобразования (conversion sequence). Поэтому, если выполнить копирующую инициализацию для объекта, конструктор которого отмечен как explicit, происходит ошибка компиляции:

struct Widget { explicit Widget(int) {}
}; Widget w1 = 1; // ERROR

Более того, если есть другой конструктор, который не является explicit, но при этом хуже подходит по типу, то копирующая инициализация вызовет его, проигнорировав explicit конструктор:

struct Widget { explicit Widget(int) {} Widget(double) {}
}; Widget w1 = 1; // вызывает Widget(double)

Она выполняется, когда массив инициализируется рядом значений в фигурных скобках: Третий тип инициализации, о котором я хотел бы рассказать — агрегатная инициализация.

int i[4] = {0, 1, 2, 3};

Если при этом не указать размер массива, то он выводится из количества значений, заключённых в скобки:

int j[] = {0, 1, 2, 3}; // array size deduction

Эта же инициализация используется для агрегатных (aggregate) классов, то есть таких классов, которые являются просто набором публичных элементов (в определении агрегатных классов есть ещё несколько правил, но сейчас мы не будем на них останавливаться):

struct Widget { int i; float j;
}; Widget widget = {1, 3.14159};

Этот синтаксис работал ещё в С и С++98, причём, начиная с С++11, в нём можно пропускать знак равенства:

Widget widget{1, 3.14159};

Поэтому, если попытаться использовать агрегатную инициализацию (как со знаком равенства, так и без него) для нескольких объектов с explicit конструкторами, то для каждого объекта выполняется копирующая инициализация и происходит ошибка компиляции: Агрегатная инициализация на самом деле использует копирующую инициализацию для каждого элемента.

struct Widget { explicit Widget(int) {}
}; struct Thingy { Widget w1, w2;
}; int main() { Thingy thingy = {3, 4}; // ERROR Thingy thingy {3, 4}; // ERROR
}

А если для этих объектов есть другой конструктор, не-explicit, то вызывается он, даже если он хуже подходит по типу:

struct Widget { explicit Widget(int) {} Widget(double) {}
}; struct Thingy { Widget w1, w2;
}; int main() { Thingy thingy = {3, 4}; // вызывает Widget(double) Thingy thingy {3, 4}; // вызывает Widget(double)
}

Вопрос: какое значение возвращает эта программа? Рассмотрим ещё одно свойство агрегатной инициализации.

struct Widget { int i; int j;
}; int main() { Widget widget = {1}; return widget.j;
}

Скрытый текст

Если при агрегатной инициализации пропустить некоторые элементы в массиве значений, то соответствующим переменным присваивается значение нуль. Совершенно верно, нуль. Оно работает с агрегатными классами и с массивами: Это очень полезное свойство, потому что благодаря нему никогда не может быть неинициализированных элементов.

// все элементы инициализируются нулями
int[100] = {};

Как вы думаете, какое значение возвращает эта программа? Другое важное свойство агрегатной инициализации — пропуск скобок (brace elision). Что мы получим, если передадим ей два инициализирующих значения: {1, 2}? В ней есть Widget, который является агрегатом двух значений int, и Thingy, агрегат Widget и int.

struct Widget { int i; int j;
}; struct Thingy { Widget w; int k;
}; int main() { Thingy t = {1, 2}; return t.k; // что мы получим?
}

Скрытый текст

Здесь мы имеем дело с подагрегатом (subaggregate), то есть с вложенным агрегатным классом. Ответ: нуль. В этом случае выполняется рекурсивный обход субагрегата, и {1, 2} оказывается эквивалентно {{1, 2}, 0}. Такие классы можно инициализировать, используя вложенные скобки, но одну из этих пар скобок можно пропустить. Надо признать, это свойство не вполне очевидное.

Это может быть сделано несколькими способами. Наконец, от С также унаследована статическая инициализация: статические переменные всегда инициализируются. В этом случае инициализация происходит во время компиляции. Статическую переменную можно инициализировать выражением-константой. Если же переменной не присвоить никакого значения, то она инициализируется значением нуль:

static int i = 3; // инициализация константой
statit int j; // инициализация нулем int main() { return i + j;
}

Если же переменная инициализируется не константой, а объектом, могут возникнуть проблемы. Эта программа возвращает 3, несмотря на то, что j не инициализировано.

Вот пример из реальной библиотеки, над которой я работал:

static Colour red = {255, 0, 0};

Это допустимое действие, но как только появляется другой статический объект, в инициализаторе которого используется red, появляется неопределённость, поскольку нет жёсткого порядка, в котором инициализируются переменные. В ней был класс Colour, и основные цвета (red, green, blue) были определены как статические объекты. К счастью, в С++11 стало возможным использовать конструктор constexpr, и тогда мы имеем дело с инициализацией константой. Ваше приложение может обратиться к неинициализированной переменной, и тогда оно упадёт. В этом случае никаких проблем с порядком инициализации уже не возникает.

Итак, от языка C унаследованы четыре типа инициализации: инициализация по умолчанию, копирующая, агрегатная и статическая инициализации.

Пожалуй, наиболее важная возможность, отличающая С++ от С — это конструкторы. Перейдём теперь к С++98. Вот пример вызова конструктора:

Widget widget(1, 2);
int(3);

Этот синтаксис называется прямой инициализацией. При помощи этого же синтаксиса можно инициализировать встроенные типы вроде int и float. Она выполняется всегда, когда у нас есть аргумент в круглых скобках.

Если же речь идёт о пользовательских типах, то, в отличие от копирующей инициализации, при прямой инициализации можно передавать несколько аргументов. Для встроенных типов (int, bool, float) никакого отличия от копирующей инициализации здесь нет. Собственно, ради этого прямую инициализацию и придумали.

Вместо этого происходит вызов конструктора при помощи разрешения перегрузки (overload resolution). Кроме того, при прямой инициализации не выполняется последовательность преобразования. У прямой инициализации тот же синтаксис, что и у вызова функции, и используется та же логика, что и в других функциях С++.

Поэтому в ситуации с explicit конструктором прямая инициализация работает нормально, хотя копирующая инициализация выдаёт ошибку:

struct Widget { explicit Widget(int) {}
}; Widget w1 = 1; // ошибка
Widget w2(1); // а так можно

В такой ситуации изменение синтаксиса приведёт к вызову другого конструктора — об этом часто забывают: В ситуации же с двумя конструкторами, один из которых explicit, а второй хуже подходит по типу, при прямой инициализации вызывается первый, а при копирующей — второй.

struct Widget { explicit Widget(int) {} Widget(double) {}
}; Widget w1 = 1; // вызывает Widget(double)
Widget w2(1); // вызывает Widget(int)

Прямая инициализация применяется всегда, когда используются круглые скобки, в том числе когда используется нотация вызова конструктора для инициализации временного объекта, а также в выражениях new с инициализатором в скобках и в выражениях cast:

useWidget(Widget(1, 2)); // вызов конструктора
auto* widget_ptr = new Widget(2, 3); // new-expression with (args)
static_cast<Widget>(thingy); // cast

Это значит, что всё, что компилятор может прочитать как объявление (declaration), он читает именно как объявление. Этот синтаксис существует столько, сколько существует сам С++, и у него есть важный недостаток, который упомянул Николай в программном докладе: the most vexing parse.

Рассмотрим пример, в котором есть класс Widget и класс Thingy, и конструктор Thingy, который получает Widget:

struct Widget {}; struct Thingy { Thingy(Widget) {}
}; int main () { Thingy thingy(Widget());
}

Этот код объявляет функцию, которая получает на вход другую функцию, которая ничего не получает на вход и возвращает Widget, а первая функция возвращает Thingy. На первый взгляд кажется, что при инициализации Thingy ему передаётся созданный по умолчанию Widget, но на самом деле здесь происходит объявление функции. Код скомпилируется без ошибок, но вряд ли мы добивались именно такого поведения.

Принято считать, что существенных изменений в этой версии не произошло, но это не так. Перейдём к следующей версии — С++03. В С++03 появилась инициализация значением (value initialization), при которой пишутся пустые круглые скобки:

int main() { return int(); // UB в C++98, 0 начиная с C++03
}

В С++98 здесь возникает неопределенное поведение, потому что происходит инициализация по умолчанию, а начиная с С++03 эта программа возвращает нуль.

Правило такое: если существует определённый пользователем конструктор по умолчанию, инициализация значением вызывает этот конструктор, в противном случае возвращается нуль.

Рассмотрим подробнее ситуацию с пользовательским конструктором:

struct Widget { int i;
}; Widget get_widget() { return Widget(); // value initialization
} int main() { return get_widget().i;
}

Мы вызываем эту функцию и обращаемся к элементу i объекта Widget. В этой программе функция инициализирует значение для нового Widget и возвращает его. А если такой конструктор существует, но не инициализирует i, то мы получим неопределённое поведение: Начиная с C++03 возвращаемое значение здесь нуль, поскольку нет пользовательского конструктора по умолчанию.

struct Widget { Widget() {} // пользовательский конструктор int i;
}; Widget get_widget() { return Widget(); // value initialization
} int main() { return get_widget().i; // значение не инициализировано, происходит UB
}

Это значит, что пользователь должен предоставить тело конструктора, т. е. Стоит заметить, что «пользовательский» не значит «определённый пользователем». Если же в примере выше заменить тело конструктора на = default (эта возможность была добавлена в С++11), смысл программы изменяется. фигурные скобки. Теперь мы имеем конструктор, определённый пользователем (user-defined), но не предоставленный пользователем (user-provided), поэтому программа возвращает нуль:

struct Widget { Widget() = default; // user-defined, но не user-provided int i;
}; Widget get_widget() { return Widget(); // value initialization
} int main() { return get_widget().i; // возвращает 0
}

Смысл программы снова изменился: Widget() = default считается предоставленным пользователем конструктором, если он находится вне класса. Теперь попробуем вынести Widget() = default за рамки класса. Программа снова возвращает неопределённое поведение.

struct Widget { Widget(); int i;
}; Widget::Widget() = default; // вне класса, считается user-provided Widget get_widget() { return Widget(); // value initialization
} int main() { return get_widget().i; // снова значение не инициализировано, UB
}

Компилятор может не увидеть этот конструктор, поскольку он может быть в другом файле .cpp. Тут есть определённая логика: конструктор, определённый вне класса, может быть внутри другой единицы трансляции. Поэтому делать какие-либо выводы о таком конструкторе компилятор не может, и он не может отличить конструктор с телом от конструктора с = default.

В частности, была введена универсальная (uniform) инициализация, которую я предпочитаю называть «unicorn initialization» («инициализация-единорог»), потому что она просто волшебная. В версии С++11 было много очень важных изменений. Давайте разберёмся, зачем она появилась.

Множество неудобств вызывала проблема vexing parse с круглыми скобками. Как вы уже заметили, в С++ очень много различных синтаксисов инициализации с разным поведением. Вместо неё приходилось выполнять .reserve и .push_back, или пользоваться всякими жуткими библиотеками: Ещё разработчикам не нравилось, что агрегатную инициализацию можно было использовать только с массивами, но не с контейнерами вроде std::vector.

// вот так было нельзя, а хотелось:
std::vector<int> vec = {0, 1, 2, 3, 4}; // приходилось писать так:
std::vector<int> vec;
vec.reserve(5);
vec.push_back(0);
vec.push_back(1);
vec.push_back(2);
vec.push_back(3);
vec.push_back(4);

Предполагалось, что это будет единый синтаксис для всех типов, в котором используются фигурные скобки и не возникает проблемы vexing parse. Все эти проблемы создатели языка попытались решить, введя синтаксис с фигурными скобками но без знака равенства. В большинстве случаев этот синтаксис выполняет свою задачу.

В первом случае используются просто фигурные скобки, во втором — фигурные скобки со знаком равенства: Эта новая инициализация называется инициализация списком, и она бывает двух типов: прямая и копирования.

// direct-list-initialization
Widget widget{1, 2}; // copy-list-initialization
Widget widget = {1, 2};

Важно, что этот список не является объектом, у него нет типа. Используемый для иницализации список называется braced-init-list. Но теперь у списка в фигурных скобках появились новые возможности. Переход на С++11 с более ранних версий не создаёт никаких проблем с агрегатными типами, так что это изменение не является критическим. И если есть конструктор, принимающий на вход std::initializer_list, то вызывается именно этот конструктор: Хоть у него и нет типа, он может быть скрыто преобразован в std::initializer_list, это такой специальный новый тип.

template <typename T>
class vector { //... vector(std::initializer_list<T> init); // конструктор с initializer_list
}; std::vector<int> vec{0, 1, 2, 3, 4}; // вызывает этот^ конструктор

От него больше вреда, чем пользы. Мне кажется, что со стороны комитета С++ std::initializer_list был не самым удачным решением.

То есть это тип, у него есть функции begin и end, которые возвращают итераторы, есть собственный тип итератора, и чтобы его использовать, нужно включать специальный заголовок. Начнём с того, что std::initializer_list — это вектор фиксированного размера с элементами const. Поскольку элементы std::initializer_list являются const, его нельзя перемещать, поэтому, если T в коде выше является типом move-only, код не будет выполняться.

Используя его, мы, фактически, создаём и передаём объекты. Далее, std::initializer_list является объектом. Как правило, компилятор может это оптимизировать, но с точки зрения семантики мы всё равно имеем дело с лишними объектами.

Больше всего голосов получил именно initializer_list. Несколько месяцев назад в твиттере был опрос: если бы можно было отправиться в прошлое и убрать что-либо из C++, что бы вы убрали?

Если вы хотите более подробно познакомиться с этой темой, я очень рекомендую этот доклад. Джейсон Тёрнер недавно выступал с полуторачасовым докладом о том, как можно исправить initializer_list.

Он вызывает конструкторы, которые принимают на вход initializer_list, и эти вызовы создают много проблем по сравнению с прямой инициализацией в старом синтаксисе. Давайе разберёмся, как работает новый синтаксис. Часто приводят следующий пример:

std::vector<int> v(3, 0); // вектор содержит 0, 0, 0
std::vector<int> v{3, 0}; // вектор содержит 3, 0

На выходе получается вектор из трёх нулей. Если вызвать vector с двумя аргументами int и использовать прямую инициализацию, то выполняется вызов конструктора, который первым аргументом принимает размер вектора, а вторым — значение элемента. Если же вместо круглых скобок написать фигурные, то используется initializer_list и на выходе получается вектор из двух элементов, 3 и 0.

Есть примеры ещё более странного поведения этого синтаксиса:

std::string s(48, 'a'); // "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
std::string s{48, 'a'}; // "0a"

Это происходит потому, что конструктор string принимает на вход initializer_list из символов. В первой строке создаётся строка из 48 символов «а», а во второй строка «0а». В ASCII число 48 — код символа «0». 48 является целочисленным значением, поэтому оно преобразуется в символ. Но вместо вызова этого конструктора происходит совершенно неочевидное преобразование. Это очень странно, потому что есть конструктор, принимающий именно такие аргументы, int и char. В итоге получается код, который чаще всего ведёт себя не так, как мы ожидаем.

Как вы думаете, что возвращает эта программа? Ещё больше трудностей возникает при использовании шаблонов. Какой здесь размер вектора?

template <typename T, size_t N>
auto test() { return std::vector<T>{N};
} int main () { return test<std::string, 3>().size();
}

Скрытый текст

Но если string заменить на int, ответ будет 1, потому что для std::vector<std::int> будет использован initializer_list. Мы получим вектор с тремя строками, то есть ответ — 3. А если вместо string или int использовать float, я и вовсе не знаю, что выйдет. В зависимости от шаблонного параметра вызывается либо конструктор initializer_list, либо другой конструктор. Например, мы не можем написать emplace функцию, которая работала бы для агрегатных типов с синтаксисом фигурных скобок. Предсказать поведение такого кода очень сложно, и это создаёт множество неудобств. В общем, агрегатная инициализиация и синтаксис {} не работают с шаблонами.

Теперь давайте разберёмся, что именно делает инициализация списком.

Для агрегатных типов при такой инициализации выполняется агрегатная
инициализация.
Для встроенных типов — прямая инициализация ({a}) или
копирующая инициализация (= {a});
А для классов выполняется такая последовательность:

  1. Вначале «жадно» выполняется вызов конструктора, который принимает std::initializer_list.
    Если для этого вызова необходимо сделать неочевидные преобразования — они выполняются.
  2. Если подходящего конструктора нет, выполняется обычный
    вызов конструктора () при помощи разрешения перегрузки.

Для второго шага есть пара исключений.

Исключение 1: при использовании = {a}, когда в списке один элемент a,
может быть использована инициализация копированием.

Исключение 2: пустые фигурные скобки, {}.
Пусть у нас будет тип с конструктором по умолчанию и конструктором, который принимает initializer_list.
Что происходит при вызове Widget<int> widget{}\?

template Typename<T>
struct Widget { Widget(); Widget(std::initializer_list<T>);
}; int main() { Widget<int> widget{}; // какой конструктор будет вызван?
}

На самом деле тут вызывается конструктор по умолчанию. Мы ожидаем, что произойдёт вызов конструктора, принимающего initializer_list, поскольку мы передали ему пустой initializer_list в фигурных скобках. Если и такого конструктора нет, выполняется инициализация значением. И только когда такого конструктора нет, вызывается конструктор, принимающий initializer_list. К сожалению, все эти правила необходимо помнить.

Здесь, опять-таки, нужно помнить, что при Widget() = default и Widget() {} наблюдается разное поведение — об этом мы уже говорили. Разберём подробнее инициализацию значением при использовании {}.

Widget() = default:

struct Widget { Widget() = default; int i;
}; int main() { Widget widget{}; // инициализация значением (нулем), не происходит vexing parse return widget.i; // возвращает 0
}

Widget() {}:

struct Widget { Widget() {}; // user-provided конструктор int i;
}; int main() { Widget widget{}; // инициализация значением, вызывается дефолтный конструктор return widget.i; // не инициализирована, возникает UB
}

Если для инициализации int использовать double, это является сужающим преобразованием, и такой код не компилируется: У инициализации списком есть полезное свойство: не допускаются преобразования, сужающие диапазон значений (narrowing conversions).

int main() { int i{2.0}; // ошибка!
}

Это нововведение C++11, и оно вызывает больше всего ошибок при обновлении кода, написанного на более старых версиях языка. То же самое происходит, если агрегатный объект инициализировать списком элементов double. Это создаёт много работы при поддержке больших объёмов унаследованного кода:

struct Widget { int i; int j;
}; int main() { Widget widget = {1.0, 0.0}; // ошибка в С++11 в отличие от C++98/03
}

С одной стороны, использовать вложенные фигурные скобки бывает очень полезно, они вносят ясность. Далее, при инициализации списком можно использовать вложенные фигурные скобки, но, в отличие от агрегатной инициализации, с ними не работает пропуск скобок (brace elision). Тогда внешние фигурные скобки инициализируют этот map, а внутренние фигурные скобки — его элементы: Например, у нас есть map.

std::map<std::string, std::int> my_map {{"abc", 0}, {"def", 1}};

Давайте рассмотрим такой случай: Но бывают случаи, когда от этой конструкции только вред.

std::vector<std::string> v1 {"abc", "def"}; // OK
std::vector<std::string> v2 {{"abc", "def"}}; // ??

В первой строке мы используем initializer_list из двух строк, поэтому в результате, очевидно, получается вектор из двух строк. Напомню, это не агрегатная инициализация, это инициализация списком с initializer_list. Попробуем разобраться, почему. А если заключить эти скобки в ещё одну пару фигурных скобок, получается неопределённое поведение.

Эта строка инициализируется внутренним списком, в котором два const char*. Внешний initializer_list имеет только один элемент — внутренний initializer_list, так что мы получим вектор с одной строкой. Так что эти две строки преобразуются в итераторы. Оказывается, у string есть конструктор, принимающий на вход итераторы char для начала и конца. Далее выполняется чтение с начала, оно доходит до неинициализированной памяти, и программа падает.

Мораль:

  • читайте списки с фигурными скобками снаружи вовнутрь;
  • без агрегатного типа пропуск скобок не работает.

Передача и возврат braced-init-list также является инициализацией копированием списка. Идём дальше. Это очень полезное свойство:

Widget<int> f1() { return {3, 0}; // copy-list инициализация возвращаемого значения
} void f2(Widget);
f2({3, 0}); // copy-list инициализация аргумента

А если передать braced-init-list функции, это также приведёт к инициализации копированием списка. Если происходит возврат по значению, то используется инициализация копированием, поэтому при возврате braced-init-list используется инициализация копированием списка.

На StackOverflow недавно был замечательный пост, в котором рассматривался один и тот же вызов функции с разными уровнями вложенности. Конечно, это приводит к некоторым затруднениям в случае со вложенными скобками. Я не буду вдаваться в подробности, потому что там всё очень сложно, но сам этот факт показателен: Выяснилось, что результаты на всех уровнях разные.

#include <iostream> struct A { A() {} A(const A&) {}
}; struct B { B(const A&) {}
}; void f(const A&) { std::cout << "A" << std::endl; }
void f(const B&) { std::cout << "B" << std::endl; } int main() { A a; f( {a} ); // A f( {{a}} ); // ambiguous f( {{{a}}} ); // B f({{{{a}}}}); // no matching function
}

Мы обсудили все инициализации прошлых версий, плюс инициализацию списком, которая часто работает по совсем не очевидным правилам. Итак, мы прошли все версии до C++11 включительно. В нём были исправлены некоторые проблемы, доставшиеся от прошлых версий. Поговорим теперь о C++14.

Выше я уже говорил о том, что direct member initializers очень полезны. Например, в С++11 у агрегатных классов не могло быть direct member initializers, что вызывало совершенно ненужные затруднения. Начиная с С++14, у агрегатных классов могут быть direct member initializers:

struct Widget { int i = 0; int j = 0;
}; Widget widget{1, 2}; // работает начиная с C++14

Если в С++11 после auto следовал braced-init-list, это всегда приводило к выведению типа std::initializer_list: Второе улучшение Николай уже упоминал в программном докладе, оно связано с auto.

int i = 3; // int
int i(3); // int
int i{3}; // int
int i = {3}; // int auto i = 3; // int
auto i(3); // int
auto i{3}; // В С++11 — std::initializer_list<int>
auto i = {3}; // В С++11 — std::initializer_list<int>

В С++14 это поведение изменили, и auto i{3} теперь читается как int. Такое поведение нежелательно: когда пишут auto i{3}, чаще всего имеют ввиду int, а не std::initializer_list<int>. Впрочем, auto i = {3} всегда читается как std::initializer_list<int>. Если же в фигурных скобках в этом примере несколько значений, то такой код не компилируется. Как видим, здесь всё равно остаётся непоследовательность: при прямой инициализации списка получается int, а при копирующей инициализации — initializer_list.

auto i = 3; // int
auto i(3); // int
auto i{3}; // в С++14 — int, но работает только для списка из одного элемента
auto i = {3}; // так и осталось std::initializer_list<int>

Если есть желание, об этом можно почитать самостоятельно. Наконец, в C++14 была решена проблема со статической инициализацией, но она была значительно менее важной, чем те, о которых я сейчас рассказал, и останавливаться на ней мы не будем.

Несмотря на все эти фиксы, в С++14 осталось много проблем с инициализацией списком:

  • Не сразу понятно, вызывается ли конструктор, принимающий std::initializer_list.

  • Сам std::initializer_list не работает с move-only типами.

  • Синтаксис практичеcки бесполезен для шаблонов, поэтому emplace или make_unique нельзя использовать для агрегатных типов.

  • Есть некоторые неочевидные правила, о которых мы уже говорили:

    • пустые фигурные скобки ведут себя иначе, чем не-пустые;
    • вложенные фигурные скобки ведут себя неочевидным образом;
    • auto работает не всегда очевидным образом.

  • Наконец, я еще не рассказал, что инициализация списка совсем не работает с макросами.

Дело в том, что у макросов есть специальное правило, которое правильно читает запятую внутри круглых скобок, но оно не было обновлено для фигурных скобок. Пример про макросы: assert(Widget(2,3)) выполняется, а assert(Widget{2,3}) ломает препроцессор. Это приводит к сбою. Поэтому запятая в этом примере рассматривается как конец первого аргумента макроса, хотя скобки ещё не закрыты.

Я могу предложить несколько советов относительно того, как правильно инициализировать значения в С++.

е. Для простых типов вроде int используйте инициализацию копированием, т. знак равенства и значение — так делается в большинстве языков программирования, к этому все давно привыкли и это наиболее простой вариант.

В последнем случае мы не можем использовать синтаксис прямой инициализации, поэтому там лучше всего подходят фигурные скобки. Фигурные скобки хороши в других ситуациях: для агрегатной инициализации, для вызова конструкторов, принимающих std::initializer_list, и для direct member initializers.

При помощи двух пустых фигурных скобок можно быстро сделать инициализацию значения временного объекта. Кроме того, фигурными скобками удобно пользоваться для передачи и возвращения врéменных объектов.

struct Point { int x = 0; int y = 0;
}; setPosition(Point{2, 3});
takeWidget(Widget{});

Можно даже пропустить имя типа и использовать braced-init-list — это работает только с фигурными скобками.

setPosition({2, 3});
takeWidget({});

Я прекрасно понимаю, что в этом со мной многие не согласятся — Николай говорил, что предпочитает для этого использовать фигурные скобки. Но если необходимо просто вызвать конструктор, то лично я предпочитаю использовать прямую инициализацию, то есть классический синтаксис. Все правила здесь очевидны, не надо думать, есть тут или нет initializer_list. Мне кажется, что круглые скобки более очевидны, поскольку тогда синтаксис такой же, как при вызове функции, и сразу ясно, что выполняется разрешение перегрузки. Мне этот подход кажется более простым и ясным: когда смотришь на такой код, сразу ясно, что он делает.

Ещё раз списком:

  • = value для простых типов

  • = {args} и = {}:

    • для агрегатной инициализации
    • для конструкторов от std::initializer_list
    • для direct member initialisation (с ними нельзя использовать (args))

  • {args} и {} для передачи и возврата врéменных объектов

  • (args) для вызова конструкторов

Но на этот счёт есть ещё один совет. Правда, при использовании (args) мы сталкиваемся с проблемой vexing parse. Мне этот совет кажется правильным, потому в этом случае все переменные всегда инициализированы: нельзя написать auto i; — это вызовет ошибку компиляции. Герб Саттер в 2013 году написал статью, в которой говорилось, что при инициализации нового объекта практически всегда следует использовать auto. Если же нужно указать тип, это можно сделать в правой части выражения:

auto widget = Widget(2, 3);

Больше того, если следовать этой рекомендации и писать тип в правой части выражения, то не возникает проблемы vexing parse: Смысл тот же, но так вы никогда не забудете инициализировать переменную.

auto thingy = Thingy();

Изначально это правило формулировалось как «почти всегда auto» («almost always auto», AAA), поскольку в С++11 и С++14 при таком написании код не всегда компилировался, как, например, в случае с таким std::atomic<int>:

auto count = std::atomic<int>(0); // C++11/14: ошибка
// std::atomic is neither copyable nor movable

Несмотря на то, что в нашем синтаксисе никакого копирования и перемещения не происходит, всё равно было требование, чтобы использовался соответствующий конструктор, хоть вызова к нему и не происходило. Дело в том, что atomic нельзя перемещать и копировать. В С++17 эта проблема была решена, было добавлено новое свойство, которое называется гарантированный пропуск копирования (guaranteed copy elision):

auto count = std::atomic<int>(0); // C++17: OK, guaranteed copy elision

Единственное исключение — это direct member initializers. Так что сейчас я советую всегда использовать auto. Элементы класса с помощью auto объявлять нельзя.

Оказалось, что у этого свойства есть довольно странные и не всегда очевидные следствия для инициализации. В С++17 также была добавлена CTAD (class template argument deduction). Кроме того, в прошлом году я выступал с докладом на CppCon, целиком посвящённым CTAD, там обо всём этом рассказано значительно подробнее. Эту тему уже затрагивал Николай в программном докладе. Инициализация списком сейчас работает лучше, чем в прошлых версиях, но, на мой взгляд, в ней ещё многое можно улучшить. По большому счёту, в С++17 ситуация та же, что и в С++11 и С++14, за исключением того, что были исправлены некоторые самые неудобные неисправности.

И да, вы угадали, в этом новом стандарте появится ещё один способ инициализации объектов: назначенная инициализация (designated initialization): Теперь давайте поговорим о С++20, то есть о грядущих изменениях.

struct Widget { int a; int b; int c;
}; int main() { Widget widget{.a = 3, .c = 7};
};

В агрегатной инициализации мы указываем элементы в начале множества, и если некоторые пропущены в конце, то для них используется нулевая инициализация. В сущности, это агрегатная инициализация с другим синтаксисом. В приведённом выше примере инициализируются а и с, а b равен нулю. С назначенной инициализацией можно инициализировать любое подмножество элементов, а для всех остальных применяется нулевая инициализация.

Работает такая инициализация только с агрегатными типами, то есть фактически это другой синтаксис для агрегатной инициализации. Важное преимущество такого подхода в том, что здесь, как и при агрегатной инициализации, не может быть неинициализированных переменных.

Сделано это было для совместимости с С, и работает так же, как в С99, с некоторыми исключениями:

  • В С++ так делать нельзя, поскольку вещи конструируются в порядке, в котором они объявлены. в С не нужно соблюдать порядок элементов, то есть в нашем примере можно сначала инициализировать с, а потом а. :

    Widget widget{.c = 7, .a = 3}; // ошибка

    К сожалению, это ограничивает применимость этой конструкции.

  • в С++ нельзя эту конструкцию нельзя использовать рекурсивно, то есть нельзя написать {.c.e = 7};, хотя можно написать {.c{.e = 7}}:

    Widget widget{.c.e = 7}; // ошибка

  • в С++ нельзя одновременно использовать назначенную и обычную инициализацию, но лично мне сложно придумать ситуацию, в которой это следовало бы делать:

    Widget widget{.a = 3, 7}; // ошибка

  • Но, опять-таки, я не думаю, что это вообще следует делать. в С++ этот вид инициализации нельзя использовать с массивами.

    int arr[3]{.[1] = 7}; // ошибка

Обсудим одно из них (wg21.link/p1008). Помимо нового вида инициализации в С++20 будут исправлены некоторые вещи из предыдущих версий, и некоторые из этих изменений были предложены мной.

В агрегатных типах с удалённым конструктором по умолчанию инициализация по умолчанию выдаёт ошибку, но агрегатная инициализация работает, и это позволяет обойти удаление конструктора, сделанное автором класса: Когда в С++17 удаляется конструктор по умолчанию, это скорее всего значит, что автор кода хочет запретить создание экземпляров объекта.

struct Widget { Widget() = delete; int i; int j;
}; Widget widget1; // ошибка
Widget widget2{}; // работает в C++17, но станет ошибкой в C++20

В С++20 правила будут изменены. Это очень странное поведение, чаще всего люди о нём не знают, и это приводит к непредсказуемым последствиям. Мне кажется, это правильное решение. При объявлении конструктора тип больше не является агрегатным, так что конструкторы и агрегатная инициализация больше не входят в конфликт друг с другом. Если в классе нет объявленного пользователем конструктора, то это агрегатный тип, а если такой конструктор есть, то не агрегатный.

Braced-init-list можно использовать в выражениях new, поэтому часто спрашивают: работают ли они в этих выражениях так же, как при обычной инициализации? Было также реализовано ещё одно предложенное мной изменение (wg21.link/p1009). Обычно — да, но есть неприятное исключение: braced-init-list не выводит размер в выражениях new:

double a[]{1, 2, 3}; // OK
double* p = new double[]{1, 2, 3}; // ошибка в C++17, заработает в C++20

В С++ это будет исправлено. Об этом просто забыли, когда в С++11 создавали braced-init-list. Вряд ли много людей сталкивалось с этой проблемой, но исправить её полезно для согласованности языка.

Я уже говорил о неудобствах инициализации списком, из них в особенности неприятна невозможность использовать её с шаблонами и с макросами. Наконец, в С++20 будет добавлен ещё один способ инициализации. В С++20 это исправят: можно будет использовать прямую инициализацию для агрегатных типов (wg21.link/p0960).

struct Widget { int i; int j;
}; Widget widget(1, 2); // заработает в C++20

А это значит, что для агрегатных типов можно будет использовать emplace и make_unique. То есть можно будет писать круглые скобки вместо фигурных для агрегатной инициализации. Вновь напомню: всегда используйте auto, то есть предыдущий пример я рекомендовал бы написать следующим образом: 58. Это очень важно при написании библиотек. 11.

struct Widget { int i; int j;
}; auto widget = Widget(1, 2);

Кроме того, эта новая возможность будет работать с массивами:

int arr[3](0, 1, 2);

0. На мой взгляд, это очень важно: назовём это uniform инициализацией 2. Если агрегатную инициализацию можно будет выполнять и с фигурными, и с круглыми скобками, то, в сущности, круглые и фигурные скобки будут делать почти одно и то же. Вновь будет достигнута некоторая однородность. Это позволяет однозначно указать, что именно нам необходимо. Исключение — конструктор initializer_list: если необходимо его вызвать, надо использовать фигурные скобки, если нет — круглые. Это делается для однородности с вызовами конструктора. Кроме того, фигурные скобки по-прежнему не будут выполнять сужающие преобразования, а круглые — будут.

Всегда используйте direct member initializers. Итак, вновь повторим мои рекомендации. Для вызова конструктора я предпочитаю direct member initializers — мне кажется, это делает код понятнее. Всегда пользуйтесь auto. Так что в конечном итоге выбор за вами — главное, чтобы вы знали все правила. Но я понимаю, что многие придерживаются другого мнения по этому вопросу.

Строки в этой таблице — различные типы, а столбцы — синтаксисы инициализации. Я подвёл итог всему, что мы сегодня обсуждали, в таблице. На этом у меня всё, спасибо большое за внимание.

Тимур расскажет про новые техники, представленные в С++20, и покажет, как их безопасно использовать, а также разберёт «дыры» в С++ и объяснит, как их можно пофиксить. Уже совсем скоро, в конце октября, Тимур приедет на C++ Russia 2019 Piter и выступит с докладом «Type punning in modern C++».

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»