Опасности конструкторов +48



Привет, Хабр! Представляю вашему вниманию перевод статьи "Perils of Constructors" автора Aleksey Kladov.


Один из моих любимых постов из блогов о Rust — Things Rust Shipped Without авторства Graydon Hoare. Для меня отсутствие в языке любой фичи, способной выстрелить в ногу, обычно важнее выразительности. В этом слегка философском эссе я хочу поговорить о моей особенно любимой фиче, отсутствующей в Rust — о конструкторах.


Что такое конструктор?


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


  1. Вы устанавливаете инварианты в конструкторе.
  2. Каждый метод заботится о сохранении инвариантов.
  3. Вместе эти два свойства значат, что можно думать об объектах как об инвариантах, а не как о конкретных внутренних состояниях.

Конструктор здесь играет роль индукционной базы, будучи единственным способом создать новый объект.


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


Значение 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):

  1. epishman
    /#20416267

    > такой конструктор работал бы как литерал записи (record literal) в Rust.
    — Похоже, имеется в виду литерал структуры.
    PS
    Rust няшный, все в нем сделано с любовью :)

    • humbug
      /#20416277

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

      • epishman
        /#20416505

        Ну, мои дефолты могут сильно отличаться от растовых дефолтов, поэтому синтаксический сахар не помешал бы, просто это неглавное-несрочное сейчас :)

  2. Siemargl
    /#20416435 / -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
      /#20416455

      Можно пояснить, что значит "адекватно"? Вроде всё правильно, никакого бреда. Вот идея передавать владение объектом (строкой), используя ссылку (универсальную или нет), как раз звучит шизофренически.

      • khim
        /#20421057

        Она может «звучать шизофренически» — но таки иногда даёт ощутимый выигрыш. Сравните.

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

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

        Впрочем если вы уже начали использовать std::string — то, скорее всего, вы уже не считаете все такты и миллисекунды.

        • math_coder
          /#20422701

          Она может «звучать шизофренически» — но таки иногда даёт ощутимый выигрыш.

          Я знаю. Одно другому не мешает. "Звучит шизофренически" — это просто указание на то, что более сложный вариант — это преждевременная оптимизация. (В противовес противоположному мнению, что более простой вариант — это необоснованная пессимизация.)

    • Videoman
      /#20416459 / +3

      Безотносительно согласен/не согласен с тезисами статьи, пример самый адекватный и правильный. Именно так и нужно принимать аргументы в современном С++, если вы хотите оптимально работать с любыми входными типами в случаях типа сеттера. Универсальные ссылки это почти всегда шаблонные типы. Вы предлагаете всегда использовать шаблоны? Или поделитесь тогда, что вы понимаете под универсальными ссылками?

      • Siemargl
        /#20416469 / -7

        предлагаю почитать учебник про std::move. зачем и почему

        • Videoman
          /#20416495 / +1

          Почему вы думает что никто кроме вас не знает как правильно использовать std::move? Просто приведите пример кода: как правильно передавать аргументы в конструктор класса из примера, по вашему мнению.

          • KanuTaH
            /#20416579

            Ну синтаксис у автора действительно немного странный, хоть и рабочий. Обычно в подобных случаях используют константные ссылки в качестве аргументов, что позволяет обойтись без странных костылей с std::move().

            • math_coder
              /#20416589 / +2

              Константные ссылки в подобных случаях использовали в C++98. Современный стандартный подход — именно как у автора. В отличие от старого варианта с константными ссылками, он адекватно отражает семантику (передачу владения строкой) и позволяет полностью избежать лишнего копирования. И что вы странного видите в использовании std::move? Оно именно для подобных случаев и существует.

              • KanuTaH
                /#20416593 / +1

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

                • math_coder
                  /#20416597 / +2

                  Эээ, простите, тут не происходит никакой «передачи владения».

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


                  Здесь создаются временные копии всех аргументов

                  Здесь не создаётся вообще никаких копий.


                  если аргументы нужны не полностью, а только для того, чтобы получить из них какую-то часть их данных

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

                  • KanuTaH
                    /#20416607

                    Простите, что вы несете?

                    #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
                      /#20416671

                      Думаю, автор и math_coder имели в виду что-то вроде godbolt.org/z/vWy5eS. Вы правы в том, что копия иногда будет создаваться (зависит от caller — если он будет передавать rvalue reference, то копирование будет заменено на еще одно перемещение), но автор имел в виде не отсутствие копирования, а «семантику», когда само «значение» явно «захватывается» (перемещается когда это возможно сделать безопасно и копируется в остальных случаях) callee и дальше уже callee сам контролирует life time этого значения.

                      «Передача владения» здесь и вправду звучит несколько неверно, потому что в C++ (и не только) под этим словами обычно понимают иное.

                      • KanuTaH
                        /#20416675

                        А зачем вы так прямо произвольно заменили 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
                          /#20417003

                          да, основная фишка именно в возможности выбора между foo(arg1, arg2) и foo(std::move(arg1), std::move(arg2)), в стандартном варианте без мувов нужно будет 2 конструктора для того же функционала (один с const string& для копирования, второй с неконстантной ссылкой и свапом внутри для передачи владения)

                          • Gorthauer87
                            /#20417029 / +2

                            Блин до чего же в плюсах запутаная move семантика.

                          • mayorovp
                            /#20417049

                            Не два, а 4: каждый аргумент может как перемещаться, так и копироваться независимо.

                      • KanuTaH
                        /#20416711

                        Кстати, пример у автора забавный еще и в том смысле, что члены класса типа std::string как class type variables таки имеют вполне себе «разумные значения по умолчанию», так как для них будут вызваны конструкторы по умолчанию, даже если они и не упомянуты в списке инициализации. Вот если бы он какие-нибудь int'ы вместо них засандалил, вот тогда это действительно было бы примером «отсутствия разумного значения по умолчанию» в данном случае :)

                    • math_coder
                      /#20416987

                      Передача владения и копирование — это вещи ортогональные. Вы сделали копии и передали f во владение копии. Если вам по логике работы программы необходимо иметь две копии соответствующих строк, естественно, что одного копирования не избежать. Если же вам не нужны две копии — надо использовать std::move:


                      foo f(std::move(arg1), std::move(arg2))`

                • Videoman
                  /#20417473

                  Ну все правильно, о том и разговор. Если это сеттер (т.е. аргументы полностью копируются для дальнейшего использования как есть), то такой синтаксис оптимален со всех точек зрения: person(std::string first_name, std::string last_name). Если это не сеттер (т.е. аргумент не копируется или используется частично), то такой синтаксис оптимален: person(const std::string& first_name, const std::string& last_name). Из примера ясно видно что это именно первый вариант. Сложно? Ну да, нужно всегда думать что, зачем и куда вы передаете, впрочем как и в Rust.

                  • KanuTaH
                    /#20417549

                    Оптимален со всех точек зрения — это вряд ли, специализированные конструкторы (которые будут принимать lvalue или rvalue ссылки) не будут допускать лишних вызовов конструкторов для своих аргументов, а тут частенько будут 2 вызова, когда в реальности достаточно одного. Ну, зато меньше кода писать, это да. Как швейцарский нож — годится для всего, но одинаково плохо.

                    • Videoman
                      /#20417641

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

                      // Временный объект (одно копирование и одно перемещение)
                      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
                        /#20417681

                        то современные стандарты гарантируют отсутствие перемещения во всех этих случаях


                        С чего вы взяли? Берем такой код:

                        #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
                          /#20417785

                          Ну да, в таком случае оптимизатору компилятора негде развернуться. Придется вызывать std::cout. Кстати забавный у вас конструктор перемещения arg_t(const arg_t&&). А что так тоже можно?

                          • KanuTaH
                            /#20417809

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


                            Ну это мягко говоря не так, в случаях, когда 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
                              /#20417957

                              Ок. Согласен. Но даже если не рассматривать copy elision (который действует строго по стандарту, как и RVO/NRVO — есть паттерн — тупо выкидываем...), вы действительно считаете что современный компилятор будет переливать из пустого в порожнее, т.е. сначала скопирует строчку во временную область на стеке, а потом ее же скопирует во внутренний буфер? Когда меня действительно волновала производительность, в боевом коде, я смотрел выхлоп компилятора и я не помню что бы такое происходило. Случаи конечно бывают разные, но зачем заморачиваться такими вещами, в общем случае?

                              • KanuTaH
                                /#20417979

                                Ну мне как раз вот этот типа «универсальный» вариант как раз и не нравится вот этим потенциальным переливанием из пустого в порожнее. Лучше написать специализированные конструкторы. Хотя, конечно, там, где перемещение дешевое, можно и так, и надеяться, что компилятор реализует все это как-то пооптимальнее.

                                • Videoman
                                  /#20418023

                                  Ну хорошо, а какие у нас есть варианты?! Если уж сильно «печёт» и профайлер указал именно на перемещение, то можно конечно сделать ваш — назовем условно «ручной вариант», но представьте себе такую сигнатуру: person(std::string first_name, std::string last_name,, std::string nick_name);
                                  Что, будем реально делать восемь перегрузок, на все комбинации?

                                  • KanuTaH
                                    /#20418055

                                    Ну простор для творчества тут есть, можно, например, не валить все в конструктор, а сделать оптимизированные сеттеры для «жирных» полей, один будет принимать const T&, второй T&&. Ну понятно, что те, кто использует конструкторы такого типа, в общем случае разменивают удобство написания кода на скорость работы.

                                  • Cheater
                                    /#20419511 / -1

                                    Специально для этого придумали std::forward

                                    • mayorovp
                                      /#20419743

                                      И как же вы его будете использовать?

                                      • KanuTaH
                                        /#20419765 / -1

                                        Я ниже привел пример, как.

                                        • KanuTaH
                                          /#20419827 / -1

                                          Интересно, что это за минусяторы, которые минусуют за рабочий пример perfect forwarding constructor'а :)

                                          • mayorovp
                                            /#20419851

                                            Вы как-то очень странно отвечаете: спорите одновременно и со мной, и с моим оппонентом — и все одним и тем же примером.

                                            • KanuTaH
                                              /#20419887

                                              Да я вроде не спорю, вы спросили, как тут можно использовать std::forward, я привел ссылку на рабочий пример. И с вашим оппонентом я не спорю, я согласен, что если бы не озвученное ограничение «шаблоны не нужны», то std::forward вполне годится. Я вообще не в курсе, что вы оппоненты :)

                                              • mayorovp
                                                /#20419905 / +1

                                                Это был риторический вопрос. На риторические вопросы отвечать не обязательно.

                                    • KanuTaH
                                      /#20419759 / +1

                                      Выше Videoman сказал, что требуется сделать без шаблонов (ну, я так понял, по крайней мере), а с std::forward и универсальными ссылками без шаблонов не обойтись. А так да, конечно, без проблем:

                                      godbolt.org/z/Ceqiwi

                                      • 0xd34df00d
                                        /#20419941

                                        Наверните туда теперь ещё SFINAE, чтобы T было только arg_t, вообще замечательно будет.


                                        Правда, ещё будет весело в случае, если есть неявные преобразования в arg_t из каких-то других типов (как, например, можно построить std::string из строкового литерала), и там всё будет совсем просто и понятно.


                                        Люблю C++.

                                        • Gryphon88
                                          /#20420237

                                          Люблю C++.
                                          Каждый раз, когда читаю такие обсуждения, радуюсь, что в своё время отказался изучать плюсы и ограничился чистым С. Там, если прострелил ногу, хотя бы видно дуло, как минимум его срез.

                                          • KanuTaH
                                            /#20420247

                                            Ой, ну началось :) Нормальный язык. Ну да, посложнее, чем «чистый C», так ведь и умеет побольше.

                    • Antervis
                      /#20420121

                      Оптимален со всех точек зрения — это вряд ли, специализированные конструкторы (которые будут принимать lvalue или rvalue ссылки) не будут допускать лишних вызовов конструкторов для своих аргументов, а тут частенько будут 2 вызова, когда в реальности достаточно одного. Ну, зато меньше кода писать, это да. Как швейцарский нож — годится для всего, но одинаково плохо.

                      лишний move — не такая уж большая беда, тем более при фиксированном типе аргумента, у которого быстрый мув (а-ля std::string).

                • MooNDeaR
                  /#20419579

                  Вы забываете одну офигенно важную вещь, порождаему именно таким способом передачи аргументов — полученный конструктор безопасен относительно исключений. Если передавать константные ссылки — мы не получим такой фичи)

                  • KanuTaH
                    /#20419597 / +1

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

                    • Videoman
                      /#20419847

                      Ну вот, кстати, хорошее замечание от MooNDeaR. Я совсем об этом забыл, т.к. по умолчание всегда так передаю аргументы. А как вы сделает конструктор принимающий константные ссылки безопасным? Ведь придется копировать и, скорее всего, выделять память. И тут мы приходим опять к дилемме: либо ограничения связанные с невозможностью сообщить о проблеме, либо двойная инициализация. А еще особая веселуха начинается когда мы начинаем менять класс, добавлять/удалять туда сюда конструкторы/операторы копирования/перемещения. В случае передачи по значению все остается как и было (noexcept не страдает). В случае с ручным подходом, у нас начинает «плясать» весь интерфейс от этого зависящий.

                      • KanuTaH
                        /#20419865

                        Так вы про какую безопасность, про noexcept что ли? Так я же вам выше продемонстрировал, что при таком способе передачи аргументов, если вызывающий не использует std::move(), то точно так же вызываются конструкторы копий, которые могут «скорее всего выделять память» и так далее.

                        • Videoman
                          /#20419897

                          Все верно, вызывается. Но вызывается он до схода в конструктор, при копировании самого аргумента. потом делается только move, который легко сделать безопасным. Т.е сам конструктор noexcept. Если возникнет исключение, то до входа в конструктор.
                          Проиллюстрирую — вот такой код безопасен относительно исключений:

                          SomeClass& SomeClass::operator=(SomeClass that) noexcept
                          {
                              swap(that);
                          
                              return *this;
                          }
                          

                          • KanuTaH
                            /#20419903

                            Так вы хотите чтобы исключений не было, или чтобы просто конструктор был noexcept? :) Ну да, noexcept конструктор при таком подходе сделать будет нельзя, но исключения все равно вполне себе будут в любом случае.

                            • Videoman
                              /#20419971

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

                              • KanuTaH
                                /#20420019 / +1

                                Эээ, минуточку. Если у вас исключение возникает в конструкторе, то ни о каких «сохранениях инварианта» и речи быть не может, поскольку класс как таковой не создается вообще. Будут автоматически вызваны деструкторы для тех членов класса, для которых уже успели вызваться конструкторы, но деструктор того класса, в конструкторе которого произошло исключение, не будет вызван потому, что он еще не был создан. Там, возможно, нужно быть аккуратным с внешними ресурсами типа файловых дескрипторов, если вы их используете «прям так» без RAII, в общей куче полей класса, так сказать, но при аккуратном написании проблем там минимум.

                                • Videoman
                                  /#20420379

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

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

                                  • KanuTaH
                                    /#20420401

                                    Ну если мы говорим «вообще», то да, немного помогает. Но не кардинально и не бесплатно.

                                • MooNDeaR
                                  /#20420395

                                  Проблема возникает не тогда, когда исключение возникает в конструкторе. Проблема тогда, когда исключение возникает в списке инициализации, т.е. ДО входа в конструктор :) Передавая аргумент по значению и делая std::move я получаю и noexcept (что немаловажно, кстати) и безопасность относительно исключений (у меня все иницилизируется гарантированно БЕЗ исключений, потому что все Move-конструкторы noexcept). Забавно, что в статье как раз-таки ссылаются на "невразумительное" решение, которое существует в С++ для решения этой проблемы.


                                  Откройте Core Guidelines, там это всё написано, кстати.

                                  • KanuTaH
                                    /#20420421

                                    Проблема тогда, когда исключение возникает в списке инициализации, т.е. ДО входа в конструктор :)


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

                                    • MooNDeaR
                                      /#20420467

                                      Это огромный плюс, потому что проблема отлавливается там, где она создается :)


                                      Представьте себе, что вы передается 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
                                        /#20420489

                                        Используя передачу по значению + std::move мы выявим проблему в самом начале, там где она и возникла!)

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

                                        К тому же, подход с передачей по значению, внезапно, уменьшает количество копий.

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

                                        • Videoman
                                          /#20420571

                                          Сильно сомневаюсь, что у вас прямо каждое место, где может быть сконструирован объект, заботливо обернуто в try/catch
                                          Извините, но то что вы описываете — это антипаттерн. В том-то и прелесть исключений, что их нужно ловить только там, где вы знаете что с ними делать или на границе модулей. Безопасность кода с точки зрения исключений это совсем про другое.

                                          • KanuTaH
                                            /#20420627

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

                                        • MooNDeaR
                                          /#20420649

                                          Сомнительное достоинство

                                          Так кажется ровно до тех пор, пока ты имеешь доступ ко всему коду, который используешь. Когда у тебя либа валится с исключениями где-то там далеко-далеко и исходников этой либы у тебя нет, вспоминаешь о том, как хорошо, когда либа предоставляет 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
                /#20421063

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

                Увы, но тут та же ситуация, что и с STL в C++98: от того момента, пока были изобретены аьстракции, которые, вроде как, чисто теоретически, ничего не должны были стоить до того момента, пока они реально перестали чего-либо стоить — прошло много лет. Тут — примерно та же история.

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

          • Siemargl
            /#20417239 / -1

            Как показала последовавшая дискуссия, действительно, знает только KanuTaH =)

            Полный правильный ответ находится в учебнике Мейерса «Эффективный и современный С++» Глава 8.1

            Другое дело, что предложенный автором вариант, как указано у того же Мейерса, не всегда плохой.
            И задача ТС стояла в другом — показать [надуманную] проблему со списками инициализации, а не эффективный код. Так что, вероятно, я немного перегнул — приношу извинения…

    • iroln
      /#20416571 / +1

      Пример, приведенный для С++, укуренный неверный бред
      Здесь должны быть универсальные ссылки, чтобы это работало адекватно.

      И что же в этом примере кода работает неадекватно? И почему этот пример "укуренный неверный бред"?


      Собственно, уровень знаний аффвтора, аналогичен и в остальном.

      Автор довольно известный, можете посмотреть его код.
      https://github.com/matklad

      • Siemargl
        /#20417327 / -1

        Не неадекватно, но неэффективно. См чуть выше

        Посмотрел. У «известного автора», выпустившегося 5 лет назад, целый 1(один) репозиторий на С++.
        Впрочем молодые революционеры такие и есть — им плевать на индустриальный опыт поколений. Я уж молчу про очередную попытку рассказать, что везде все плохо, только в Расте [будет] хорошо…

  3. dim2r
    /#20417025

    Хотел бы увидеть подобную статью на тему клонирования объектов.

  4. alsoijw
    /#20417843 / -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
      /#20419737 / -1

      Интересно, минусующие не поняли что это ошибки компиляции или им не интересен язык в котором решена проблема с null pointer exception и присутствуют конструкторы?

      • mayorovp
        /#20419749

        Именно в такой постановке вопроса — нет, не интересен. Наличие конструкторов — не самоцель.

      • Cerberuser
        /#20420889

        А чем это так принципиально отличается от подхода Rust, кроме того, что null в целом из основной части языка никуда не делся, и фактически все nullable-значения, судя по этому куску кода, ведут себя как Option?

        • alsoijw
          /#20424143 / -1

          Crystal значительно превосходит 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, самостоятельно выведенный компилятором. Он гарантирует, что не содержит Nil

          • Siemargl
            /#20424475

            Не очень понял, какой тип, если это статическая типизация, выведет Кристалл для test
            play.crystal-lang.org/#/r/7a17 — ошибка выведения типа,
            если же test += 1 — то тоже ошибка…

            • alsoijw
              /#20424585

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

              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
            /#20424563 / +2

            как и в Haskell

            Смотря что из системы типов вы используете. Современный хаскель давно потерял automatic type inference.


            Rust довольно плохо выводит типы и может сломаться на простейшем коде.

            Я бы предпочёл, чтобы разные конструкторы в ADT таки были тегированы, а последующий за этой фразой код не тайпчекался.

          • khim
            /#20424613

            То ли разработчики rust не хотели/не знали о таком, то ли решили что в системном языке это лишнее.
            В этом месте rust несколько… эээ… шизофренистичен. С одной стороны он не поддерживает автоматического создания «сложных» типов, которые не ложатся однозначно «на железо»… с другой стороны — он не даёт жёсткого описания того, как типы, которые в нём таки есть, на него ложатся.

            Было бы разумно сделать выбор либо в одну сторону, либо в другую… но пока — вот так.

            • mayorovp
              /#20424655

              с другой стороны — он не даёт жёсткого описания того, как типы, которые в нём таки есть, на него ложатся

              А зачем на пустом месте закрывать будущие возможности оптимизации?

              • khim
                /#20424921

                А зачем на пустом месте закрывать будущие возможности оптимизации?
                А почему тогда не разрешить вещи типа описанного String | Int?

                • mayorovp
                  /#20425061

                  Такие типы с обобщенными типами дружат плохо. К примеру, какой-нибудь T | Int при T = Int совсем не равен Int | Int.

          • humbug
            /#20425423 / -1

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

            Ну это же не так. Если есть поддержка АДТ, то String | Int можно описать Either<String, Int>, и конструктор будет ничуть не менее безопасным, чем в Crystal. Так что не надо наезжать на пустом месте.

            • alsoijw
              /#20425631 / -1

              Если есть поддержка АДТ, то String | Int можно описать Either<String, Int>, и конструктор будет ничуть не менее безопасным, чем в Crystal.
              Конструктор чего? Either<String, Int> или класса в котором он используется?
              Crystal выводит тип буквально в каждой строке.
              if Random.rand > 0.5
                test = "text"
              else
                test = 5
              end
              # String | Int
              test = test.to_s
              # String
              puts test.upcase
              Rust либо не в состоянии вывести тип вообще, либо только по первой иницаилизации. Как следствие структуру в rust можно инициализировать только в одну строку. Нельзя в одной строке инициализировать одно поле, потом вызвать какой-то метод, потом инициализировать второе поле. Rust не может гарантировать, что вызванный метод не наткнётся на неинициализированное поле. То-есть данный код не может быть перенесён на rust без создания временных переменных
              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
                /#20425739 / -1

                Crystal выводит тип буквально в каждой строке.

                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
                  /#20425883 / -1

                  Rust тоже.
                  Ага, охотно верим. После первой строки лишь проверяется соответствие уже выведенным. rust способен вывести тип аргументов функции хотя бы в простейшем случае?
                  В расте нет перегрузки функций
                  Это не перегрузка функции.
                  То есть может. Если вы не разбираетесь в чем-то, спросите у людей, которые знают:
                  А вы что, сами не знаете? Тогда зачем говорите? А если знаете, то почему сами не перепишите мой код на rust?

                  • humbug
                    /#20425999

                    Это не перегрузка функции.
                    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
                      /#20427075 / -1

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

                      ЗЫ где аналогичный код на rust, если он в состоянии выразить это?

  5. lamerok
    /#20419149 / +1

    Подобный код на C++ приведет к еще более любопытным результатам. Вместо вызова функции производного класса будет вызвана функция базового класса. Это имеет немного смысла, потому что производный класс еще не был инициализирован (помните, мы не можем просто сказать, что все поля имеют значение null). Однако если функция в базовом классе будет чистой виртуальной, ее вызов приведет к UB.

    Как только на С++ попытаться создать объект наследника, думаю, такой код не отлинкуется вообще, потому что для чисто виртуальной функции базового класса определения не будет. Линкер просто не найдет её… и не соберется ничего, соответственно и вызова не будет.

    • iskorotkov
      /#20419295 / +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
        /#20420257

        Спасибо, понял. Я да, имел ввиду вызов чисто виртуальной метода прямо в конструкторе. Об вызове через обычную не подумал даже.
        А уж тем более, чтобы вызвать чисто виртуальный метод, который можно определить.

    • 0xd34df00d
      /#20419955

      А на этапе линковки эта функция и не нужна, она же в данном случае вызывается через vtbl, а там просто для неё записи не будет.

      • lamerok
        /#20420297

        Так он не сможет указатель на неё определить для таблицы. Ему же надо туда что то записать… Но оказывается, мне выше ссылку дали, StackOverflow можно чисто виртуальную функцию определить. И тогда указатель будет и UB будет.

        • Ryppka
          /#20423681

          Неужели Вам ни разу не приходилось определять абстрактный виртуальный деструктор?!

          • lamerok
            /#20424385

            В реальном коде ни разу. Как, впрочем, и обычный деструктор. Хотя нет, обычный определял, RAII для критической секции и мьтексов.

            • Ryppka
              /#20425175

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

              • mayorovp
                /#20425183

                Через умный указатель — можно и без виртуального деструктора.

                • Ryppka
                  /#20425237

                  В смысле через умный указатель на базовый класс можно корректно удалить объект производного класса?

                  • mayorovp
                    /#20425585

                    Именно так.

                    • Ryppka
                      /#20425603

                      Задавая при создании в делитере статический вызов деструктора фактического типа?

                • 0xd34df00d
                  /#20425831

                  Только через shared_ptr (если вы, конечно, не будете делать кастомный делитер unique_ptr'а).

              • lamerok
                /#20425219

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

  6. Antervis
    /#20420095

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

    «джентельменского соглашения»? А вариант с конструкторами точно плохая идея? Тем более что вариант с одним базовым конструктором, выдерживающим инварианты, и несколькими переиспользующими его перегрузками приведен в статье

  7. bm13kk
    /#20421415 / -1

    А почему вообще вставили this в конструктор? Без него нет ни одной из єтих проблем.

    Для язбіков без this, вроді джавьі — почему конструктор не статичньій?