Хабрахабр

[Перевод] Опасности конструкторов

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

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

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

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

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

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

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

Но такой подход требует, чтобы все типы имели значения по умолчанию, и вводит в язык печально известный null. Наиболее легкий способ сделать это — присвоить всем полям значения по умолчанию: false для bool, 0 для чисел, null для всех ссылок. Именно по этому пути пошла Java: в начале создания объекта все поля имеют значения 0 или null.

Хороший пример для изучения — Kotlin. При таком подходе будет очень сложно избавиться от null впоследствии. Дизайн языка хорошо скрывает этот факт и хорошо применим на практике, но несостоятелен. 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, чтобы сделать вышеприведенные случаи некорректными на этапе компиляции: учитывая существующие ограничения (семантику JVM), отношение цена/польза проверок в рантайме намного лучше таковой у статических проверок. Причина, по которой Kotlin может существовать с этой несостоятельностью, та же, что и в случае с ковариантными массивами в Java: в рантайме все равно происходят проверки.

Например, в 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) (что само по себе нормально). Так как это специальный синтаксис, остальная часть языка работает с ним небезупречно. Чтобы работать с исключениями, возникающими с списках инициализации, необходимо использовать еще одну невразумительную фичу языка.

В основном, методы ожидают, что объект, доступный через this, уже полностью сконструирован и корректен (соответствует инвариантам). Как намекают примеры из Kotlin, все разлетается в щепки, как только мы пытаемся вызвать метод из конструктора. Конструктор обещает установить инварианты, но в то же время это самое простое место их возможного нарушения. Но в 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)?

Вектору двоек длины 92 a.

[92, 92] b.

[92, 2] c.

Вы не можете просто вернуть Result<MyClass, io::Error> или null из конструктора! Проблемы с возвращаемым значением возникают, как правило, тогда, когда оказывается невозможно создать объект.

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

  • Таким образом, такой конструктор работал бы как литерал записи (record literal) в Rust. Создайте один приватный конструктор, который принимает значения всех полей в качестве аргументов и просто присваивает их. Он также может проверять любые инварианты, но он не должен делать что-то еще с аргументами или полями.

  • для публичного API предоставляются публичные фабричные методы с подходящими названиями и типами возвращаемых значений.

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

trait Default { fn default() -> Self;
} trait Clone { fn clone(&self) -> Self;
}

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

На практике это легко решается приватностью: если поля структуры приватные, то эта структура может быть создана только в том же модуле. Недостаток этого подхода заключается в том, что любой код может создать структуру, так что нет единого места, такого как конструктор, для поддержания инвариантов. Вы даже можете представить расширение языка, которое позволит помечать некоторые функции атрибутом #[constructor], чтобы синтаксис литерала записи был доступен только в помеченных функциях. Внутри одного модуля совсем нетрудно придерживаться соглашения "все способы создания структуры должны использовать метод new". Но, опять же, дополнительные языковые механизмы мне кажутся излишними: следование локальным соглашениям требует мало усилий.

Контракты вроде "не null" или "положительное значение" лучше всего кодируются в типах. Лично я считаю, что этот компромисс выглядит точно также и для контрактного программирования в целом. Между этими двумя паттернами есть немного места для #[pre] и #[post] условий, реализованных на уровне языка или основанных на макросах. Для сложных инвариантов просто писать assert!(self.validate()) в каждом методе не так уж и сложно.

Как и Kotlin, Swift — null-безопасный язык. Swift — еще один интересный язык, на механизмы конструирования в котором стоит посмотреть. В отличие от 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) ОО языка с простым наследованием! Однако, чтобы такой макет работал, конструктору необходимо выделять память под весь объект за один раз. Таким образом, префикс объекта производного класса является корректным объектом базового класса. Но такое выделение памяти по кускам необходимо, если мы хотим использовать синтаксис записи, где мы могли бы указывать значение для базового класса. Он не может просто выделить память только под базовый класс, а затем присоединить производные поля.

Конструктор работает с указателем на this, который указывает на область памяти, которую должен занимать новый объект. Во-вторых, в отличие от записей, конструкторы имеют ABI, хорошо работающий с размещением подобъектов объекта в памяти (placement-friendly ABI). В противовес этому, в Rust конструирование записей семантически включает довольно много копий, и здесь мы надеемся на милость оптимизатора. Что самое важное, конструктор может с легкостью передавать указатель в конструкторы подобъектов, позволяя тем самым создавать сложные деревья значений "на месте". Это не совпадение, что в Rust еще нет принятого рабочего предложения относительно размещения подобъектов в памяти!

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть