Знаете, я никогда не задумывался, насколько плоха или хороша инициализация переменных в языке C++. Я просто использовал ее. И не имел никаких проблем. Но недавно я посмотрел пару видео, пролистал несколько статей и да, я должен признать… она действительно ужасна. Один очень серьезный человек даже сказал, что мы, как сообщество программистов, виновны в том, что C++ не настолько хорош, насколько он мог бы быть.

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

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

Статья требует хорошего знания C++. Она не предназначена для новичков и тех, кто хочет изучить язык.

UPD: Эту ремарку я вынужден сделать на основе первых комментариев. Статья получилась сложная. Это - не туториал. Мы пытаемся размышлять о том, что можно было бы изменить в C++, чтобы заметно упростить (и упорядочить) некоторые вещи, сохранив обратную совместимость. Это не так просто, как кажется. И кому-то, вероятно, придется перечитать все лишний раз, чтобы понять, что именно мы делаем и зачем. Не потому, что я - такой хороший автор, а потому, что плохой. Но я сразу хочу сказать тем, кто решил «пробежаться по диагонали», найти какой-нибудь «косяк» или просто не понять написанное, а потом устроить «фрик-шоу с конями» в комментариях: «Друзья мои, боюсь вы «вошли не в ту дверь».

Используемая терминология

Думаю, для большинства наши «новые» термины не будут такими уж новыми, зато сразу подскажут, чем мы собираемся заниматься.

Агрегатные присвоения - Присвоения анонимного объекта именованному. Например: my_obj = { 2, 6 }. От агрегатной инициализации это отличается тем, что мы не обнуляем опущенное и можем использовать такие операции для целей, отличных от инициализации.

Специальные конструкторы - Конструкторы преобразования и конструкторы копирования. Особенностью таких конструкторов является то, что они могут вызываться неявным образом.

Специальный оператор присвоения - Оператор присвоения (или копирования), который принимает в качестве параметра объект другого типа.

Сценарии инициализации

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

Итак, говоря об инициализации переменных, важно понимать, что возможны два сценария:

1. Мы хотим ускорить разработку и нам все равно, потеряем ли мы (весьма незначительно) производительность из-за обнуления переменных, которые могут оставаться неинициализированными.

2. Нам нужен полный контроль над тем, что и как инициализируется.

Рассмотрим эти ситуации отдельно.

UPD: Лишний раз хочу уточнить, ниже рассматривается инициализация в том виде, в котором она могла бы быть реализована. Не так, как это работает сейчас! И пусть Вас не смущает сходство концепций. Выше головы мы в данном вопросе не прыгнем, да этого и не требуется.

Сценарий «Нам всё равно»

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

  • Мы новички, которые еще не знают, зачем нужна инициализация.

  • Мы могли бы просто забыть (или мы избалованы другим языком).

  • Это необходимо, если мы хотим «переизобрести» некоторые механики C++, не разрушая имеющийся код.

Не вдаваясь в тонкие материи заметим, что в C++ существует три различных синтаксиса инициализации: инициализация без скобок, инициализация с использованием круглых скобок и инициализация с использованием фигурных скобок.

Инициализация без скобок

Рекомендация: Давайте использовать инициализацию без скобок для инициализации объекта или переменной по умолчанию. В сценарии «Нам все равно» это практически то же самое, что инициализация значениями (value-initialization).

Например:

class MyType
{
     int a;
     int b = 5;
};

int val;                     // val=0
MyType obj;                  // a=0, b=5

int* pi = new int;           // *pi=0
MyType* pobj = new MyType;   // pobj->a=0, pobj->b=5

char s1[5];                  // s1[0]=s1[1]=s1[2]=s1[3]=s1[4]=0
MyType a1[2];                // a1[0].a=0, a1[0].b=5, a1[1].a=0, a1[1].b=5

MyType* a2 = new MyType[2];  // a2[0].a=0, a2[0].b=5, a2[1].a=0, a2[1].b=5
char*   s2 = new char[5];    // s2[0]=s2[1]=s2[2]=s2[3]=s2[4]=0

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

