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

Главная ошибка была в том, что ты в это вообще ввязался — в этом никаких сомнений.

Ещё когда я впервые взялся проходить курс по C++ несколько лет назад, меня учили, что если я не предоставлю собственного конструктора, то компилятор сам подберёт ему замену — своего рода конструкторы, действующие по умолчанию. Я решил подробнее в этом разобраться, особенно меня волновали случаи, которые выглядят примерно так:

struct T { /* ... */ };

T t;
T s{};
T r{arg1, arg2, ...};

Я заинтересовался, что именно это значит. Наиболее тщательно я решил изучить первые два случая — что касается третьего, меня вполне устраивало импровизированное объяснение «если T достаточно прост, то для него будет выполнена покомпонентная инициализация». Но именно в двух первых случаях кроется опасность: а что делать, если некоторые объекты останутся неинициализированными? Эти изыскания привели меня к выводам, которые я изложу ниже.

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

  • Для любого типа T, T t; выполняет инициализацию по умолчанию для t и делает это следующим образом:

o    Если T — это тип класса, причём имеется конструктор по умолчанию, то его нужно выполнить.

o    Если T — это тип массива, то нужно по умолчанию инициализировать каждый его элемент.

o    В иных случаях ничего не делать.

  • Для любого типа T, T t{}; выполняет инициализацию по значению для t и делает это следующим образом:

o    Если T — это тип класса…

Если отсутствует конструктор, задаваемый по умолчанию (напр., если пользователь объявил какие-либо конструкторы, не задаваемые по умолчанию) или если имеется предоставленный пользователем конструктор, либо конструктор по умолчанию был удалён — то инициализация выполняется так, как задано по умолчанию.

В противном случае происходит инициализация в ноль, а затем инициализация по умолчанию.

o    Если же T — это тип массива, то каждый элемент массива инициализируется по значению.

o    В противном случае происходит инициализация в ноль.

Вот  как все эти примеры выглядят на практике:

struct Pair {
    int x, y;
    Pair() : x{0}, y{1} {}
};
struct SimplePair {
    int x, y;
};

int x{}; // инициализация по значению => инициализация в ноль
int y; // инициализация по умолчанию (далее попадает в мусор)
Pair p; // инициализация по умолчанию => конструктор по умолчанию
Pair q{}; // инициализация по значению => инициализация по умолчанию => конструктор по умолчанию
SimplePair r; // инициализация по умолчанию => конструктор по умолчанию и далее попадает в мусор (подробнее об этом ниже) 

В таком случае остаётся обсудить конструктор по умолчанию, т.e., перегрузку безаргументного конструктора. Что именно предоставляет нам компилятор и когда? Считается общеизвестным, что если не объявить никаких собственных конструкторов, то компилятор объявит и (возможно) предоставит собственный. Но дьявол, как всегда, в деталях — а в C++ таких деталей чудовищно много. Соответственно, то, что в них кроется, может стать настоящим кошмаром для экзорциста.

Конструктор по умолчанию

Когда вы не объявляете каких-либо конструкторов, компилятор объявит конструктор по умолчанию за вас. О таком конструкторе говорят, что он объявляется неявно. Кроме того, существует почти такой же конструктор, который «принимается по умолчанию при первом объявлении». Два этих конструктора почти идентичны, потому что, согласно стандарту, они обычно взаимозаменяемы. Выглядят они так:

struct T {
    T() = default;
};

Совсем не факт, что эти конструкторы что-то делают – ведь они объявляются неявно или принимаются по умолчанию при первом объявлении. Но при применении такого конструктора возникает эффект домино, который скажется на всём, что делают эти конструкторы. В частности, тогда как стандартный конструктор неявно объявляется или явно принимается по умолчанию (и не определяется как удалённый), неявно определяемый конструктор по умолчанию предоставляется компилятором. Что касается реализации, он гарантированно будет эквивалентен конструктору с пустым телом и пустым списком инициализации членов (т.e., T() {}).

Итак, если бы мы сделали что-то подобное:

