Привет, Хабр! Представляю вашему вниманию перевод статьи "Perils of Constructors" автора Aleksey Kladov.
Один из моих любимых постов из блогов о Rust — Things Rust Shipped Without авторства Graydon Hoare. Для меня отсутствие в языке любой фичи, способной выстрелить в ногу, обычно важнее выразительности. В этом слегка философском эссе я хочу поговорить о моей особенно любимой фиче, отсутствующей в Rust — о конструкторах.
Что такое конструктор?
Конструкторы обычно используются в ОО языках. Задача конструктора — полностью инициализировать объект, прежде чем остальной мир увидит его. На первый взгляд, это кажется действительно хорошей идеей:
- Вы устанавливаете инварианты в конструкторе.
- Каждый метод заботится о сохранении инвариантов.
- Вместе эти два свойства значат, что можно думать об объектах как об инвариантах, а не как о конкретных внутренних состояниях.
Конструктор здесь играет роль индукционной базы, будучи единственным способом создать новый объект.
К сожалению, в этих рассуждениях есть дыра: сам конструктор наблюдает объект в незаконченном состоянии, что и создает множество проблем.
Значение this
Когда конструктор инициализирует объект, он начинает с некоторого пустого состояния. Но как вы определите это пустое состояние для произвольного объекта?
Наиболее легкий способ сделать это — присвоить всем полям значения по умолчанию: false для bool, 0 для чисел, null для всех ссылок. Но такой подход требует, чтобы все типы имели значения по умолчанию, и вводит в язык печально известный null. Именно по этому пути пошла Java: в начале создания объекта все поля имеют значения 0 или null.
При таком подходе будет очень сложно избавиться от null впоследствии. Хороший пример для изучения — Kotlin. Kotlin использует non-nullable типы по умолчанию, но он вынужден работать с прежде существующей семантикой JVM. Дизайн языка хорошо скрывает этот факт и хорошо применим на практике, но несостоятелен. Иными словами, используя конструкторы, есть возможность обойти проверки на null в Kotlin.
Главная характерная черта Kotlin — поощрение создания так называемых "первичных конструкторов", которые одновременно объявляют поле и присваивают ему значение прежде, чем будет выполняться какой-либо пользовательский код:
class Person(
val firstName: String,
val lastName: String
) { ... }
Другой вариант: если поле не объявлено в конструкторе, программист должен немедленно инициализировать его:
class Person(val firstName: String, val lastName: String) {
val fullName: String = "$firstName $lastName"
}
Попытка использовать поле перед инициализацией запрещена статически:
class Person(val firstName: String, val lastName: String) {
val fullName: String
init {
println(fullName) // ошибка: переменная должна быть инициализирована
fullName = "$firstName $lastName"
}
}
Но, имея немного креативности, любой может обойти эти проверки. Например, для этого подойдет вызов метода:
class A {
val x: Any
init {
observeNull()
x = 92
}
fun observeNull() = println(x) // выводит null
}
fun main() {
A()
}
Также подойдет захват this лямбдой (которая создается в Kotlin следующим образом: { args -> body }):
class B {
val x: Any = { y }()
val y: Any = x
}
fun main() {
println(B().x) // выводит null
}
Примеры вроде этих кажутся нереальными в действительности (и так и есть), но я находил подобные ошибки в реальном коде (правило вероятности 0-1 Колмогорова в разработке ПО: в достаточно большой базе любой кусок кода почти гарантированно существует, по крайней мере, если не запрещен статически компилятором; в таком случае он почти точно не существует).
Причина, по которой Kotlin может существовать с этой несостоятельностью, та же, что и в случае с ковариантными массивами в Java: в рантайме все равно происходят проверки. В конце концов, я бы не хотел усложнять систему типов Kotlin, чтобы сделать вышеприведенные случаи некорректными на этапе компиляции: учитывая существующие ограничения (семантику JVM), отношение цена/польза проверок в рантайме намного лучше таковой у статических проверок.
А что, если язык не имеет разумного значения по умолчанию для каждого типа? Например, в C++, где определенные пользователем типы не обязательно являются ссылками, вы не можете просто присвоить null каждому полю и сказать, что это будет работать! Вместо этого в C++ используется специальный синтаксис для установления начальных значений полям: списки инициализации:
#include <string>
#include <utility>
class person {
person(std::string first_name, std::string last_name)
: first_name(std::move(first_name))
, last_name(std::move(last_name))
{}
std::string first_name;
std::string last_name;
};
Так как это специальный синтаксис, остальная часть языка работает с ним небезупречно. Например, сложно поместить в списки инициализации произвольные операции, так как C++ не является фразированным языком (expression-oriented language) (что само по себе нормально). Чтобы работать с исключениями, возникающими в списках инициализации, необходимо использовать еще одну невразумительную фичу языка.
Вызов методов из конструктора
Как намекают примеры из Kotlin, все разлетается в щепки, как только мы пытаемся вызвать метод из конструктора. В основном, методы ожидают, что объект, доступный через this, уже полностью сконструирован и корректен (соответствует инвариантам). Но в Kotlin или Java ничто не мешает вам вызывать методы из конструктора, и таким образом мы можем случайно оперировать полусконструированным объектом. Конструктор обещает установить инварианты, но в то же время это самое простое место их возможного нарушения.
Особенно странные вещи происходят, когда конструктор базового класса вызывает метод, переопределенный в производном классе:
abstract class Base {
init {
initialize()
}
abstract fun initialize()
}
class Derived: Base() {
val x: Any = 92
override fun initialize() = println(x) // выводит null!
}
Просто подумайте об этом: код произвольного класса выполняется до вызова его конструктора! Подобный код на C++ приведет к еще более любопытным результатам. Вместо вызова функции производного класса будет вызвана функция базового класса. Это имеет немного смысла, потому что производный класс еще не был инициализирован (помните, мы не можем просто сказать, что все поля имеют значение null). Однако если функция в базовом классе будет чистой виртуальной, ее вызов приведет к UB.
Сигнатура конструктора
Нарушение инвариантов — не единственная проблема конструкторов. Они имеют сигнатуру с фиксированным именем (пустым) и типом возвращаемого значения (сам класс). Это делает перегрузки конструкторов сложными для понимания людьми.
Вопрос на засыпку: чему соответствует std::vector<int> xs(92, 2)?
a. Вектору двоек длины 92
b. [92, 92]
c. [92, 2]
Проблемы с возвращаемым значением возникают, как правило, тогда, когда оказывается невозможно создать объект. Вы не можете просто вернуть Result<MyClass, io::Error> или null из конструктора!
Это часто используется в качестве аргумента в пользу того, что использовать C++ без исключений сложно, и что использование конструкторов вынуждает также использовать исключения. Однако, я не думаю, что этот аргумент корректен: фабричные методы решают обе эти проблемы, потому что они могут иметь произвольные имена и возвращать произвольные типы. Я считаю, что следующий паттерн иногда может быть полезен в ОО языках:
Создайте один приватный конструктор, который принимает значения всех полей в качестве аргументов и просто присваивает их. Таким образом, такой конструктор работал бы как литерал структуры в Rust. Он также может проверять любые инварианты, но он не должен делать что-то еще с аргументами или полями.
для публичного API предоставляются публичные фабричные методы с подходящими названиями и типами возвращаемых значений.
Похожая проблема с конструкторами заключается в том, что они специфичны, и поэтому нельзя их обобщать. В C++ "есть конструктор по умолчанию" или "есть копирующий конструктор" нельзя выразить проще, чем "определенный синтаксис работает". Сравните это с Rust, где эти концепции имеют подходящие сигнатуры:
trait Default {
fn default() -> Self;
}
trait Clone {
fn clone(&self) -> Self;
}
Жизнь без конструкторов
В Rust есть только один способ создать структуру: предоставить значения для всех полей. Фабричные функции, такие как общепринятый new, играют роль конструкторов, но, что самое важное, они не позволяют вызывать какие-либо методы до тех пор, пока у вас на руках нет хотя бы более-менее корректного экземпляра структуры.
Недостаток этого подхода заключается в том, что любой код может создать структуру, так что нет единого места, такого как конструктор, для поддержания инвариантов. На практике это легко решается приватностью: если поля структуры приватные, то эта структура может быть создана только в том же модуле. Внутри одного модуля совсем нетрудно придерживаться соглашения "все способы создания структуры должны использовать метод new". Вы даже можете представить расширение языка, которое позволит помечать некоторые функции атрибутом #[constructor], чтобы синтаксис литерала структуры был доступен только в помеченных функциях. Но, опять же, дополнительные языковые механизмы мне кажутся излишними: следование локальным соглашениям требует мало усилий.
Лично я считаю, что этот компромисс выглядит точно также и для контрактного программирования в целом. Контракты вроде "не null" или "положительное значение" лучше всего кодируются в типах. Для сложных инвариантов просто писать assert!(self.validate()) в каждом методе не так уж и сложно. Между этими двумя паттернами есть немного места для #[pre] и #[post] условий, реализованных на уровне языка или основанных на макросах.
А что насчет Swift?
Swift — еще один интересный язык, на механизмы конструирования в котором стоит посмотреть. Как и Kotlin, Swift — null-безопасный язык. В отличие от Kotlin, проверки на null в Swift более сильные, так что в языке используются интересные уловки для смягчения урона, вызванного конструкторами.
Во-первых, в Swift используются именованные аргументы, и это немного помогает с "все конструкторы имеют одинаковое имя". В частности, два конструктора с одинаковыми типами параметров — не проблема:
Celsius(fromFahrenheit: 212.0)
Celsius(fromKelvin: 273.15)
Во-вторых, для решения проблемы "конструктор вызывает виртуальный метод класса объекта, который еще не был полностью создан" Swift использует продуманный протокол двухфазной инициализации. Хотя и нет специального синтаксиса для списков инициализации, компилятор статически проверяет, чтобы тело конструктора имело правильную и безопасную форму. Например, вызов методов возможно только после того, как все поля класса и его потомков проинициализированы.
В-третьих, на уровне языка есть поддержка конструкторов, вызов которых может завершиться неудачей. Конструктор может быть обозначен как nullable, что делает результат вызова класса вариантом. Конструктор также может иметь модификатор throws, который лучше работает с семантикой двухфазной инициализации в Swift, чем с синтаксисом списков инициализации в C++.
Swift удается закрыть в конструкторах все дыры, на которые я пожаловался. Это, однако, имеет свою цену: глава, посвященная инициализации одна из самых больших в книге по Swift.
Когда конструкторы действительно необходимы
Вопреки всему я могу придумать как минимум две причины, по которым конструкторы не могут быть замещены литералами структуры, такими как в Rust.
Во-первых, наследование в той или иной степени вынуждает язык иметь конструкторы. Вы можете представить расширение синтаксиса структур с поддержкой базовых классов:
struct Base { ... }
struct Derived: Base { foo: i32 }
impl Derived {
fn new() -> Derived {
Derived {
Base::new()..,
foo: 92,
}
}
}
Но это не будет работать в типичном макете объектов (object layout) ОО языка с простым наследованием! Обычно объект начинается с заголовка, за которым следуют поля классов, от базового до самого производного. Таким образом, префикс объекта производного класса является корректным объектом базового класса. Однако, чтобы такой макет работал, конструктору необходимо выделять память под весь объект за один раз. Он не может просто выделить память только под базовый класс, а затем присоединить производные поля. Но такое выделение памяти по кускам необходимо, если мы хотим использовать синтаксис создания структуры, где мы могли бы указывать значение для базового класса.
Во-вторых, в отличие от синтаксиса литерала структуры, конструкторы имеют ABI, хорошо работающий с размещением подобъектов объекта в памяти (placement-friendly ABI). Конструктор работает с указателем на this, который указывает на область памяти, которую должен занимать новый объект. Что самое важное, конструктор может с легкостью передавать указатель в конструкторы подобъектов, позволяя тем самым создавать сложные деревья значений "на месте". В противовес этому, в Rust конструирование структур семантически включает довольно много копий, и здесь мы надеемся на милость оптимизатора. Это не совпадение, что в Rust еще нет принятого рабочего предложения относительно размещения подобъектов в памяти!
Upd 1: исправил опечатку. Заменил "литерал записи" на "литерал структуры".
Комментарии (103)
Siemargl
21.07.2019 23:31-8Пример, приведенный для С++, укуренный неверный бред
class person { person(std::string first_name, std::string last_name) : first_name(std::move(first_name)) , last_name(std::move(last_name))
Здесь должны быть универсальные ссылки, чтобы это работало адекватно.
Собственно, уровень знаний аффвтора, аналогичен и в остальном.
P.S.Дочитал. Автор не знает С++ совсем. Пусть хотя бы quiz сдаст.math_coder
21.07.2019 23:48Можно пояснить, что значит "адекватно"? Вроде всё правильно, никакого бреда. Вот идея передавать владение объектом (строкой), используя ссылку (универсальную или нет), как раз звучит шизофренически.
khim
23.07.2019 08:03Она может «звучать шизофренически» — но таки иногда даёт ощутимый выигрыш. Сравните.
Всегда, когда у вас что-то создаётся в одном модуле, а потом передаётся в другой — есть эта проблема. А вот если нет — тогда всё «схлопывается», как и должно.
Ну а дальше — уже нужно решать вопрос: насколько для вас важно, чтобы данные, которые поступили откуда-то «извне» нормально отработались.
Впрочем если вы уже начали использоватьstd::string
— то, скорее всего, вы уже не считаете все такты и миллисекунды.math_coder
23.07.2019 13:04Она может «звучать шизофренически» — но таки иногда даёт ощутимый выигрыш.
Я знаю. Одно другому не мешает. "Звучит шизофренически" — это просто указание на то, что более сложный вариант — это преждевременная оптимизация. (В противовес противоположному мнению, что более простой вариант — это необоснованная пессимизация.)
Videoman
21.07.2019 23:50+3Безотносительно согласен/не согласен с тезисами статьи, пример самый адекватный и правильный. Именно так и нужно принимать аргументы в современном С++, если вы хотите оптимально работать с любыми входными типами в случаях типа сеттера. Универсальные ссылки это почти всегда шаблонные типы. Вы предлагаете всегда использовать шаблоны? Или поделитесь тогда, что вы понимаете под универсальными ссылками?
Siemargl
21.07.2019 23:58-7предлагаю почитать учебник про std::move. зачем и почему
Videoman
22.07.2019 00:11+1Почему вы думает что никто кроме вас не знает как правильно использовать std::move? Просто приведите пример кода: как правильно передавать аргументы в конструктор класса из примера, по вашему мнению.
KanuTaH
22.07.2019 01:07Ну синтаксис у автора действительно немного странный, хоть и рабочий. Обычно в подобных случаях используют константные ссылки в качестве аргументов, что позволяет обойтись без странных костылей с std::move().
math_coder
22.07.2019 01:17+2Константные ссылки в подобных случаях использовали в C++98. Современный стандартный подход — именно как у автора. В отличие от старого варианта с константными ссылками, он адекватно отражает семантику (передачу владения строкой) и позволяет полностью избежать лишнего копирования. И что вы странного видите в использовании
std::move
? Оно именно для подобных случаев и существует.KanuTaH
22.07.2019 01:20+1Эээ, простите, тут не происходит никакой «передачи владения». Здесь создаются временные копии всех аргументов, которые потом «перемещаются» (а обычно свапаются) в поля-члены класса. Синтаксис, конечно, рабочий, но более-менее оптимален он только в случае прямой инициализации членов класса из аргументов, а если аргументы нужны не полностью, а только для того, чтобы получить из них какую-то часть их данных, то это плохой синтаксис, приводящий к ненужным копированиям.
math_coder
22.07.2019 01:28+2Эээ, простите, тут не происходит никакой «передачи владения».
Именно это здесь и происходит. Внешний код передаёт строку экземпляру класса. Строки вообще редко одалживаются, но почти всегда передаются во владение.
Здесь создаются временные копии всех аргументов
Здесь не создаётся вообще никаких копий.
если аргументы нужны не полностью, а только для того, чтобы получить из них какую-то часть их данных
Если аргументы нужны не полностью, то имеет место не передача владения, а одалживание, и в таком случае передача по ссылке будет оптимальным синтаксисом, отражающим подразумеваемую семантику.
KanuTaH
22.07.2019 01:37Простите, что вы несете?
#include <string> #include <iostream> class foo { public: foo(std::string arg1, std::string arg2) : m1(std::move(arg1)), m2(std::move(arg2)) {} std::string m1, m2; }; int main() { std::string arg1("AAA"); std::string arg2("BBB"); foo f(arg1, arg2); std::cout << arg1 << arg2 << std::endl; std::cout << f.m1 << f.m2 << std::endl; }
выведет:
AAABBB
AAABBB
Никакой «передачи владения» не произошло, при вызове конструктора создались временные копии аргументов, которые затем были свапнуты move-конструкторами std::string с полями класса. Исходные строки остались нетронуты.mabrarov
22.07.2019 04:06Думаю, автор и math_coder имели в виду что-то вроде godbolt.org/z/vWy5eS. Вы правы в том, что копия иногда будет создаваться (зависит от caller — если он будет передавать rvalue reference, то копирование будет заменено на еще одно перемещение), но автор имел в виде не отсутствие копирования, а «семантику», когда само «значение» явно «захватывается» (перемещается когда это возможно сделать безопасно и копируется в остальных случаях) callee и дальше уже callee сам контролирует life time этого значения.
«Передача владения» здесь и вправду звучит несколько неверно, потому что в C++ (и не только) под этим словами обычно понимают иное.KanuTaH
22.07.2019 04:11А зачем вы так прямо произвольно заменили std::string m1, m2 на std::string &m1, &m2 (у автора такого не было)? Вы же понимаете разницу, я надеюсь? Если этой замены не производить, то все точно так же сработает прекрасно, при создании m1 и m2 вызовутся конструкторы копий и будет опять же произведено безопасное копирование, и std::move() не нужен.
P.S. А, вы имеете в виду, что этот синтаксис дает выбор между foo(arg1, arg2) и foo(std::move(arg1), std::move(arg2)). Тогда согласен.SmallSnowball
22.07.2019 09:32да, основная фишка именно в возможности выбора между foo(arg1, arg2) и foo(std::move(arg1), std::move(arg2)), в стандартном варианте без мувов нужно будет 2 конструктора для того же функционала (один с const string& для копирования, второй с неконстантной ссылкой и свапом внутри для передачи владения)
mayorovp
22.07.2019 09:48Не два, а 4: каждый аргумент может как перемещаться, так и копироваться независимо.
KanuTaH
22.07.2019 04:58Кстати, пример у автора забавный еще и в том смысле, что члены класса типа std::string как class type variables таки имеют вполне себе «разумные значения по умолчанию», так как для них будут вызваны конструкторы по умолчанию, даже если они и не упомянуты в списке инициализации. Вот если бы он какие-нибудь int'ы вместо них засандалил, вот тогда это действительно было бы примером «отсутствия разумного значения по умолчанию» в данном случае :)
math_coder
22.07.2019 09:22Передача владения и копирование — это вещи ортогональные. Вы сделали копии и передали
f
во владение копии. Если вам по логике работы программы необходимо иметь две копии соответствующих строк, естественно, что одного копирования не избежать. Если же вам не нужны две копии — надо использоватьstd::move
:
foo f(std::move(arg1), std::move(arg2))`
Videoman
22.07.2019 11:43Ну все правильно, о том и разговор. Если это сеттер (т.е. аргументы полностью копируются для дальнейшего использования как есть), то такой синтаксис оптимален со всех точек зрения: person(std::string first_name, std::string last_name). Если это не сеттер (т.е. аргумент не копируется или используется частично), то такой синтаксис оптимален: person(const std::string& first_name, const std::string& last_name). Из примера ясно видно что это именно первый вариант. Сложно? Ну да, нужно всегда думать что, зачем и куда вы передаете, впрочем как и в Rust.
KanuTaH
22.07.2019 11:58Оптимален со всех точек зрения — это вряд ли, специализированные конструкторы (которые будут принимать lvalue или rvalue ссылки) не будут допускать лишних вызовов конструкторов для своих аргументов, а тут частенько будут 2 вызова, когда в реальности достаточно одного. Ну, зато меньше кода писать, это да. Как швейцарский нож — годится для всего, но одинаково плохо.
Videoman
22.07.2019 12:21Что-то вы обтекаемо очень формулируете свою мысль. Я не пойму о каких конструкторах вы говорите, можете привести пример? Еще раз, если речь идет только о сеттерах:
// Временный объект (одно копирование и одно перемещение) person("Very long string..."); // Константный объект (одно копирование и одно перемещение) const std::string str("Very long string..."); person(str); // Не константный объект (одно копирование и одно перемещение) std::string str("Very long string..."); person(str); // Объект с принудительным перемещением (одно копирование и одно перемещение) std::string str("Very long string..."); person(std::move(str));
Если вы намекаете на лишнее перемещение (действительно бывают случаи когда даже лишнее перемещение сильно влияет на общую производительность), то современные стандарты гарантируют отсутствие перемещения во всех этих случаях. Прошу заметить, что тогда это очень специализированная ситуация. Объекты сами по себе очень большие или очень маленькие, типа int(). Ну тогда мы не может себе позволить даже инициализировать объект нулями и не применим весь подход к конструкторам из статьи.KanuTaH
22.07.2019 12:35то современные стандарты гарантируют отсутствие перемещения во всех этих случаях
С чего вы взяли? Берем такой код:
#include <string> #include <iostream> class arg_t { public: arg_t() {std::cout << "DEF CALLED" << std::endl;} arg_t(const arg_t&) {std::cout << "CPY CALLED" << std::endl;} arg_t(const arg_t&&) {std::cout << "MOV CALLED" << std::endl;} }; class foo_t { public: foo_t(arg_t arg1) : m1(std::move(arg1)) {} arg_t m1; }; int main() { arg_t arg; foo_t foo(arg); }
Он выведет следующее:
DEF CALLED
CPY CALLED
MOV CALLED
Имеем и копирование, и перемещение сразу. Если мы заменим конструктор foo_t на такой:
foo_t(const arg_t &arg1) : m1(arg1) {}
то получим следующее:
DEF CALLED
CPY CALLED
Лишний вызов конструктора убрался. Аналогично для случая перемещения — будет 2 строчки, вторая «MOV CALLED», а в случае со «швейцарским ножом» будет 3 строчки, два MOV то есть. Можете сами поиграться:
godbolt.org/z/n5eCXS
Copy elision в данном случае вам никак не поможет.Videoman
22.07.2019 13:01Ну да, в таком случае оптимизатору компилятора негде развернуться. Придется вызывать std::cout. Кстати забавный у вас конструктор перемещения arg_t(const arg_t&&). А что так тоже можно?
KanuTaH
22.07.2019 13:07Ну да, в таком случае оптимизатору компилятора негде развернуться.
Ну это мягко говоря не так, в случаях, когда copy elision действительно можно делать, никакой std::cout компилятору не мешает:
#include <string> #include <iostream> class arg_t { public: arg_t() {std::cout << "DEF CALLED" << std::endl;} arg_t(const arg_t&) {std::cout << "CPY CALLED" << std::endl;} arg_t(const arg_t&&) {std::cout << "MOV CALLED" << std::endl;} }; class foo_t { public: foo_t(arg_t arg1) : m1(std::move(arg1)) {} arg_t m1; }; arg_t f() { return arg_t(); } int main() { foo_t foo(f()); }
выведет:
DEF CALLED
MOV CALLED
А что так тоже можно?
Ну а почему нет, в данном случае он ничего не делает с аргументом, а по правилам матчинга вполне подходит. Если смущает, можете убрать const — будет ровно то же самое :)Videoman
22.07.2019 13:41Ок. Согласен. Но даже если не рассматривать copy elision (который действует строго по стандарту, как и RVO/NRVO — есть паттерн — тупо выкидываем...), вы действительно считаете что современный компилятор будет переливать из пустого в порожнее, т.е. сначала скопирует строчку во временную область на стеке, а потом ее же скопирует во внутренний буфер? Когда меня действительно волновала производительность, в боевом коде, я смотрел выхлоп компилятора и я не помню что бы такое происходило. Случаи конечно бывают разные, но зачем заморачиваться такими вещами, в общем случае?
KanuTaH
22.07.2019 13:43Ну мне как раз вот этот типа «универсальный» вариант как раз и не нравится вот этим потенциальным переливанием из пустого в порожнее. Лучше написать специализированные конструкторы. Хотя, конечно, там, где перемещение дешевое, можно и так, и надеяться, что компилятор реализует все это как-то пооптимальнее.
Videoman
22.07.2019 13:52Ну хорошо, а какие у нас есть варианты?! Если уж сильно «печёт» и профайлер указал именно на перемещение, то можно конечно сделать ваш — назовем условно «ручной вариант», но представьте себе такую сигнатуру: person(std::string first_name, std::string last_name,, std::string nick_name);
Что, будем реально делать восемь перегрузок, на все комбинации?KanuTaH
22.07.2019 13:58Ну простор для творчества тут есть, можно, например, не валить все в конструктор, а сделать оптимизированные сеттеры для «жирных» полей, один будет принимать const T&, второй T&&. Ну понятно, что те, кто использует конструкторы такого типа, в общем случае разменивают удобство написания кода на скорость работы.
Cheater
22.07.2019 19:16-1Специально для этого придумали std::forward
mayorovp
22.07.2019 20:20И как же вы его будете использовать?
KanuTaH
22.07.2019 20:23-1Я ниже привел пример, как.
KanuTaH
22.07.2019 20:36-1Интересно, что это за минусяторы, которые минусуют за рабочий пример perfect forwarding constructor'а :)
mayorovp
22.07.2019 20:42Вы как-то очень странно отвечаете: спорите одновременно и со мной, и с моим оппонентом — и все одним и тем же примером.
KanuTaH
22.07.2019 20:51Да я вроде не спорю, вы спросили, как тут можно использовать std::forward, я привел ссылку на рабочий пример. И с вашим оппонентом я не спорю, я согласен, что если бы не озвученное ограничение «шаблоны не нужны», то std::forward вполне годится. Я вообще не в курсе, что вы оппоненты :)
mayorovp
22.07.2019 20:56+1Это был риторический вопрос. На риторические вопросы отвечать не обязательно.
KanuTaH
22.07.2019 20:23+1Выше Videoman сказал, что требуется сделать без шаблонов (ну, я так понял, по крайней мере), а с std::forward и универсальными ссылками без шаблонов не обойтись. А так да, конечно, без проблем:
godbolt.org/z/Ceqiwi0xd34df00d
22.07.2019 21:04Наверните туда теперь ещё SFINAE, чтобы
T
было толькоarg_t
, вообще замечательно будет.
Правда, ещё будет весело в случае, если есть неявные преобразования в
arg_t
из каких-то других типов (как, например, можно построитьstd::string
из строкового литерала), и там всё будет совсем просто и понятно.
Люблю C++.
Gryphon88
22.07.2019 22:35Люблю C++.
Каждый раз, когда читаю такие обсуждения, радуюсь, что в своё время отказался изучать плюсы и ограничился чистым С. Там, если прострелил ногу, хотя бы видно дуло, как минимум его срез.KanuTaH
22.07.2019 22:38Ой, ну началось :) Нормальный язык. Ну да, посложнее, чем «чистый C», так ведь и умеет побольше.
Antervis
22.07.2019 21:55Оптимален со всех точек зрения — это вряд ли, специализированные конструкторы (которые будут принимать lvalue или rvalue ссылки) не будут допускать лишних вызовов конструкторов для своих аргументов, а тут частенько будут 2 вызова, когда в реальности достаточно одного. Ну, зато меньше кода писать, это да. Как швейцарский нож — годится для всего, но одинаково плохо.
лишний move — не такая уж большая беда, тем более при фиксированном типе аргумента, у которого быстрый мув (а-ля std::string).
MooNDeaR
22.07.2019 19:32Вы забываете одну офигенно важную вещь, порождаему именно таким способом передачи аргументов — полученный конструктор безопасен относительно исключений. Если передавать константные ссылки — мы не получим такой фичи)
KanuTaH
22.07.2019 19:37+1Конструктор, принимающий константные ссылки, тоже можно написать вполне безопасным с точки зрения исключений.
Videoman
22.07.2019 20:40Ну вот, кстати, хорошее замечание от MooNDeaR. Я совсем об этом забыл, т.к. по умолчание всегда так передаю аргументы. А как вы сделает конструктор принимающий константные ссылки безопасным? Ведь придется копировать и, скорее всего, выделять память. И тут мы приходим опять к дилемме: либо ограничения связанные с невозможностью сообщить о проблеме, либо двойная инициализация. А еще особая веселуха начинается когда мы начинаем менять класс, добавлять/удалять туда сюда конструкторы/операторы копирования/перемещения. В случае передачи по значению все остается как и было (noexcept не страдает). В случае с ручным подходом, у нас начинает «плясать» весь интерфейс от этого зависящий.
KanuTaH
22.07.2019 20:44Так вы про какую безопасность, про noexcept что ли? Так я же вам выше продемонстрировал, что при таком способе передачи аргументов, если вызывающий не использует std::move(), то точно так же вызываются конструкторы копий, которые могут «скорее всего выделять память» и так далее.
Videoman
22.07.2019 20:55Все верно, вызывается. Но вызывается он до схода в конструктор, при копировании самого аргумента. потом делается только move, который легко сделать безопасным. Т.е сам конструктор noexcept. Если возникнет исключение, то до входа в конструктор.
Проиллюстрирую — вот такой код безопасен относительно исключений:
SomeClass& SomeClass::operator=(SomeClass that) noexcept { swap(that); return *this; }
KanuTaH
22.07.2019 20:56Так вы хотите чтобы исключений не было, или чтобы просто конструктор был noexcept? :) Ну да, noexcept конструктор при таком подходе сделать будет нельзя, но исключения все равно вполне себе будут в любом случае.
Videoman
22.07.2019 21:12Под безопасностью, по умолчанию, подразумевается базовая или строгая, т.е. насколько сохраняется инвариант класса в случае, если происходит исключение:
небезопасный — класс может быть в любом состоянии в случае исключения, в том числе потерять свой инвариант. UB короче.
базовая безопасность — класс может поменять состояние, но инвариант сохраняется
строгая безопасность — класс откатывается в точности в то состояние, в котором он был до вызова метода который вызвал исключение.
В случае передачи по значению, вы автоматом обеспечиваете строгую безопасность, грубо говоря, откатываете транзакцию, и вам не нужно ни о чем думать.KanuTaH
22.07.2019 21:25+1Эээ, минуточку. Если у вас исключение возникает в конструкторе, то ни о каких «сохранениях инварианта» и речи быть не может, поскольку класс как таковой не создается вообще. Будут автоматически вызваны деструкторы для тех членов класса, для которых уже успели вызваться конструкторы, но деструктор того класса, в конструкторе которого произошло исключение, не будет вызван потому, что он еще не был создан. Там, возможно, нужно быть аккуратным с внешними ресурсами типа файловых дескрипторов, если вы их используете «прям так» без RAII, в общей куче полей класса, так сказать, но при аккуратном написании проблем там минимум.
Videoman
22.07.2019 23:47Здесь конструктор — это только частный случай. Я говорил об сеттерах вообще, передаче параметров по значению и о конструкторах в частности. Так что инвариант класса, вам этот подход, очень даже помогает поддерживать. Я даже привел пример кода. Просто мы говорим о передаче параметров по значению. То что вы описали это прописные истины и без их знания код безопасный к исключениям вообще не напишешь.
но при аккуратном написании проблем там минимум.
Вот для аккуратного написания, желательно, правильно обходиться с исключениями, следить за ресурсами, минимизировать «ручное» управление и использовать RAII везде.KanuTaH
22.07.2019 23:57Ну если мы говорим «вообще», то да, немного помогает. Но не кардинально и не бесплатно.
MooNDeaR
22.07.2019 23:54Проблема возникает не тогда, когда исключение возникает в конструкторе. Проблема тогда, когда исключение возникает в списке инициализации, т.е. ДО входа в конструктор :) Передавая аргумент по значению и делая std::move я получаю и noexcept (что немаловажно, кстати) и безопасность относительно исключений (у меня все иницилизируется гарантированно БЕЗ исключений, потому что все Move-конструкторы noexcept). Забавно, что в статье как раз-таки ссылаются на "невразумительное" решение, которое существует в С++ для решения этой проблемы.
Откройте Core Guidelines, там это всё написано, кстати.
KanuTaH
23.07.2019 00:06Проблема тогда, когда исключение возникает в списке инициализации, т.е. ДО входа в конструктор :)
Пфф, так вы просто в данном случае переносите проблему в другое место. Вместо того, чтобы отловить исключение, выброшенное конструктором копии аргумента, из списка инициализации своего конструктора хотя бы даже через такое «невразумительное решение» и там же на месте, скажем, написать об этом в лог, вы его все равно словите, но уже в неявном виде и где-то хрен пойми где в произвольном месте своего кода. Считаете, это большой плюс?MooNDeaR
23.07.2019 00:30Это огромный плюс, потому что проблема отлавливается там, где она создается :)
Представьте себе, что вы передается const-ссылку на семь уровней ниже по стеку, чтобы где-то там проиницилизировался объект, создав копию значения переданного по ссылке. При большой иерархии объектов, такая ситуация совсем не редкость. Используя передачу по значению + std::move мы выявим проблему в самом начале, там где она и возникла!)
К тому же, подход с передачей по значению, внезапно, уменьшает количество копий.
Вот пример кода:
class X { public: X(const std::string& val) : m_str(val){} private: std::string m_str; } int main(void) { X x("hello"); }
При вызове конструктора класса Х:
1) Создается временный объект (выделение памяти) строки и передается в конструктор
2) Временный объект копируется в m_str (выделение памяти)
3) Возврат из конструктора вызывает деструктор временного объекта (освобождение памяти).
Если бы конструктор принимал строку по значению:
class X { public: X(std::string val) noexcept : m_str(std::move(val)) {} private: std::string m_str; } int main(void) { X x("hello"); }
1) Создается временный объект (выделение памяти).
2) Временный объект передает владение памятью в аргумент конструктора (пара swap-ов указателя и длины)
3) Аргумент конструктора передает владение в m_str (опять же просто пара swap-ов)
4) Возврат из конструктора и вызов деструктора временного объекта который "пуст" и ничем не владеет, поэтому ничего не надо освобождать.
В итоге имеем на одно выделение памяти меньше. Если еще учесть такую штуку как Copy Elision, то скорее всего этапы 2 и 3 схолпнутся в один.
Плюс noexcept, плюс безопасность по исключениям и т.д. и т.п.
KanuTaH
23.07.2019 00:40Используя передачу по значению + std::move мы выявим проблему в самом начале, там где она и возникла!)
Сомнительное достоинство. Вы с таким же успехом можете словить эту проблему на семь уровней выше по стеку просто потому, что там, где она возникла, у вас не стоит try/catch, а стоит где-то гораздо выше. Такая ситуация тоже совсем не редкость. Сильно сомневаюсь, что у вас прямо каждое место, где может быть сконструирован объект, заботливо обернуто в try/catch.
К тому же, подход с передачей по значению, внезапно, уменьшает количество копий.
Не всегда, я это уже выше демонстрировал. Зачастую он приводит, наоборот, к излишним вызовам конструкторов, которых можно было избежать.Videoman
23.07.2019 01:13Сильно сомневаюсь, что у вас прямо каждое место, где может быть сконструирован объект, заботливо обернуто в try/catch
Извините, но то что вы описываете — это антипаттерн. В том-то и прелесть исключений, что их нужно ловить только там, где вы знаете что с ними делать или на границе модулей. Безопасность кода с точки зрения исключений это совсем про другое.KanuTaH
23.07.2019 01:38Ну так человек, которому я отвечаю, как раз и радуется, что он якобы поймает проблему "сразу там, где она возникла", а я выражаю вежливое сомнение, потому что скорее всего он в любом случае поймает проблему не там, где она возникла, а там, где у него catch выставлен. Где проблема произойдёт, и где он её поймает — это две большие разницы.
MooNDeaR
23.07.2019 01:54Сомнительное достоинство
Так кажется ровно до тех пор, пока ты имеешь доступ ко всему коду, который используешь. Когда у тебя либа валится с исключениями где-то там далеко-далеко и исходников этой либы у тебя нет, вспоминаешь о том, как хорошо, когда либа предоставляет noexcept интерфейс (естественно, при условии, что он действительно сделан правильно).
Как по мне, правилом хорошего тона считается падать как можно быстрее и как можно ближе к месту ошибки.
К тому же, если новый объект создается через new, то снова возникае ситуация, когда мы зазря выделяли память (ведь this должен указывать на уже выделенную память). После исключения в конструкторе копирования аргумента где-то глубоко на седьмом уровне стека, мы не сможем доконструировать объект и хоть память и будет освобождена, оператор new зря старался запрашивая у ОС кусок памяти.
Не всегда, я это уже выше демонстрировал. Зачастую он приводит, наоборот, к излишним вызовам конструкторов, которых можно было избежать.
Это очень дешевые вызовы в подавляющем большинстве случаев, минусы от которых вполне возможно будут компенсированы наличием noexcept.
В вашем примере, кстати, собственно никакой семантики передачи владения и нет. Объект
arg
вполне себе существует после вызова конструктораfoo_t
, а значит вполне себе семантика копирования тут. Объектfoo_t
попросту не владеет ресурсом, а владеет его копией.
Мне кажется мой аргумент по части двойного выделения ресурсов более убедительный, т.к. частенько можно увидеть код вроде:
class Struct { public: Struct(std::string v1, std::string v2) : value1(v1) , value2(v2) {} private: std::string value1; std::string value2; }; class ParsedMessage { public: /// ...Impl std::string getValue1() { return m_msg.substr(0, 3); } std::string getValue2() { return m_msg.substr(3, 10); } private: std::string m_msg; }; Struct MessageToStruct( const ParsedMessage& msg ) { return Struct{ msg.getValue1(), msg.Value2() }; }
А вот код, в котором созданный объект, после передачи в другой объект продолжает использоваться мне встречается довольно редко. Если такое происходит, то обычно здесь замешан std::shared_ptr, который, к слову, правильно передавать именно по значению.
khim
23.07.2019 08:07В отличие от старого варианта с константными ссылками, он адекватно отражает семантику (передачу владения строкой) и позволяет полностью избежать лишнего копирования.
Вот только не надо всё это переносить в настоящее. Он может полностью избежать копирования и это, часто, но не всегда, реально происходит.
Увы, но тут та же ситуация, что и с STL в C++98: от того момента, пока были изобретены аьстракции, которые, вроде как, чисто теоретически, ничего не должны были стоить до того момента, пока они реально перестали чего-либо стоить — прошло много лет. Тут — примерно та же история.
Иногда, действительно, для борьбы с существующими компиляторами приходится использовать ссылки. Ну тут нужно понимать — почему и зачем.
Siemargl
22.07.2019 10:38-1Как показала последовавшая дискуссия, действительно, знает только KanuTaH =)
Полный правильный ответ находится в учебнике Мейерса «Эффективный и современный С++» Глава 8.1
Другое дело, что предложенный автором вариант, как указано у того же Мейерса, не всегда плохой.
И задача ТС стояла в другом — показать [надуманную] проблему со списками инициализации, а не эффективный код. Так что, вероятно, я немного перегнул — приношу извинения…
iroln
22.07.2019 00:58+1Пример, приведенный для С++, укуренный неверный бред
Здесь должны быть универсальные ссылки, чтобы это работало адекватно.И что же в этом примере кода работает неадекватно? И почему этот пример "укуренный неверный бред"?
Собственно, уровень знаний аффвтора, аналогичен и в остальном.
Автор довольно известный, можете посмотреть его код.
https://github.com/matkladSiemargl
22.07.2019 11:02-1Не неадекватно, но неэффективно. См чуть выше
Посмотрел. У «известного автора», выпустившегося 5 лет назад, целый 1(один) репозиторий на С++.
Впрочем молодые революционеры такие и есть — им плевать на индустриальный опыт поколений. Я уж молчу про очередную попытку рассказать, что везде все плохо, только в Расте [будет] хорошо…
alsoijw
22.07.2019 13:15-3Сколько нужно Жетбрейнсов и Мазилл, чтобы изобрести конструктор?
В Crystal всё работает.
class Test def initialize() test @val = 1 end def test puts @val end end puts "ok" Test.new()
Error in line 13: instantiating 'Test.class#new()'
instance variable '@val' of Test must be Int32, not Nil
Error: instance variable '@val' was used before it was initialized in one of the 'initialize' methods, rendering it nilable
Rerun with --error-trace to show a complete error trace.
class Test def initialize(a : String) @str = a @val = 0 end def initialize(a : Int) @val = a end def test puts @val end end puts "ok" t = Test.new("123") t.test()
Error in line 7: this 'initialize' doesn't explicitly initialize instance variable '@str' of Test, rendering it nilable
The instance variable '@str' is initialized in other 'initialize' methods,
and by not initializing it here it's not clear if the variable is supposed
to be nilable or if this is a mistake.
To fix this error, either assign nil to it here:
@str = nil
Or declare it as nilable outside at the type level:
@str : (String)?
alsoijw
22.07.2019 20:18-1Интересно, минусующие не поняли что это ошибки компиляции или им не интересен язык в котором решена проблема с null pointer exception и присутствуют конструкторы?
mayorovp
22.07.2019 20:21Именно в такой постановке вопроса — нет, не интересен. Наличие конструкторов — не самоцель.
Cerberuser
23.07.2019 05:18А чем это так принципиально отличается от подхода Rust, кроме того, что null в целом из основной части языка никуда не делся, и фактически все nullable-значения, судя по этому куску кода, ведут себя как Option?
alsoijw
23.07.2019 16:47-1Crystal значительно превосходит Rust в плане вывода типов. В Crystal, как и в Haskell(возможно и в других языках) возможно написать программу ни разу не указав конкретный тип и программа будет защищена статической типизацией. Rust довольно плохо выводит типы и может сломаться на простейшем коде.
if Random.rand > 0.5 test = "text" else test = 5 end puts test
extern crate rand; use rand::Rng; fn main() { let mut rng = rand::thread_rng(); let mut v; if rng.gen_range(0, 9) > 5 { v = 1; } else { v = "1"; } println!("{}", v); }
Именно по этой причине систем типов rust не может дать возможность реализовать безопасный конструктор, а не из-за того что это невозможно. То ли разработчики rust не хотели/не знали о таком, то ли решили что в системном языке это лишнее.
и фактически все nullable-значения, судя по этому куску кода, ведут себя как Option?
Нет. Crystal может самостоятельно вывести алгебраические типы данных. String — гарантированно стока, String | Nil — аналог Option, String | Int из последнего примера — аналог enum из rust, самостоятельно выведенный компилятором. Он гарантирует, что не содержит NilSiemargl
23.07.2019 18:02Не очень понял, какой тип, если это статическая типизация, выведет Кристалл для test
play.crystal-lang.org/#/r/7a17 — ошибка выведения типа,
если же test += 1 — то тоже ошибка…alsoijw
23.07.2019 18:26Строки и числа складывать нельзя. Нужно привести к общему типу: либо привести строку в число и складывать или наоборот, либо складывать строки со строками, числа с числами.
if Random.rand > 0.5 test = "text" else test = 5 end if test.is_a? String test += "1" else test += 1 end puts test
Если же что-то можно сделать со всеми типами, то определять тип не требуется
if Random.rand > 0.5 test = "AЯ" else test = [0, 1] end puts test.size
0xd34df00d
23.07.2019 18:22+2как и в Haskell
Смотря что из системы типов вы используете. Современный хаскель давно потерял automatic type inference.
Rust довольно плохо выводит типы и может сломаться на простейшем коде.
Я бы предпочёл, чтобы разные конструкторы в ADT таки были тегированы, а последующий за этой фразой код не тайпчекался.
khim
23.07.2019 18:33То ли разработчики rust не хотели/не знали о таком, то ли решили что в системном языке это лишнее.
В этом месте rust несколько… эээ… шизофренистичен. С одной стороны он не поддерживает автоматического создания «сложных» типов, которые не ложатся однозначно «на железо»… с другой стороны — он не даёт жёсткого описания того, как типы, которые в нём таки есть, на него ложатся.
Было бы разумно сделать выбор либо в одну сторону, либо в другую… но пока — вот так.mayorovp
23.07.2019 18:40с другой стороны — он не даёт жёсткого описания того, как типы, которые в нём таки есть, на него ложатся
А зачем на пустом месте закрывать будущие возможности оптимизации?
khim
23.07.2019 19:48А зачем на пустом месте закрывать будущие возможности оптимизации?
А почему тогда не разрешить вещи типа описанногоString | Int
?
humbug
23.07.2019 22:07-1Именно по этой причине систем типов rust не может дать возможность реализовать безопасный конструктор, а не из-за того что это невозможно. То ли разработчики rust не хотели/не знали о таком, то ли решили что в системном языке это лишнее.
Ну это же не так. Если есть поддержка АДТ, то String | Int можно описать Either<String, Int>, и конструктор будет ничуть не менее безопасным, чем в Crystal. Так что не надо наезжать на пустом месте.
alsoijw
23.07.2019 23:09-1Если есть поддержка АДТ, то String | Int можно описать Either<String, Int>, и конструктор будет ничуть не менее безопасным, чем в Crystal.
Конструктор чего? Either<String, Int> или класса в котором он используется?
Crystal выводит тип буквально в каждой строке.
Rust либо не в состоянии вывести тип вообще, либо только по первой иницаилизации. Как следствие структуру в rust можно инициализировать только в одну строку. Нельзя в одной строке инициализировать одно поле, потом вызвать какой-то метод, потом инициализировать второе поле. Rust не может гарантировать, что вызванный метод не наткнётся на неинициализированное поле. То-есть данный код не может быть перенесён на rust без создания временных переменныхif Random.rand > 0.5 test = "text" else test = 5 end # String | Int test = test.to_s # String puts test.upcase
class Test @b : Int32 def initialize @a = 1 @b = plus @c = 3 end def plus @a + 2 end def print puts @a + @b + @c end end Test.new.print
humbug
23.07.2019 23:43-1Crystal выводит тип буквально в каждой строке.
Rust тоже.
Rust либо не в состоянии вывести тип вообще, либо только по первой иницаилизации.
По первой инициализации.
Конструктор чего? Either<String, Int> или класса в котором он используется?
И того и другого.
В расте нет перегрузки функций, поэтому ваш
initialize
сa
типаString | Int
в расте должен выглядеть какfn initialize(a: Either<i32, String>)
.
То-есть данный код не может быть перенесён на rust без создания временных переменных
То есть может. Если вы не разбираетесь в чем-то, спросите у людей, которые знают: https://web.telegram.org/#/im?p=@rust_beginners_ru
alsoijw
24.07.2019 00:30-1Rust тоже.
Ага, охотно верим. После первой строки лишь проверяется соответствие уже выведенным. rust способен вывести тип аргументов функции хотя бы в простейшем случае?
В расте нет перегрузки функций
Это не перегрузка функции.
То есть может. Если вы не разбираетесь в чем-то, спросите у людей, которые знают:
А вы что, сами не знаете? Тогда зачем говорите? А если знаете, то почему сами не перепишите мой код на rust?humbug
24.07.2019 02:05Это не перегрузка функции.
Error: instance variable '@val' was used before it was initialized in one of the 'initialize' methods, rendering it nilableИ что же это тогда? Документация говорит, что это перегрузка. https://crystal-lang.org/reference/syntax_and_semantics/overloading.html
alsoijw
24.07.2019 11:00-1Перегрузка, это когда есть несколько объявлений с разными аргументами. В том примере все функции объявлены однократно.
ЗЫ где аналогичный код на rust, если он в состоянии выразить это?
lamerok
22.07.2019 17:43+1Подобный код на C++ приведет к еще более любопытным результатам. Вместо вызова функции производного класса будет вызвана функция базового класса. Это имеет немного смысла, потому что производный класс еще не был инициализирован (помните, мы не можем просто сказать, что все поля имеют значение null). Однако если функция в базовом классе будет чистой виртуальной, ее вызов приведет к UB.
Как только на С++ попытаться создать объект наследника, думаю, такой код не отлинкуется вообще, потому что для чисто виртуальной функции базового класса определения не будет. Линкер просто не найдет её… и не соберется ничего, соответственно и вызова не будет.iskorotkov Автор
22.07.2019 18:22+1Из стандарта, 10.4 Абстрактные классы, параграф 6:
Member functions can be called from a constructor (or destructor) of an abstract class; the effect of making a virtual call (10.3) to a pure virtual function directly or indirectly for the object being created (or destroyed) from such a constructor (or destructor) is undefined.
Если написать примитивно (т. е. просто переписать пример c Kotlin на C++), то да, код просто не скомпилируется. Но можно написать так, что все соберется и запустится. Вот пример кода, который приводит к UB.
Подробнее можно почитать на StackOverflow.
lamerok
22.07.2019 22:42Спасибо, понял. Я да, имел ввиду вызов чисто виртуальной метода прямо в конструкторе. Об вызове через обычную не подумал даже.
А уж тем более, чтобы вызвать чисто виртуальный метод, который можно определить.
0xd34df00d
22.07.2019 21:09А на этапе линковки эта функция и не нужна, она же в данном случае вызывается через vtbl, а там просто для неё записи не будет.
lamerok
22.07.2019 23:12Так он не сможет указатель на неё определить для таблицы. Ему же надо туда что то записать… Но оказывается, мне выше ссылку дали, StackOverflow можно чисто виртуальную функцию определить. И тогда указатель будет и UB будет.
Ryppka
23.07.2019 15:35Неужели Вам ни разу не приходилось определять абстрактный виртуальный деструктор?!
lamerok
23.07.2019 17:45В реальном коде ни разу. Как, впрочем, и обычный деструктор. Хотя нет, обычный определял, RAII для критической секции и мьтексов.
Ryppka
23.07.2019 20:58Видимо, Вам не приходилось создавать иерархии классов. Вообще-то эта gotcha описывается в начале книг типа Effective C++.
Класс у которого ожидаются публичные наследники обязан объявить виртуальный деструктор. Всегда можно создать пустой, но многим хочется сделать его абстрактным. Ну, не знаю, для красоты, что ли. Но если его не определить, то нельзя будет освобождать наследников.mayorovp
23.07.2019 20:59Через умный указатель — можно и без виртуального деструктора.
0xd34df00d
24.07.2019 00:09Только через
shared_ptr
(если вы, конечно, не будете делать кастомный делитерunique_ptr
'а).
lamerok
23.07.2019 21:08Это я знаю, просто зачем его объявлять, если объект никогда не уничтожается… Статика, сплошная статика. Деструктор только если на стеке объект, да и то, так для баловства для критической секции, чтобы кода меньше писать.
И да, можно же умный указатель использовать.
Antervis
22.07.2019 21:48Недостаток этого подхода заключается в том, что любой код может создать структуру, так что нет единого места, такого как конструктор, для поддержания инвариантов. На практике это легко решается приватностью: если поля структуры приватные, то эта структура может быть создана только в том же модуле. Внутри одного модуля совсем нетрудно придерживаться соглашения «все способы создания структуры должны использовать метод new».
«джентельменского соглашения»? А вариант с конструкторами точно плохая идея? Тем более что вариант с одним базовым конструктором, выдерживающим инварианты, и несколькими переиспользующими его перегрузками приведен в статье
bm13kk
23.07.2019 10:00-1А почему вообще вставили this в конструктор? Без него нет ни одной из єтих проблем.
Для язбіков без this, вроді джавьі — почему конструктор не статичньій?
epishman
> такой конструктор работал бы как литерал записи (record literal) в Rust.
— Похоже, имеется в виду литерал структуры.
PS
Rust няшный, все в нем сделано с любовью :)
humbug
А такая штука есть, называется типаж Default. И структуру можно инициализировать дефолтными значениями, если все поля могу быть дефолтными.
epishman
Ну, мои дефолты могут сильно отличаться от растовых дефолтов, поэтому синтаксический сахар не помешал бы, просто это неглавное-несрочное сейчас :)