Важный момент: если для класса предусмотрен конструктор по умолчанию, он вызовется неявно во всех указанных выше случаях, даже для элементов массива.

Инициализация с использованием круглых скобок

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

И ТОЛЬКО ДЛЯ ЭТОГО. Скобки не нужны нам для инициализации значениями, потому что мы и так это делаем. Для инициализации с использованием круглых скобок у нас есть два типа синтаксиса:

MyType obj(...);             // syntax 1
MyType obj = MyType(...);    // syntax 2 involves copy-elision

Я считаю, что оба они имеют право на существование, поскольку синтаксис 1 является кратким, а синтаксис 2 - более универсальным. Использование круглых скобок для инициализации встроенных типов выглядит странновато, но не нарушает общей концепции, поскольку имитирует конструктор копирования.

Рассмотрим несколько примеров:

class MyType
{
     int a, b, c = 5;
  
 public:
     MyType(int b) { this->b = b; }
};

int val(7);                    // val=7
int val = int(7);              // val=7

MyType obj(3);                 // a=0, b=3, c=5
MyType obj = MyType(3);        // a=0, b=3, c=5

int* pi = new int(7);          // *pi=7
MyType* pobj = new MyType(3);  // a=0, b=3, c=5

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

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

Инициализация с использованием фигурных скобок

Рекомендация: Давайте использовать синтаксис инициализации с фигурными скобками для «агрегатных присвоений».

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

MyType  o1 = { 2, 4, 6 };                    // o1.a=2, o1.b=4, o1.c=6
MyType* o2 = new MyType { 2, 4, 6 };         // o2.a=2, o2.b=4, o2.c=6

С массивами это выглядит так:

char   s1[] = “abcdef”;                      // no braces but still ‘curly-braces stuff’
MyType a1[] = { { 2, 4, 6 }, { 2, 4, 6 } };  // a1[0].a=2, a1[0].b=4, a1[0].c=6, a1[1].a=2, a1[1].b=4, a1[1].c=6

char*    s2 = new char[] { “abcdef” };       // s2=“abcdef”
MyType*  a2 = new MyType[] { { 2, 4, 6 }, { 2, 4, 6 } };

Существует особый синтаксис, который может быть полезен для возврата объекта в исходное состояние:

obj = { };

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

MyType obj = { .a=2, .b=4, .c=6 };  // designated initialization

Помимо этого, я хотел бы предложить следующее:

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

- Еще один синтаксис, позволяющий пропускать значения:

MyType obj = { 2,  , 6 };  // a=2, c=6, b is left intact

Теперь давайте рассмотрим нововведения (хотя введены они уже довольно давно), способные вызывать самые неожиданные проблемы в совершенно неожиданных местах.

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

MyType* arr = new MyType[5] { { 2, 4, 6 }, { 2, 4, 6 } };

В приведенном выше примере мы выделяем массив из пяти объектов и вызываем конструктор, принимающий три параметра, для первых двух. Угадайте, что произойдет с остальными тремя объектами.

Иногда все это может оказаться довольно неожиданным. Как вы думаете, что сделает следующая строка для объекта с конструктором, принимающим три параметра?

obj = { 2, 4, 6 };

Правильно, она вызовет этот самый конструктор. Для уже инициализированного объекта… Да ладно, парни!

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

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

std::vector<int> v1(3, 0);  // v1[0]=0, v1[1]=0, v1[2]=0
std::vector<int> v2{3, 0};  // v2[0]=3, v2[1]=0

В первой строке вызывается конструктор, принимающий два параметра, во второй – список инициализаторов.

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

У меня есть несколько спорная концепция, которая, как мне кажется, могла бы решить эту проблему, а заодно и гармонизировать «понапридуманное». Я называю ее «Трехстадийная инициализация».

Трехстадийная инициализация

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

void TypeA::operator= (TypeB&) { }  // special assignment operator

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

Изменив это поведение, наш специальный оператор присвоения можно было бы использовать следующим образом:

class MyType
{
     int a, b, c = 5;
  
     struct init_obj     // initializer-object
     {
           int a, b;
     };
  
public:
     void operator= (init_obj& o) { a = o.a; b = o.b; }
};

