Как убить единорога или попытка навести порядок с инициализацией переменных в языке C++

в 10:16, , рубрики: инициализация, инициализация переменных

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

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

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

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

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

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

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

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

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

СЦЕНАРИИ ИНИЦИАЛИЗАЦИИ

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

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

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

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

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

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

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

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

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

  • Это необходимо, если мы хотим «переизобрести» некоторые механики 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 };

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

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++ более простой и понятной. Разумеется, это лишь мое личное мнение, теория, с которой можно не соглашаться, но которая, на мой взгляд, заслуживает рассмотрения. Если не будет сказано, что все это - глупость и никому не нужно, можно будет попробовать переосмыслить ссылочные типы, превратившиеся со своими множественными амперсандами в какой-то дикий «фетиш». Мне кажется, там можно сильно все упростить.

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

Автор: AndreyAaabbb

Источник

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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js