struct T {
    int x;
    T() = default;
};

T t{};
std::cout << t.x << std::endl;

То в консоли был бы выведен 0. Дело в том, что мы инициализировалипо значению и, поскольку у T есть свой конструктор, предоставленный по умолчанию, а не пользователем, объект инициализируется в ноль (следовательно, t.x инициализируется в ноль), а затем инициализируется по умолчанию (и вызывает неявно определяемый конструктор о умолчанию, который ничего не делает).

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

struct T {
    T();
};
T::T() = default;

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

struct T {
    int x;
    T();
};
T::T() = default;

T t{};
std::cout << t.x << std::endl;

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

Если конструктор по умолчанию не применяется (т.e., пользователь объявил любые конструкторы, кроме задаваемых по умолчанию) или если имеется конструктор, предоставленный пользователем, или удалённый конструктор по умолчанию, то выполняем инициализацию по умолчанию.

Агааа! Эту строку видите?

T::T() = default;

Это предоставлено пользователем. Если вот так определять конструктор вне класса, то мы не объявляем его сразу как действующий по умолчанию; мы определяем его как действующий по умолчанию. Предоставьте его, если хотите. Поэтому компилятор правомерно остановится на том, чтобы просто инициализировать t по умолчанию. Дело в том, что конструктор по умолчанию, явно определённый как таковой, не будет делать ровно ничего. Отлично.

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

  • У T есть нестатический ссылочный член (как думаете, во что его вообще можно было бы инициализировать по умолчанию, чтобы в этом был смысл?);

  • У T есть нестатические члены или неабстрактные базовые классы, которые в принципе не поддаются конструкции или деструкции по умолчанию (здесь я опускаю некоторые детали, но идею вы поняли) или

  • У T есть любые const, нестатические члены или члены, не конструируемые по умолчанию как константы без задаваемых по умолчанию инициализаторов членов (если член — const и его не получается по умолчанию инициализировать во что-либо полезное, то готовый объект, который у нас получится, обязательно будет постоянно содержать некоторое количество мусора).

o    Кстати: применительно к типам классов, «конструируемый с const по умолчанию» означает, что при инициализации, проводимой по умолчанию, будет неунаследованный конструктор, предоставленный пользователем. Идея в том, что const-объект этого типа поддаётся инициализации по умолчанию во что-либо внятное.

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

Здесь я опускаю некоторые детали и вообще не вдаюсь в дискуссию об объединениях, но обрисовываю ситуацию в самых общих чертах. В принципе, если в вашем классе содержатся какие-либо компоненты, не поддающиеся инициализации по умолчанию так, чтобы из них получилось что-нибудь хотя бы минимально полезное, то компилятор отступится и определит неявно объявленный конструктор по умолчанию как удалённый. Это делается в соответствии с (зачастую неблагодарной) генеральной линией С++, которую можно назвать очень разрешительной.

Вот максимально простой пример: при наличии члена const int без заданного по умолчанию инициализатора члена (напр., = 0) неявно объявленный конструктор по умолчанию определяется как удалённый. Поэтому следующий код не скомпилируется:

struct A {
    const int x;
};

A a{};

— подумали бы вы, если не очень внимательно читали то, что написано выше. Оказывается, этот гнусный код совершенно корректно сформулирован и поэтому он скомпилируется. При этом a.x инициализируется в 0. Почему? Потому что A — это агрегат. Кстати, тут вообще ничего не инициализируется по значению.

Как сделать инициализацию правильно

Хорошо, вернёмся к сути дела. Я вам солгал. Правда, лишь отчасти — все другие примеры, которые я привёл до сих пор, были тщательно подобраны так, чтобы все мои объяснения чисто технически оставались верными. Я допустил всего одно некорректное определение и намеренно старался не приметить очень большого слона в комнате.