MyType obj1;             // default constructor call (if defined)
MyType obj2 = { 2, 4 };  // default constructor call (if defined), then assignment operator call

Таким образом, стадии нашей трехстадийной инициализации это:

- инициализация по умолчанию, которая в нашем случае является инициализацией значениями;
- вызов конструктора (если он имеется);
- вызов специального оператора присвоения (если указано).

При этом, первые две стадии необходимы и достаточны для создания полноценного объекта, а последняя - необязательна. Логика здесь предельна проста. Представьте, что вы объявляете переменную и тут же присваиваете ей значение. А могли бы и не присваивать:

float a = 0.5;
float b;

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

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

obj1 = { 2, 4 };
obj1 = obj2;

То, что теперь выглядит как копирование (или присвоение), по сути им всегда и является. Но главное, как мне кажется то, что это устраняет целый ряд правил и оговорок, делая все понятным из кода, из синтаксиса. Желающим взглянуть на имеющуюся сейчас картину в целом, могу предложить следующий перечень для ознакомления: Default initialization, Value initialization, Direct initialization, Copy-initialization, List initialization, Aggregate initialization, Reference initialization, Copy elision, Static initialization, Zero initialization, Constant initialization, Dynamic non-local initialization, Ordered dynamic initialization, Unordered dynamic initialization, Class member initialization, Member initializer list, Default member initializer.

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

Есть одно основное правило. Присутствуют круглые скобки, значит имеет место вызов конструктора (конструктор это, строго говоря, функция). А если круглых скобок нет, то происходит копирование данных либо при помощи нашего оператора присвоения, либо побайтно, либо ничего не происходит вовсе, так как данные уже там, где требуется. ВСЕ. Нюансы, которые при этом могут возникнуть, новичкам - неинтересны, а спецам - понятны интуитивно.

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

Конструкторы преобразования (типов)

Такие конструкторы инициализируют объект, используя данные объекта другого типа.

Минимум, что мы могли бы сделать, это перевести конструктор преобразования в категорию явных, если есть оператор копирования с идентичной ему сигнатурой (как если бы он был объявлен «explicit»). Но, думаю, мы могли бы пойти еще дальше и сделать все конструкторы преобразования явными по умолчанию. В свое время было серьезное брожение в умах по поводу их «неявности». Поправьте, если что путаю, но в результате этого и появилось вышеупомянутое ключевое слово.

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

MyType1 obj1;
MyType2 obj2;

obj1 = obj2;             // what is better to use here?

Какой вариант Вам кажется логичнее?

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

MyType1 obj1;
MyType2 obj2;

void MyFunction(obj1 a) { … }

MyFunction(obj2);        // implicit type conversion
MyFunction(obj1(obj2));  // explicit type conversion

Последний вариант использования конструктора преобразования является потенциально опасным и это, собственно, инициализация объекта:

MyType1 obj1;

MyType2 obj2(obj1);      // constructor syntax
MyType2 obj2 = obj1;     // assignment syntax

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

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

Конструкторы копирования (генераторы копий)

Когда мы передаем в функцию объект по значению, компилятор бросается создавать для нас его копию. Если наш объект не является агрегатом, то для этого ему потребуется конструктор копирования.

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

На первый взгляд выглядит громоздко, но такой подход безопаснее. Конструктор по умолчанию вызовется для нас автоматически, а позднее мы рассмотрим, как напомнить программисту не забыть написать соответствующий оператор присвоения. Что же касается специального конструктора копирования, то кто о нем вообще когда вспоминает? Разумеется, если такой конструктор уже определен, нам следует использовать его, чтобы не разломать имеющийся код.

Запрет на копирование объекта мог бы выглядеть следующим образом:

MyType& operator= (MyType&) = delete;

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

Давайте рассмотрим инициализацию массива с использованием операторов присвоения:

MyType arr[10] =    // allocate an array of 10 elements
{
     { 2, 4, 6 },   // invoke ‘special’ assignment operator taking 3 ints
     “asdf”,        // invoke ‘special’ assignment operator taking a string
     ,              // invoke no ‘special’ assignment operator
     { { 2, 4 }, { 3, 7 } }, // invoke ‘special’ assignment operator taking initializer-list
     { 2, 4 },      // invoke another ‘special’ assignment operator
     SomeObj        // invoke another ‘special’ assignment operator taking SomeObj
};

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

