Альтернатива спискам инициализации конструкторов

Сергей Тиунов
Сергей Тиунов

Мотивация

 

Сейчас часть конструирования объекта должна быть сделана в member initializer list'ах (далее - списки инициализации).

В частности в списке инициализации должны быть указаны:

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

Все это неплохо работает, если нужно просто правильно передать аргументы конструктора:

class MyClass : public MyBase {
public:
    MyClass(A a, B b, C& c):
        MyBase(a), _b(b), _c(c) {}
private:
    const B _b;
    C&      _c;
};

Но если требуется сделать более сложную инициализацию, то приходится прятать конструктор в private-секцию и делать фабричный метод:

static MyClass create(A a) {
    C& c = getC(a);
    B b = getB(c);
    return MyClass(a, std::move(b), c);
}

(здесь в качестве суррогатного примера сложной инициализации приведена зависимость аргументов - `b' получается из `c', `с' получается из `a' - на практике же инициализация может быть значительно сложнее).

Другой (возможно, более простой) вариант - не использовать константы и ссылки в качестве членов класса (вместо них использовать соответственно изменяемые объекты и указатели):

    MyClass(A a): MyBase(a) {
        _c = &getC(a);
        _b = getB(*_c);
    }
private:
    B       _b;
    C *     _c;

Однако в этом случае мы теряем гарантию того, что во время жизни объекта эти члены класса не изменятся - это неприятно, особенно с учетом того, что причина заключается в ограничениях конструкторов в C++.

 

Предложение

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

Аналогично и со ссылками, однако для отложенной инициализации ссылок в C++ не существует синтаксиса. Можно предложить что-нибудь вроде &x = ... (т.е. сделать &x lvalue с типом decltype(x), если это выражение указано в конструкторе и x - это ссылка).

В результате, в списке инициализации можно будет указать только вызовы конструкторов классов (direct base, virtual base или delegating), а остальные члены можно проиницилизировать в теле класса:

class MyClass : public MyBase {
public:
    MyClass(A a): MyBase(a) {
        &_c = getC(a);
        _b = getB(_c);
    }
private:
    const B   _b;
    C&        _c;
};

Что это даст?

  1. больше возможностей переместить сложную инициализацию объектов класса в конструктор класса (где им и место, на мой взгляд), и избавиться от фабричных методов (если единственной причиной их существования были ограничения конструкторов в C++)
  2. больше возможностей использовать константы и ссылки в качестве членов класса (и таким образом гарантировать их неизменность с момента инициализации объекта)
-8
рейтинг
17 комментариев
yndx-antoshkka
Существующее положение вещей никто не даст менять. Ваш подход поломает следующий код:

struct A {
void foo() const { cerr << "const"; }
void foo() { cerr << "non const"; }
};

struct B {
const A a;
B() {
a.foo(); // now calls non-const version
}
};

Однако сама идея мне нравится. Её надо как следует продумать и придумать такое решение, которое не поломает существующий код и будет красиво выглядеть.
yndx-antoshkka
Сергей Тиунов
yndx-antoshkka,

Что скажете насчет такого решения...
Можно сделать "отложенную инициализацию" member-констант и ссылок.
В конструкторе мы можем написать такой код:
_const = TypeName(args...); // user-defined types
_const = literal; // literal types

Если в конструкторе встречается строка такого вида, это означает, что мы откладываем инициализацию этого члена до этого момента. Использование этой константы до этого момента либо запрещено, либо undefined behaviour (как лучше?). Использование константы после этого момента - как обычное использование member-константы.

Если же в конструкторе не встречается строка такого вида (т.е. любой старый код), то member-константа будет инициализирована до тела конструктора.

Аналогично со ссылками:
&_ref = value;
Это выражение будет считаться "отложенной инициализацией" ссылки, до которой ее вызов приведет к UB, а после - как обычная ссылка.
Сергей Тиунов
yndx-antoshkka
Сергей Тиунов, напишите пожалуйста пример целиком. А то непонятно где писать предложенные вами конструкции.
yndx-antoshkka
Сергей Тиунов
yndx-antoshkka, Как-то так:

class MyClass : public MyBase {
public:
MyClass(A a): MyBase(a), _d(42) {

_d.baz(); // okay, _d initialized in member initialization list

_с.foo(); // undefined behaviour, _c not initialized
&_c = getC(a); // deferred initialization of _c
_c.foo(); // okay, _c initialized

_b.bar(); // undefined behaviour, _b not initialized
_b = getB(_c); // deferred initialization of _b
_b.bar(); // okay, _b initialized, calls const-version
}
private:
const B _b;
C& _c;
const D _d;
};
Сергей Тиунов
Павел Корозевцев
Сергей Тиунов, это ломает всё, что использует перегруженный оператор=. Как компилятор поймёт, что автор имел в виду: инициализировать переменную конструктором по умолчанию, а потом применить оператор=, или оператор= есть инициализация, а все использования переменной до него -- ошибки?
Всё же '=' не обязательно является инициализацией, это просто оператор. В связи с этим, как мне кажется, тут любой синтаксис, использующий существующие конструкции, обречён на поломку старого кода.
Например, как будет работать код отсюда: pastebin.com/0A4wambz ?
К тому же предложенный вами синтаксис ничего не упрощает, а только позволяет лишний раз стрельнуть в ногу при работе с (казалось бы, простой вещью) константами. В моём мире слово const перед объявлением переменной не значит ничего, кроме невозможности применения (напрямую) неконстантных методов. Ваше же предложение ломает это простое представление и вносит хаос в тело конструктора.
Павел Корозевцев
Сергей Тиунов
Павел Корозевцев, operator= недоступен для констант, поэтому никакого противоречия у компилятора не будет.

> Как компилятор поймёт, что автор имел в виду: ...
Если член класса, константа, то синтаксис x = ... не может быть operator=, т.к. operator= недоступен для констант, поэтому это отложенная инициализация.
Если член класса не константа, то синтаксис x = ... - это operator=.

> Например, как будет работать код отсюда: pastebin.com/0A4wambz ?
> T a; // конструктор по умолчанию
> T b(1); // конструктор с отложенной инициализацией

Конструктор по умолчанию не использует отложенную инициализацию, поэтому сработает по-старому и напечатает "0 const".
Конструктор с параметром использует отложенную инициализацию члена val, и пытается использовать его неинициализированное значение. Таким образом, первый вызов print приведет к undefined behaviour (например, напечатает "-858993460 const"), а второй вызов print напечатает уже инициализированное значение "1 const".
В этом примере operator= не будет вызван нигде.

Другими словами, никаких двусмысленностей (ambiguity) у компилятора не будет. Но если Вас смущает похожесть на operator=, или смущает, что таким образом нельзя инициализировать не-константу, то можно предложить альтернативный синтаксис для user-defined типов, например,
вместо: val = f(x); // some complicated 'initialisation'
вот так: val.V(f(x)); // explicitly call constructor

Т.е. мы явным образом вызываем конструктор, ни с чем не спутаешь. Аналогичный явный вызов деструктора объекта разрешен уже давно.
Сергей Тиунов
Сергей Тиунов
Павел Корозевцев, по поводу философии const.

> В моём мире слово const перед объявлением переменной не значит ничего, кроме невозможности применения (напрямую) неконстантных методов.

У меня ровно такое же представление о константах - мы ограничиваем спектр возможных операций над объектом операциями "чтения".
Поэтому рекомендуется применять const везде, где это возможно (принцип наименьшего интерфейса).
Однако применение const на членах классов приводит к трудностям в конструкторе, и поэтому 90% населения просто не используют const на членах классов.
В результате те прекрасные гарантии, которые мог бы дать const, ничто уже не дает, и класс становится более уязвимым.

> Ваше же предложение ломает это простое представление и вносит хаос в тело конструктора.

Еще раз: const ограничивает набор возможных операций на протяжении жизненного цикла объекта.
Отложенная инициализация всего лишь позволяет отложить начало жизненного цикла, т.е. вызов конструктора (императивный подход).
Во всех остальных функциях (т.е. не в конструкторе) предложение ничего не меняет.
Увеличение свободы в конструкторе дает возможность чаще использовать const на членах классов.
В результате (как я надеюсь) const-члены классов будут более привлекательны и более популярны, а классы станут более прочными и удобными в разработке.
Сергей Тиунов
Павел Корозевцев
Сергей Тиунов,
>> operator= недоступен для констант
Чего? Вон же там написано `void operator=(int) const {...}`.

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

>> напечатает "-858993460 const"
>> напечатает уже инициализированное значение "1 const"
Оба раза выведет "2 const".

>> В этом примере operator= не будет вызван нигде.
Очень интересно. А что такое `val = f(x);`, если не вызов оператора=?

>> val.V(f(x)); // explicitly call constructor
Знакомьтесь: `new (const_cast<V*>(&val)) V(f(x));`
Дать жизнь переменным, у которых не выполнился конструктор, чтобы потом явно его вызвать, тоже никто в трезвом уме не позволит.
Павел Корозевцев
Сергей Тиунов
Павел Корозевцев, прошу прощения, никогда не думал, что оператор присваивания доступен для констант. Существуют ли не выдуманные примеры его применения от людей в трезвом уме?

> Вы предложенный код вообще компилировали?
> А что такое `val = f(x);`, если не вызов оператора=?
Код не компилировал, просто просмотрел. Но мы тут вроде рассуждаем о развитии языка, а не о том, как он работает сейчас, поэтому val = f(x) - это в моей интерпретации отложенная инициализация члена val.

C placement-new я знаком, разумеется, но приведенный Вами код запустит конструктор второй раз, тогда как мой вариант - первый раз (отложенный).

> Дать жизнь переменным, у которых не выполнился конструктор, чтобы потом явно его вызвать, тоже никто в трезвом уме не позволит.
int x; // А как же встроенные типы?
Сергей Тиунов
Павел Корозевцев
Сергей Тиунов,
>> Но мы тут вроде рассуждаем о развитии языка, а не о том, как он работает сейчас
То, что работает сейчас, должно работать так же через 20 лет. В том числе, мой код должен выводить нолик, двоечку, хе-хе и двоечку, а никак не (условное) -858993460 и 1.

>> int x; // А как же встроенные типы?
Тут отработал "конструктор" инта. Дальше только присваивание.
Павел Корозевцев
Сергей Тиунов
Павел Корозевцев,

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

>>> Дать жизнь переменным, у которых не выполнился конструктор, чтобы потом явно его вызвать, тоже никто в трезвом уме не позволит.
>> int x; // А как же встроенные типы?
> Тут отработал "конструктор" инта. Дальше только присваивание.
Результат один и тот же - undefined behaviour, и язык позволяет это делать. Или есть какие-то другие причины (кроме возможного UB), по которым "никто в трезвом уме не позволит" сделать отложенный вызов конструктора?
Сергей Тиунов
Павел Корозевцев
Сергей Тиунов, UB -- уже довольно весомая причина.
C++ силён, благодаря своей строгости. Нужно пользоваться этим, а не пытаться "упростить". Никто не любит "упрощения", ведущие к UB.
Ну хорошо, понятно, что у нас с вами разные подходы к решению поставленной проблемы. Но всё-таки есть правило "все фичи, которые могут быть реализованы в либе, должны быть в либе, а не в языке".
Почему вы хотите именно языковое нововведение? Быть может, вам такая обёртка подойдёт: `std::optional<const T>`? Понятно, что это не совсем то, чего вы хотели, но проблему решает: хранимая переменная имеет квалификатор const, а инициализировать можно в любое время.
Можно ещё написать велик, приближающий std::optional к тому, что вы хотите. То есть завести char[sizeof(T)], в котором с помощью placement new инициализировать единственный раз. По оператору-> возвращать константный указатель. В обоих случаях получается оверхед в один bool.
Павел Корозевцев
Сергей Тиунов
Павел Корозевцев,

> Быть может, вам такая обёртка подойдёт: `std::optional<const T>`?
Я же правильно понимаю, Вы можете присвоить в std::optional в любой другой момент? Тогда зачем мне это, если я могу с тем же успехом использовать просто `T`?

> Можно ещё написать велик...
Если Вы можете присвоить велику в теле конструктора, то вы можете присвоить велику в любой другой member-функции, т.е. Вы теряете const-гарантию.

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

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

> Никто не любит "упрощения", ведущие к UB.
Явный вызов конструктора (равно как и placement new) - это сознательное действие программиста, который понимает, что он делает, не так ли? Это просто инструмент, и у него есть свое применение. Никто же не заставляет нас пользоваться "void *", но бывают случаи, когда это удобно.

> C++ силён, благодаря своей строгости.
При использовании подхода, который предлагаю я, можно будет объявлять больше членов класса константами и ссылками, это приведет к повышению "прочности конструкции" с момента выхода из конструктора. Согласитесь, редко бывают классы, у которых бОльшая часть кода сосредоточена в конструкторах, в основном код в member-функциях. А если и бывают, то опять же - никто не заставляет использовать этот подход :)
Сергей Тиунов
Павел Корозевцев
То есть Вы хотите позволить внести внутрь конструктора какие-то относительно сложные вычисления, решающие конкретную задачу? Кажется, люди используют модульность именно для того, чтобы отдельные задачи решались отдельным модулем. Почему бы не инициализировать переменную через передачу её конструктору возвращаемого значения специальной функции?

class B {
public:
B(int x, int y)
: val(calc_val(x, y)) {}
private:
static int calc_val(int x, int y) { return (x + y) / 2; }
const int val;
};

Извините за очевидный код, но я искренне не понял проблему. Возможно, мне нужно увидеть более сложный пример, чтобы проникнуться.
И раз уж мы хотим снять константность со всех переменных-членов в области видимости конструктора, может быть еще для деструктора то же провернуть? Возможно, тоже кому-то пригодится.
Павел Корозевцев
Сергей Тиунов
Павел Корозевцев,

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

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

Я же предлагаю сделать написание конструкторов *удобнее*. В конце концов цель любого улучшения языка заключается в том, чтобы тот или иной аспект работы с ним стал удобнее.
Сергей Тиунов
Сергей Тиунов
Кстати, забыл еще один недостаток списков инициализации.
О нем упоминал еще Скотт Мейерс (Effective C++, Item 4), вряд ли это поменялось.

Члены класса инициализируются в порядке объявления в классе, а не в порядке перечисления в списке инициализации. Это поведение абсолютно неочевидно, т.е. о нем нужно знать. В результате, если члены класса инициализируются друг из друга, можно получить развеселые баги, и потратить немало времени в отладчике.
Сергей Тиунов
dmitriy@izvolov.ru
Сергей Тиунов, для этого есть флажок -Wreorder, включённый в -Wall.
dmitriy@izvolov.ru
Другие идеи
Группа создана, чтобы собирать предложения к стандарту C++, организовывать их внутренние обсуждения, помогать готовить их для отправки в комитет и защищать на общих собраниях в рабочей группе по С++ Международной организации по стандартизации (ISO).