Is Two-Step Initialization a Solution or a Symptom?

The following text is a partial translation of the original English article, performed by ChatGPT (gpt-3.5-turbo) and this Jekyll plugin:

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

Обычно это происходит так (я нашел это в Apache Kafka):

Ожидается, что объект используется следующим образом:

Могут быть практические обоснования для такой двухэтапной конструкции, которая, в соответствии с Microsoft, является «всегда более безопасным» подходом к созданию объектов. Однако я убежден, что каждое из этих обоснований указывает на недостаточно проработанный дизайн и должно служить катализатором для рефакторинга.

Рассмотрим Java-класс auto-closeable, который открывает поток в своем конструкторе и затем ожидает его закрытия в методе close().

Однако, если исключение времени выполнения возникнет в конструкторе, поток не закроется и ресурс будет утекать:

Метод close() не будет вызван оператором try-with-resources, так как объект не будет полностью создан и его инициализация не будет завершена. Однако, даже если инициализация не будет завершена, экземпляр FileInputStream выполнит часть своей работы: он откроет файл. Однако, он никогда не закроет его.

Двухшаговая инициализация может быть решением:

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

Несмотря на то, что это может быть хорошим обходным путем, это только прикрытие конструктивного недостатка: изменяемость атрибута. Лучшим решением было бы избавиться от изменяемости «Book» и удалить метод «init()».

Таким образом, инициализация потока должна выполняться вне объекта «Book», а затем передаваться ему в качестве аргумента конструктора (обратите внимание на модификатор final поля in).

Затем, вот как мы можем использовать это:

Теперь поток и книга определенно будут закрыты.

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

Рассмотрим этот родительский класс, с неизменяемым атрибутом title:

Теперь давайте расширим его (снова, атрибут author неизменяемый):

Что, по вашему мнению, будет напечатано после выполнения следующих действий?

This is what:

Title: Object Thinking
Author: null

Почему author выводится как null, когда мы передали строку "David West" в конструктор? Причина в том, что super(), конструктор родительского класса, был вызван до инициализации this.author. Конструктор класса Product вызвал свой собственный виртуальный метод print(), который был переопределен в производном классе Book. Эта проблема может быть более общепринятой именно как проблема “хрупкого базового класса”: базовый класс вызывает свой собственный метод, ожидая, что он будет работать как определено, но этот метод неожиданно заменяется другой реализацией в производном классе, что приводит к непреднамеренному и неправильному поведению. Такая возможность замены метода делает базовый класс хрупким.

Использование двухфазовой конструкции может решить эту проблему, сохраняя инициализацию атрибутов в конструкторе, а “печатающую” функциональность переносит в новый метод init(). Однако такой подход только замаскирует основной дизайнерский недостаток: внутреннюю хрупкость класса.

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

Теперь оба класса помечены как final, что технически делает невозможным переопределение любого из их методов. Вместо расширения Product, класс Book инкапсулирует экземпляр этого класса. Метод print() в классе Book отвечает за функциональность печати, передавая часть этой ответственности методу product.print(). Такой дизайн становится единственным жизнеспособным вариантом, если мы соглашаемся с самого начала, что все конструкторы должны оставаться без кода и что наследование реализации недопустимо.

Если вы достаточно долго программировали на Java, то, несомненно, вам будет знаком этот подход к проектированию.

Здесь конструктор присваивает значения по умолчанию четырем атрибутам объекта, а метод init() впоследствии обновляет их на основе значений из объекта передачи данных “configuration” (DTO). Этот способ инициализации объекта может показаться более привлекательным, чем последовательность вызовов сеттеров, поскольку он гарантирует, что все необходимые атрибуты присваиваются одновременно и ни один из них не пропускается. Такая гарантия не гарантируется при использовании отдельных сеттеров. Кроме того, объект передачи данных (DTO) может быть автоматически заполнен из XML- или JSON-файла, что при передаче в метод init() еще более упрощает код.

Однако это только замаскировывает основную конструктивную ошибку: класс слишком объемный. Здесь дело не в количестве строк кода, а в множестве внешних элементов, которые он пытается абстрагировать: интернет-адрес, TCP-порт, строка входа, пароль и, возможно, еще много чего в будущем. В настоящее время Config DTO достаточно маленький, но он, кажется, заложен для будущего расширения, что соблазняет программистов расширять его по своему усмотрению. С увеличением размера Config будет расти и количество атрибутов в Database. Скорее всего, они скоро перестанут совпадать. Со временем может возникнуть сложность в определении, готов ли объект Database к работе после его создания, или требуется предварительный вызов init(). Более того, будет ли достаточным просто вызов init() для полного использования объекта?

Если бы мы изначально согласились, что наши объекты не должны инкапсулировать более трех атрибутов, как ранее предложено, мы бы провели рефакторинг этого класса. Метод init() был бы удален, и все необходимые параметры передавались бы через его основные или вторичные конструкторы. Также полезным было бы сохранение всех атрибутов в неизменяемом состоянии.

Кажется, что даже шаблон проектирования Строитель был бы лучшим решением, чем метод init() в этом конкретном случае.

Рассмотрим следующие два класса Java, которые зависят друг от друга:

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

Теперь их можно создавать:

Однако этот дизайн имеет значительный недостаток: объекты order и book остаются неполными до вызова соответствующих методов init(). По мере разработки кода исходный автор понимает правильную последовательность вызовов методов: сначала конструктор, затем сеттер, и только потом метод init(). Однако в будущем, когда другие изменят код, это временное связывание между вызовами методов может быть легко упущено. Ненамеренный вызов init() перед сеттером может привести к ошибкам времени выполнения, которые сложно диагностировать.

Основная проблема здесь связана с нарушением принципа слоистости, присутствующего в дизайне как Book, так и Order: они взаимозависимы. Если я правильно помню, Мартин Фаулер утверждал, что “слою разрешается обращаться только к слоям ниже него”. В контексте нашего дизайна книги и заказа нет четкого разграничения этих слоев: невозможно определить, какой из них является основополагающим для другого. Проблемы с инициализацией являются только проявлениями этой проблемы, и двухэтапная инициализация служит скорее временным решением, а не реальным решением.

Я не знаю, как можно улучшить этот код напрямую. Вероятно, всю архитектуру необходимо тщательно пересмотреть, с введением более соответствующих абстракций вместо Book и Order.

Кажется, что двухэтапная инициализация на самом деле не решает проблемы, а лишь временно их маскирует. Хотя вы можете иметь методы init() в ваших объектах, они выглядят как флаги с надписью “Я не смог правильно спроектировать этот класс!”.

Translated by ChatGPT gpt-3.5-turbo/42 on 2023-11-18 at 05:29

sixnines availability badge   GitHub stars