У оператора присвоения есть интересная особенность. Он всегда принимает один параметр. Мы могли бы использовать это, чтобы избавиться от объекта-инициализатора, расписав его содержимое в сигнатуре оператора. В этом случае наш специальный оператор присвоения будет выглядеть следующим образом:

void operator= (int a, int b) { }  // assignment operator taking two values

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

void operator= (init_obj (&o)[])  // won’t compile
{
     for (int i=0; i < ARRAYSIZE(o); i++)
     {
          ...
     }
}

Описанное выше не сработает, поскольку размер передаваемого массива не указан. Но можно так:

template<size_t N> void operator= (init_obj (&o)[N])
{
     for (int i=0; i < N; i++)
     {
          ...
     }
}

Я считаю, что это - слишком базовый функционал, чтобы использовать здесь интерфейсы. Ну не C# это. И range-based for тоже можно было бы реализовать без них.

Инициализаторы в определении конструктора

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

struct A
{
     int e, f;
};

class T
{
     int c;

public:
     T(int c) { this->c = c; }
};

class MyType
{
     int i;     // value
     A a, b;    // aggregates
     T t;       // non-aggregate

public:
     MyType();
};

Конструктор MyType может использовать инициализаторы в своем определении следующим образом:

MyType::MyType() :
i(5),           // assignment
a{ 2, 4 },      // aggregate initialization
t(9)            // constructor call
{
}

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

MyType::MyType() :
i = 5,          // assignment
a = { 2, 4 },   // aggregate initialization
t(9)            // constructor call
{  
}

Поправьте меня, если я ошибаюсь, но это, пожалуй, все, что нам нужно для инициализации. Предлагаемые подходы допускают развитие, но смысл того, что мы делаем, не в том, чтобы изобрести что-то новое. Скорее наоборот, мы взяли уже имеющуюся логику функционирования языка, немного убрали, немного добавили и попытались подвести под все это максимально простую теорию. Мы пытаемся навести порядок. С этой точки зрения, то многообразие форматов, которое предлагает унифицированная инициализация, нам совсем не помогает. И не нужен нам ни narrowing, ни прочие «побочные преимущества» всех этих синтаксических выкрутасов.

В общем, если я не забыл что-то важное, я предлагаю положить единорога в коробку с игрушками и пометить все прочие варианты инициализации как «deprecated».

Недостатки трехстадийной инициализации

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

__initr__ void TypeA::operator= (TypeB&) { }  // for initialization

Через какое-то время это ключевое слово можно было бы начать игнорировать.

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

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

MyType& operator= (MyType&) = default;

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

Сценарий «Нам нужно оптимизировать»

Это тот случай, когда нам нужно контролировать, что инициализируется нулем, а что нет. Поскольку это довольно редкий случай, я бы предложил ввести новое ключевое слово, указывающее переменные, которые следует оставить неинициализированными. Например, «noinit»:

noinit double val;     // val remains uninitialized (UB)

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

class MyType1
{
     noinit int a;    // UB
     noinit double b; // UB
     int c;           // c=0
};

class MyType2 noinit
{
     int a;           // UB
     double b;        // UB
     int c;           // UB
};

С массивами это могло бы быть сделано следующим образом:

int* pi = new noinit int;             // *pi contains garbage

noinit char str1[5];                  // str1 contains garbage
char* str2 = new noinit char[5];      // str2 contains garbage

Инициализация объектов пользовательского типа - случай специфический. По идее, мы должны оставить неинициализированными члены-данные, которые разработчик пользовательского типа не проинициализировал явным образом. Логика в этом есть, но это вернет ту самую неопределенность, которой мы так хотели избежать. То же самое можно сказать и о массивах таких объектов:

noinit MyType obj;                    //
noinit MyType obj[5];                 // error-prone
MyType* obj = new noinit MyType[5];   //

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

В заключение