В самом деле, если написать что-то вроде T t{};, то на самом деле в первую очередь осуществится так называемая списковая инициализация. Действительно, любой код, напоминающий по виду T t{...}, T t = {...}, а также большинство других видов инициализации с применением фигурных скобок — это, вероятно, списковая инициализация. Первые две формы, рассматриваемые здесь, называются прямая списковая инициализация и списковая инициализация с копированием соответственно. Инициализация с копированием как таковая — это инициализация объекта на основе другого объекта, обычно с применением оператора = в том или ином качестве. С другой стороны, при прямой инициализации мы формируем объект, собирая его из множества аргументов конструктора. На практике (кроме синтаксиса) разница минимальная, поэтому далее будем обсуждать преимущественно прямую списковую инициализацию.

Конечно, списковая инициализация — немного сложная штука, но, оказывается, есть ещё один вариант инициализации, занимающий промежуточное положение между рассматриваемым здесь примером и забавным примером с const. Начнём с определения:

ОПРЕДЕЛЕНИЕ

Агрегат — это или (1) массив или (2) класс, обладающий следующими свойствами:

  • Никаких унаследованных конструкторов или конструкторов, определённых пользователем;

  • Никаких приватных или защищённых прямых нестатических членов, содержащих данные;

  • Никаких приватных или защищённых прямых базовых классов и

  • Никаких виртуальных функций или виртуальных базовых классов.

В принципе, агрегат относится к таким «простым» типам, которые можно смастерить самостоятельно, и которые очень похожи на тривиальные типы и типы со стандартным смещением. Существует особый вид списковой инициализации, предназначенный специально для работы с агрегатами. Это агрегатная инициализация. Связанные с ней частности, изложенные в стандарте, несколько путаные (читай: неинтересные), но достаточно сказать, что это продвинутый вариант, при котором инициализация с копированием применяется к каждому элементу класса (или массива) в том порядке, в котором они перечислены в списке на инициализацию. Если в списке на инициализацию больше элементов, чем в агрегате, то все оставшиеся в агрегате элементы инициализируются с применением заданного по умолчанию инициализатора членов (при наличии такового). Если предполагается, что инициализация происходит не по ссылке, то в ход идёт инициализация с копированием пустого списка инициализации (как в случае = {}; это рекурсивно приведёт нас к следующему кругу списковой инициализации).

Итак, существует клей между списковой и агрегатной инициализацией: если списковая инициализация применяется к агрегату, то агрегатная инициализация выполняется во всех случаях, кроме того, когда в списке всего один аргумент, относящийся к типу T или к типу, производному от T. В последнем случае выполняется прямая инициализация (или инициализация с копированием). Следовательно, вот следующий пример:

struct S {
    int a;
    float b;
    char c;
};

S s{3, 4.0f, 'S'};

…здесь вообще нет вызовов конструкторов, о которых можно было бы поговорить.

Разумеется, здесь охватывается и списковая инициализация для агрегатов, но остаются не рассмотренными ещё несколько случаев. А именно…

  • Если T — это неагрегатный тип класса…

o    Если инициализатор пуст, а у T есть конструктор по умолчанию, то выполняется инициализация по значению.

o    В противном случае в соответствии с обычной процедурой разрешения перегрузок рассматриваются другие конструкторы — обратите внимание, что в данном случае перегрузка конструктора std::initializer_list всегда получает приоритет.

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

  • Наконец, иначе, если список на инициализацию пуст, то выполняется инициализация по значению.

Учитывая сказанное, исправлю приведённое ранее слегка неверное определение инициализации по значению: для неагрегатных типов T, T t{}; к t применяется инициализация по значению (через списковую инициализацию) вот так:

  • Если T — это тип класса…

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

o    Иначе, происходит инициализация в ноль и затем инициализация по умолчанию.

  • Иначе, если T — это тип массива, то по значению инициализируется каждый его элемент.

  • Иначе — инициализация в ноль.

Здесь добавилось всего одно слово — «неагрегатный». Я не затрагиваю здесь некоторые частности (например, работу с объединениями), но на практике они оказываются более-менее именно такими, какими следует ожидать, учитывая всё вышесказанное. Опять же, вновь вернёмся к тому гнусному примеру, рассмотренному выше:

struct A {
    const int x;
};

A a{};

Острый момент ясности и понимания: такое чувство, что если Бог существует, то это и есть откровение. Вы в любом случае благодарны. Здесь A — это явно агрегат. Поэтому списковая инициализация приводит к агрегатной инициализации, которая приводит к инициализации с копированием списка a.x. Но этот список пуст, поэтому происходит инициализация по значению и далее инициализация в ноль. Без шума и пыли. Кстати, конструкторы никогда сомнений не вызывали. Сейчас, ощущая такую силу и мощь, мы могли бы попробовать даже вот так:

A b{4};

Здесь выполняется списковая инициализация, следовательно, агрегатная инициализация, следовательно, инициализация с копированием b.a при значении 4. Да! Мы можем даже осмелиться проделать следующее:

A c{4.0f};
// ошибка: сужающее преобразование '4.0e+0f' от 'float' к 'int' [-Wnarrowing]

Чёрт. Ладно, возможно, мы просто сами себя сглазили. Забыл упомянуть, что при списковой инициализации с единственным элементом не разрешаются сужающие преобразования. Но это нормально. Всё нормально. Такой код:

A c(4.0f); // Обратите внимание: круглые, а не фигурные скобки 

В C++20 это скомпилируется.

Ору.

Списки, причёсанные

Возможно, из вашего первого курса по C++ вы припоминаете, что при инициализации почти всегда приходится использовать круглые скобки вместо фигурных. Ладно, не везде, скорее, почти везде. Вероятно, вам советовали избегать подобного по причинам, связанным с большими сложностями при парсинге: тогда как T t(a, b, c); синтаксически представляет собой вызов некого конструктора (не вполне; поговорим об этом ниже), T t(); — здесь с синтаксической точки зрения объявляется функция t, не принимающая параметров и имеющая возвращаемый тип T. Здесь вам в самом деле требуется определиться: либо T t{}; либо T t;. Правда, если такой инициализатор спискового вида с круглыми скобками зачастую функционально отличается от аналогичных инициализаторов с фигурными скобками. Инициализаторы с круглыми скобками вызывают прямую несписковую инициализацию, которая подобна по правилам прямой списковой инициализации, но отличается от неё.

Инициализаторы с круглыми скобками удобны в случаях, когда нужно инициализировать агрегаты (механизм работает как с классами, так и с массивами). В принципе, эта инициализация работает точно как и списковая, но при ней допускаются сужающие преобразования. Оставшиеся после неё элементы инициализируются непосредственно по значениям (а не через пустой список), а временные сущности привязываются к ссылкам, при этом срок их жизни не продлевается. Последнее понятно? Вот как это выглядит на практике:

struct T {
    const int& r;
};

T t(42);

Всё верно: t.r — это висячая ссылка. Попытавшись прочитать через неё информацию, получите неопределённое поведение. Да что ж это такое. В самом деле, не могу представить ситуацию, в которой такое поведение было бы желательным для кого угодно и когда угодно, но просто иногда так всё и устроено.

В любом случае, при применении круглых скобок ситуация обычно менее «зарегулирована» — то есть, разрешено больше. Наряду с теми допусками, о которых я рассказал выше, такой подход также позволяет вызвать копирующий конструктор для T на тот случай, если будет применена перегрузка конструктора std::initializer_list<T>.  Напомню, что при разрешении перегрузок именно такие перегрузки имеют приоритет, а T, инициализируемые по списковому принципу с фигурными скобками могут быть интерпретированы как std::initializer_list<T>.

struct T {
    T(std::initializer_list<T>) {
        std::cout << "list" << std::endl;
    }
    T(const T&) {
        std::cout << "copy" << std::endl;
    }
};

T t{}; // список
T s{t} // список
T r(t); // копирование
T q(T{}); // список (без копирования!)