Это были мои соображения о том, как сделать инициализацию переменных в языке C++ более простой и понятной. Разумеется, это лишь мое личное мнение, теория, с которой можно не соглашаться, но которая, на мой взгляд, заслуживает рассмотрения. Если не будет сказано, что все это - глупость и никому не нужно, можно будет попробовать переосмыслить ссылочные типы, превратившиеся со своими множественными амперсандами в какой-то дикий «фетиш». Мне кажется, там можно сильно все упростить.

Спасибо за прочтение. Комментарии приветствуются.

UPD: Статья полностью написана мной. Сперва на английском языке, затем переведена (мною же) на русский. Никакой ChatGPT я не использовал и предлагаю эту тему больше не поднимать.

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


  1. lazy_val
    31.08.2024 11:44

    Варианты:

    1. Перевод (тогда не хватает ссылки на оригинал)

    2. ChatGPT

    3. 1 + 2 (перевод с помощью ChatGPT)


  1. KanuTaH
    31.08.2024 11:44
    +5

    Иногда все это может оказаться довольно неожиданным. Как вы думаете, что сделает следующая строка для объекта с конструктором, принимающим три параметра?

    obj = { 2, 4, 6 };

    Правильно, она вызовет этот самый конструктор. Для уже инициализированного объекта… Да ладно, парни! Списки инициализаторов добавляют сумятицы.

    Неверно. Она создаст новый временный объект, а потом переместит его в obj путем вызова у него соответствующего operator=(). Дальше читать этот позор не стал. Написано скорее всего чатгпт "на основе" подобных статей (AI очень любит нести полную херню с уверенным видом), ну либо некомпетентным человеком.


    1. AndreyAaabbb Автор
      31.08.2024 11:44

      Ну не стал и не стал, о чем тогда говорить-то?

      А если Вы operator=() не описали, то будет у Вас переписанный obj.


      1. KanuTaH
        31.08.2024 11:44
        +4

        А если Вы operator=() не описали, то будет у Вас переписанный obj.

        Если я operator=() не описал, то он будет сгенерирован компилятором - за исключением тех случаев, когда это сделать невозможно, тогда будет ошибка компиляции. Изучите матчасть наконец.


        1. AndreyAaabbb Автор
          31.08.2024 11:44

          Во-первых, Вы тут никому не KanuTaH и сделайте лицо попроще, пожалуйста.

          Во-вторых, ...он будет сгенерирован компилятором... ИМЕННО. И в результате ...будет у Вас переписанный obj...

          Справедливости ради, я действительно не учел S& operator=(S&&). Похоже, что часть предлагаемого в статье уже работает. Радоваться этому или нет пока не ясно.


          1. KanuTaH
            31.08.2024 11:44
            +4

            И в результате ...будет у Вас переписанный obj...

            Это не то же самое, что повторный вызов конструктора для уже инициализированного объекта, как вы(?) пишете в статье.

            Справедливости ради, я действительно не учел S& operator=(S&&). Похоже, что часть предлагаемого в статье уже работает. Радоваться этому или нет пока не ясно.

            Обычная проблема для "улучшений", предлагаемых незнайками, не удосужившимися толком изучить, как работает "улучшаемое".


  1. AndreyAaabbb Автор
    31.08.2024 11:44

    Перевод. Ссылка на оригинал начинается так: C:\Users\ ...

    Но каким бы умным Вы не были, Вам его оттуда не достать. :)

    И причем здесь ChatGPT?


  1. crackedmind
    31.08.2024 11:44
    +1

    https://en.cppreference.com/w/cpp/language/attributes/indeterminate


    1. AndreyAaabbb Автор
      31.08.2024 11:44

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


  1. sergegers
    31.08.2024 11:44
    +3

    От агрегатной инициализации это отличается тем, что мы не обнуляем опущенное 

    АУЕ какое-то


  1. asatost
    31.08.2024 11:44
    +1

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

    Не факт, поскольку не обозначен контекст.

    Если это код внутри какой-то функции (например, main), то нулевые значения не гарантированы.


    1. AndreyAaabbb Автор
      31.08.2024 11:44

      Это не туториал.


  1. NeoCode
    31.08.2024 11:44
    +1

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

    В целом я согласен - круглые скобки это конструктор (выполнение кода), фигурные с присваиванием - поэлементное присваивание публичных полей (т.е. даже и не инициализация как таковая, а просто присваивание нескольких полей одним оператором). Фигурные скобки без оператора присваивания вообще не стоило вводить. А вот что стоило, так это ввести универсальную концепцию кортежей - как раз тех самых составных литералов в фигурных скобках - как структурно типизированных объектов. Тогда бы можно было писать что-то вроде {i, j, k} = {1,2,3}, не потребовался бы отдельный синтаксис "структурных привязок" и т.д.


    1. KanuTaH
      31.08.2024 11:44
      +2

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

      Это пока вы не пишете обобщенный код типа такого:

      template<typename T, typename... Ts>
      auto foo(Ts&&... args)
      {
        T obj(std::forward<Ts>(args)...);
        
        // Do something with obj...
        
        return obj;
      }

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


      1. NeoCode
        31.08.2024 11:44

        Ох уж эти шаблоны:) Но я не против унифицированной инициализации всего в круглых скобках (да и вообще я всегда за универсальность во всем). А зачем добавили фигурные? (на Хабре кстати множество статей с подробным разбором инициализации, например эта). Для решения проблемы "vexing parse" и для инициализации контейнеров. Первое - это проблема кривого синтаксиса функций (функция должна начинаться с ключевого слова, как во всех современных языках, а не с возвращаемого типа). Второе решается введением концепции "кортежей". Но вместо прямого решения проблемы )пусть и ломающего обратную совместимость) решили поставить заплатку на заплатке и породили еще один синтаксис инициализации.


        1. KanuTaH
          31.08.2024 11:44
          +2

          Ох уж эти шаблоны:)

          Шаблоны - это наше все.

          Но я не против унифицированной инициализации всего в круглых скобках (да и вообще я всегда за универсальность во всем)

          Так и нужно двигаться в этом направлении, как это к слову сделано в C++20, унифицировавшем инициализацию агрегатов, которую теперь можно выполнять как с помощью круглых, так и фигурных скобок (с разницей в обработке narrowing conversions). А идеи типа "давайте выполнять инициализацию агрегатов через присваивание, инициализацию путем вызова конструктора через круглые скобки, а value-initialization как default-initialization, прибьем все это гвоздями, а все остальное задепрекейтим" - это ну, такое. Такого не будет.

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

          Типа Паскаля? :)

          пусть и ломающего обратную совместимость

          Да-да, в этом все дело. Для любителей ломать обратную совместимость есть "современные языки".


  1. AnimeSlave
    31.08.2024 11:44
    +1

    Через какое-то время это ключевое слово можно было бы упразднить.

    То, что описано здесь - бесполезно. Из-за поддержки обратной совместимости из языка ничего не удаляется. То есть, если что-то попало в язык, то оно там останется навсегда. Удаление «ненужного» синтаксиса настолько редко, что можном утвердительно написать, что этого нет вовсе. Это не только в C++ так. Странно, что вы этого не знаете, и делаете такие предложения.

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

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

    В каких-то местах вы правы, что комитет сделал ошибку. Но, увы, всё в угоду обратной совместимости. Есть мнение, что если бы в стандарте C++11 сломали ABI, то язык C++ не был бы столь популярен, как сейчас. Так как переписывать код на новый стандарт не было смысла из-за маячивший на горизонте всепоглощающей Java, а в месте с ней и C#. Там ещё Go стал вырисовываться. И Rust к середине 10-х годов


    1. AndreyAaabbb Автор
      31.08.2024 11:44

      Заменил "упразднить" на "игнорировать". Строго говоря, первое, имхо, включает в себя и второе, но если это вызывает вопросы, то пусть будет так.


      1. AnimeSlave
        31.08.2024 11:44
        +2

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

        Имейте в виду, что я не консерватор. Я за развитие и модернизацию языка C++. Я хорошо понимаю, что язык C++ сейчас устарел. Он слишком перегружен специфичным поведением, оставшимся от предыдущих поколений программистов. Я сам, как разработчик на C++, жду, что C++2 от Херба Саттера станет новым стандартом, или Carbon от Google заменит этот архаичный ад интерфейсов, или мифический Circle, таки добавит безопасности like in Rust. Но, увы, пока нам придётся жить с тем, что есть, так как комитет, если бы захотел уже давно привёл бы в порядок язык. А раз этого не сделано до сих пор, значит кому-то это нужно


        1. AndreyAaabbb Автор
          31.08.2024 11:44

          Проблема у вас в том, что вы предлагаете переделать то, что переделывать в языке не будут.

          Это у НАС проблема. :)

          И просто так взять и поменять семантику объявления и инициализации никто не даст.

          Синтаксис не меняем вообще. А там, где в дело идет специальный оператор присвоения (отрывающий фигурные скобки от непосредственно инициализации), нас страхует то самое ключевое слово.

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

          :)


          1. AnimeSlave
            31.08.2024 11:44
            +2

            Это у НАС проблема. :)

            У C++ программистов нет проблем с инициализациями. Потому что используются тот вид инициализации, которым привыкли работать. Остальное просто не используется. Это плохо, но так исторически сложилось. Таков язык C++. А если у вас возникла потребность что-то поменять, то это конкретно лично у вас проблема. Ваша статья выглядит так, будто вы пытаетесь натянуть эту проблему на всех. Представьте, что комитет стоял перед такой же проблемой до 2011 года. И они вышли из ситуации таким образом, какой мы, все программисты на C++, имеем на данный момент. И рыбку съели и сковородку не помыли

            Синтаксис не меняем вообще

            Но меняете семантику. Вы добавляете ещё одно новое «что-то», перегружая уже и без того перегруженный язык. Вы же понимаете, что помимо инициализации есть, копирование, и перемещение. И это всё должно работать. Вы задумывались о том, как у вас будут работать эти механизмы вне инициализации? И как это будет внедрено в компилятор? Вопросы риторические.


            1. AndreyAaabbb Автор
              31.08.2024 11:44

              У C++ программистов нет проблем с инициализациями. Потому что используются тот вид инициализации, которым привыкли работать. Остальное просто не используется. Это плохо, но так исторически сложилось. Таков язык C++. А если у вас возникла потребность что-то поменять, то это конкретно лично у вас проблема. Ваша статья выглядит так, будто вы пытаетесь натянуть эту проблему на всех.

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

              Вы же понимаете, что помимо инициализации есть, копирование, и перемещение. И это всё должно работать. Вы задумывались о том, как у вас будут работать эти механизмы вне инициализации?

              Абзац, где "Через какое-то время это ключевое слово можно было бы упразднить".

              Вопросы риторические

              Ну почему же? Разумеется, я всего не предусмотрел. Сказать так было бы просто глупостью. Это все - чистая теория. При попытке (чисто гипотетической) реализовать подобное, наверняка вылезет куча нюансов, предусмотреть которые сейчас практически невозможно. Тем не менее, мне это кажется вполне реализуемым и это как раз-таки ничто иное, как попытка разгрузить перегруженный язык.

              В любом случае, спасибо за Ваше мнение и комментарии!


              1. AnimeSlave
                31.08.2024 11:44
                +1

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

                А почему не будем? Думаете другие не хотели бы посмотреть на материал с которого вы приняли решение написать статью в Хабр? Обычно как раз ссылки это что даёт возможность более глубоко погрузиться в контекст. А в научных кругах ссылки на другие работы показывают, что вы не с потолка взяли первичные вводные данные. Я уже выше писал, что у вас статья натягивание проблемы. Которая как бы существует. Но в текущих реалиях языка C++ несущественна.

                Абзац, где "Через какое-то время это ключевое слово можно было бы упразднить".

                У вас там нет чёткого различия в какой момент будет вызван конструктор копирования, а в какой конструктор перемещение; в какой оператор присваивания, а в какой оператор перемещения. Как компилятор поймёт, что ему делать? То есть ваша сущность отдельного ключевого слова для операции инициализации это ещё одна функция к уже имеющимся. Это не упрощение и не разгрузка. Это перегрузка. Накладывание поверх существующий логики. Примите как данность, что слома обратной совместимости не будет. Комитет это просто не примет. Пока вы не сможете уместить вашу сущность в логику существующих механизмов инициализаций. Я не удивлюсь тому, что не вы были первым, кто предложил похожую концепцию. Атрибуты в синтаксисе существуют уже давно. Добавить что-то такое не проблема. Проблема не сломать ничего


                1. KanuTaH
                  31.08.2024 11:44
                  +1

                  Это перегрузка.

                  Это не просто перегрузка. Решил таки пачетать сие творение, и тут все настолько плохо и сумбурно, что даже не знаешь, с чего начать.

                  Вот, например, автор пишет:

                  Последний вариант использования конструктора преобразования является потенциально опасным и это, собственно, инициализация объекта:

                  MyType1 obj1;

                  MyType2 obj2(obj1); // constructor syntax
                  MyType2 obj2 = obj1; // assignment syntax

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

                  Что тут является таким "потенциально апасным", я даже хз, здесь забавно другое. Получается, что если я захочу иногда делать то, что здесь называется "constructor syntax" (а в стандарте это называется direct-initialization), а иногда - то, что здесь называется "assignment syntax" (а в стандарте - copy-initialization), то мне придется писать И конструктор, И operator=(), так? Потому что про то, что происходит в предпоследней строке в случае, если обычного конструктора с параметром типа MyType1 нет, тут не написано ничего. Отмечу, что сейчас для обоих случаев мне достаточно написать только один не-explicit конструктор. А учитывая, что подавляющее большинство контейнеров, имеющих методы наподобие emplace(), используют именно direct-initialization для создания элементов внутри себя (ведь placement new не умеет в copy-initialization), то...

                  Далее, вопрос copy elision. Не знаю, знает ли об этом автор (подозреваю, что нет), но сейчас, когда погромист пишет что-нибудь вроде:

                  SomeType foo()
                  {
                    SomeType obj = {...};
                  
                    obj.x = ...;
                    [...]
                    obj.y = ...;
                  
                    return obj;
                  }
                  
                  SomeType obj = foo();

                  компилятор имеет право (а в определенных случаях даже обязан) вызывать конструктор SomeType только один раз. По автору же тут получается чистый "assignment syntax", так вот любопытно, как автор себе это представляет, особенно если у SomeType есть только конструктор по умолчанию и парочка operator=(), ведь этого (по автору) достаточно для его так называемой "трехстадийной инициализации". Из этого не слепишь единственный вызов конструктора ну никак! Компилятор будет вынужден дважды вызывать конструктор по умолчанию, и опять-таки дважды operator=() (по одному каждого типа).

                  Далее, вопрос эффективности этой самой "трехстадийной инициализации". Сейчас, даже в том теоретическом случае, если компилятор будет всегда заниматься value initialization полей, отсутствующих в списке инициализации конструктора, он всегда сможет сделать это эффективно, ведь компилятор видит, какие поля присутствуют в списке инициализации конструктора, а какие - отсутствуют, и может применить value initialization только к тем из них, которые в списке отсутствуют. В случае же "трехстадийной инициализации" (с вызовом конструктора по умолчанию и operator=() по отдельности) компилятор сможет это сделать более-менее эффективно, только если он сможет "подсмотреть" внутрь соответствующего operator=(), что может быть затруднительно, особенно если код этого оператора находится в другом модуле по отношению к модулю, в котором непосредственно производится инициализация объекта соответствующего типа, что встречается сплошь и рядом.

                  Это вот просто те вопросы, что сразу видны невооруженным глазом, я даже не буду касаться более интересных вопросов про operator=(T&&) и виртуальное наследование, и т.п. Эта статья - это вот чистая графомания дилетанта, я даже затрудняюсь сказать, есть ли в ней вообще хоть что-нибудь ценное, одни лишние сущности и пессимизации уже существующих и работающих решений.


                1. KanuTaH
                  31.08.2024 11:44

                  P.S. Кстати да, чуть не забыл еще одну очевидную вещь, бгг.

                  const SomeType obj = { ... };

                  этот банальный и практически повсеместно встречающийся код так называемой "трехстадийной инициализации" вообще не поддается!