Последняя строчка, возможно, вас удивит — ведь, учитывая всё, рассмотренное выше, следовало бы ожидать, что с T{} будет использоваться списковый конструктор, а за ним последует копирующий конструктор для q(T{}). Возможно, вы припоминаете такой феномен как «пропуск копии» (copy elision) — в сущности, это он и есть. В частности, при прямой инициализации и инициализации с копированием есть предусловия такого рода: если инициализатор — это приватное значение типа T, то объект инициализируется напрямую специальным выражением, а не временно материализуется. В стандарте такая ситуация никогда явно не именуется «пропуском». Можно даже утверждать, что теперь (со времён C++ 17) такое название не вполне корректно, но вы, вероятно, это и так знаете.

В сторону: насколько мне известно, технически такой подход работает только при несписковой инициализации, то есть, при T q(T{}); но НЕ при T q{T{}};. Аналогично, в стандарте ничего не сказано о такой списковой инициализации, которая оставляла бы место для пропусков копирования. Как GCC, так и Clang игнорируют этот аспект и, как правило, идут на пропуск. Исключение делается только, если определена перегрузка конструктора std::initializer_list<T> (как выше). В таком случае GCC вместо пропуска использует именно такую перегрузку. Думаю, это единственный пограничный случай, в котором GCC действует правильно. Думаю, вся эта проблема, в конце концов, будет устранена — см. CWG issue 2311.

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

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

На этом почти всё. Я имею в виду, что есть ещё специальные правила инициализации статических переменных (включая инициализацию констант), но, честно говоря, вам это правда интересно? На мой скромный взгляд, ключевой вывод здесь таков: просто пишите сами эти чёртовы конструкторы! Оценили, сколько здесь нонсенса? Так вот, его можно почти полностью избежать, если вы напишете собственный конструктор. Не отдавайте этого на откуп компилятору. Вы тут главный. Или хотите всем угодить? Вы с лёгкой руки  забрасываете в вашу корпоративную базу кода шесть кусков кода с неопределённым поведением — и будьте уверены, целый взвод русских хакеров уже позарился на ваше приложение. Вы что, дурак? Да что же с вами? О чём вы думали? О, Господи.

Комментарии (7)


  1. NotSure
    15.07.2024 11:13
    +7

    Оригинальный заголовок статьи (I Have No Constructor, and I Must Initialize) отсылает к названию рассказа Харлана Эллисона "I Have No Mouth, and I Must Scream", и перевести его стоило бы не в таком стиле, как у вас.

    Представьте, если бы название рассказа переводили бы как "Рта у меня нет, а кричать надо" (название совпадает с завершающими словами рассказа, где сверхразумный военный ИИ уничтожил человечество и пытает последнего человека).

    https://ru.wikipedia.org/wiki/У_меня_нет_рта,_но_я_должен_кричать



  1. Apoheliy
    15.07.2024 11:13
    +5

    далее попадает в мусор

    совсем режет глаз.

    Лучше так: "заполняется мусором". И, конечно же, потом этот мусор можно заменить осмысленным значением.


  1. kozlov_de
    15.07.2024 11:13

    После прочтения статьи я уже не уверен что утром проинициализируется солнце


    1. JoshMil
      15.07.2024 11:13

      Начнет инициализировать как массив, солнечная система начнет лагать, люди вымрут


  1. isadora-6th
    15.07.2024 11:13

    На мой скромный взгляд, ключевой вывод здесь таков: просто пишите сами эти чёртовы конструкторы! 

    Честно говоря, у меня диаметрально противоположное мнение по поводу написания своих конструкторов относительно мнения автора.

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

    Но это я пока инициализацией в ногу себе не стрелял.

    Назначенная инциализация мастхев, но насколько знаю она из мира Си и не совсем стандарт до С++23(?)

    Я об этом
    A a{.name = "John", .age=15};

    Вообще всегда относился к инициализации фигурными скобками как к "дефолт занулить", пока вроде не подводило.


    1. eao197
      15.07.2024 11:13

      Назначенная инциализация мастхев, но насколько знаю она из мира Си и не совсем стандарт до С++23(?)

      Стандарт с C++20, но там есть свои особенности.