Seven Virtues of a Good Object

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

Мартин Фаулер говорит:

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

Так что же такое “правильный” объект? Какой из них не является правильным? В чем разница? Несмотря на то, что это очень полемическая тема, она очень важна. Если мы не понимаем, что такое объект, как мы можем писать объектно-ориентированное программное обеспечение? Хорошо, благодаря Java, Ruby и другим языкам, мы можем. Но насколько хорошим это будет? К сожалению, это не точная наука, и есть много мнений. Вот мой список качеств хорошего объекта.

Прежде чем мы начнем говорить о объектах, давайте определим, что такое класс. Это место, где рождаются объекты (также известные как инстанцированные). Основная ответственность класса - конструировать новые объекты по требованию и уничтожать их, когда они больше не используются. Класс знает, как должны выглядеть его потомки и как они должны себя вести. Другими словами, он знает, каким контрактам они должны подчиняться.

Иногда я слышу, что классы называют “шаблонами объектов” (например, Википедия так говорит). Это определение неправильное, потому что оно ставит классы в пассивное положение. Данное определение предполагает, что кто-то получит шаблон и создаст объект, используя его. Технически говоря, это может быть верно, но концептуально это неправильно. Никто другой не должен быть вовлечен - есть только класс и его потомки. Объект просит класс создать другой объект, и класс его создает; вот и все. Ruby гораздо лучше выражает эту концепцию, чем Java или C++:

Объект photo создается классом File (new является точкой входа в класс). После создания объект действует сам по себе. Он не должен знать, кто его создал и сколько у него есть братьев и сестер в классе. Да, я имею в виду, что рефлексия - ужасная идея, но я расскажу о ней подробнее в одном из следующих постов. Сейчас давайте поговорим об объектах и их лучших и худших сторонах.

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

Сотрудник, отдел, HTTP-запрос, таблица в MySQL, строка в файле или сам файл являются правильными объектами – потому что они существуют в реальной жизни, даже когда наше программное обеспечение выключено. Более точно, объект является представителем существа реальной жизни. Он является прокси этого существа перед всеми остальными объектами. Без такого существа, очевидно, нет и объекта.

В этом примере я запрашиваю у File конструирование нового объекта photo, который будет представлять реальный файл на диске. Можно сказать, что файл также является чем-то виртуальным и существует только при включенном компьютере. Я бы согласился и уточнил определение “реальной жизни” следующим образом: это все, что существует помимо области программы, в которой существует объект. Дисковый файл находится вне области нашей программы; поэтому вполне правильно создавать его представителя внутри программы.

Контроллер, парсер, фильтр, валидатор, локатор сервисов, синглтон или фабрика не являются хорошими объектами (да, большинство паттернов GoF являются анти-паттернами!). Они не существуют отдельно от вашего программного обеспечения, в реальной жизни. Они придуманы только для связывания других объектов. Они искусственные и фальшивые существа. Они ни представляют никого. Серьезно, парсер XML - кого он представляет? Никого.

Некоторые из них могут стать хорошими, если изменят свои имена; другие никогда не могут оправдать свое существование. Например, этот парсер XML можно переименовать в “разбираемый XML” и начать представлять XML-документ, который существует вне нашей области.

Всегда спрашивайте себя: “Какая реальная сущность находится за моим объектом?” Если вы не можете найти ответ, начните думать о рефакторинге.

Хороший объект всегда работает по контрактам. Он ожидает быть нанятым не из-за своих личных достоинств, а потому, что он соблюдает контракты. С другой стороны, когда мы нанимаем объект, мы не должны дискриминировать и ожидать, что определенный объект из определенного класса выполнит работу за нас. Мы должны ожидать, что любой объект сделает то, что говорит наш контракт. Пока объект делает то, что нам нужно, нам не интересно, из какого класса он происходит, его пол или религия.

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

Но подождите, мне важно, откуда именно будет получено содержимое - из файла на диске, запроса HTTP или, может быть, документа в Dropbox? На самом деле, это не имеет значения. Мне важно только то, чтобы какой-то объект дал мне массив байт с содержимым PNG. Так что мой контракт будет выглядеть так:

Теперь любой объект из любого класса (не только DataFile) может для меня работать. Ему нужно только следовать договору, реализуя интерфейс Binary.

Правило здесь простое: каждый публичный метод в хорошем объекте должен реализовывать своего аналога из интерфейса. Если ваш объект имеет публичные методы, которые не наследуются от интерфейса, он плохо спроектирован.

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

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

Я могу создать несколько экземпляров класса HTTPStatus, и все они будут равны друг другу.

Очевидно, что утилитарные классы, в которых есть только статические методы, не могут создавать хорошие объекты. Более обще говоря, утилитарные классы не обладают никакими достоинствами, упомянутыми в этой статье, и даже не могут называться “классами”. Они являются просто ужасными нарушителями объектной парадигмы и существуют в современных объектно-ориентированных языках только потому, что их создатели предусмотрели статические методы.

Хороший объект никогда не должен изменять своё инкапсулированное состояние. Помните, что объект представляет собой сущность из реальной жизни, и эта сущность должна оставаться неизменной на протяжении всей жизни объекта. Другими словами, объект никогда не должен предавать тех, кого он представляет. Он никогда не должен менять владельцев.

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

Несмотря на то, что метод read() может возвращать разные значения, объект является неизменяемым. Он указывает на определенную веб-страницу и никогда не будет указывать куда-либо еще. Он никогда не изменит свое инкапсулированное состояние и никогда не предаст URL, который он представляет.

Почему неизменяемость является добродетелью? Эта статья подробно объясняет: Объекты должны быть неизменяемыми. В двух словах, неизменяемые объекты лучше, потому что:

  • Действительно неизменяемые объекты всегда безопасны для работы с потоками.

  • Они помогают избегать временной связности.

  • Их использование безопасно (без оборонительных копий).

  • Они всегда обладают атомарностью ошибки.

  • Они намного проще кэшировать.

  • Они предотвращают NULL ссылки.

Конечно, хороший объект не имеет сеттеров, которые могут изменить его состояние и заставить его изменить URL. Другими словами, введение метода setURL() было бы ужасной ошибкой в классе HTTPStatus.

Кроме всего прочего, неизменяемые объекты заставят вас создавать более связанные, надежные и понятные конструкции, как объясняется в этой статье: How Immutability Helps.

Статический метод реализует поведение класса, а не объекта. Предположим, у нас есть класс File, и его потомки имеют метод size().

Пока все идет хорошо; метод size() существует из-за контракта Measurable, и каждый объект класса File сможет измерить свой размер. Ужасной ошибкой было бы создать этот класс с помощью статического метода (этот дизайн также известен как утилитарный класс и очень популярен в Java, Ruby и почти в каждом ООП языке).

Этот дизайн полностью противоречит объектно-ориентированной парадигме. Почему? Потому что статические методы превращают объектно-ориентированное программирование в “классо-ориентированное” программирование. Этот метод size() предоставляет поведение класса, а не его объектов. Что в этом плохого, вы можете спросить? Почему мы не можем иметь и объекты, и классы как граждан первого класса в нашем коде? Почему у них не могут быть методы и свойства?

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

Помимо концептуального противоречия объектно-ориентированным принципам, у публичных статических методов есть несколько практических недостатков:

Во-первых, их невозможно замокать (Ну, вы можете использовать PowerMock, но это будет самым ужасным решением, которое вы могли принять в проекте на Java… Я сделал это однажды несколько лет назад).

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

Каждый раз, когда вы видите публичный статический метод, начинайте переписывать его сразу же. Я даже не хочу упоминать о том, насколько ужасны статические (или глобальные) переменные. Я думаю, это просто очевидно.

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

Например, эти названия говорят нам, кто их владельцы: яблоко, файл, серия HTTP-запросов, сокет, XML-документ, список пользователей, регулярное выражение, целое число, таблица PostgreSQL или Джеффри Лебовски. Объект, названный правильно, всегда можно нарисовать в виде небольшой картинки. Даже регулярное выражение можно нарисовать.

Наоборот, вот примеры названий, которые говорят нам, что делают их владельцы: читатель файла, разборщик текста, проверяющий URL, печатник XML, локатор сервиса, синглтон, исполнитель сценария или программист Java. Вы можете нарисовать хотя бы одного из них? Нет, не можете. Эти названия не подходят для хороших объектов. Это ужасные названия, которые приводят к ужасному дизайну.

Вообще, стоит избегать названий, оканчивающихся на “-er”—большинство из них плохие.

“Какая альтернатива FileReader?” - слышу я, вы спрашиваете. Какое было бы лучшее название? Давайте посмотрим. У нас уже есть File, который представляет собой реальный файл на диске. Этого представителя нам недостаточно мощного, потому что он не умеет читать содержимое файла. Мы хотим создать более мощного, который будет обладать этой способностью. Как бы мы его назвали? Помните, что название должно говорить, что он есть, а не что он делает. Что он есть? Он - файл, содержащий данные; не просто файл, как File, а более сложный, с данными. Что насчет FileWithData или просто DataFile?

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

Подробнее об этом в статье “Не создавайте объекты, оканчивающиеся на -ER”.

Хороший объект создается либо из конечного, либо из абстрактного класса. Final класс - это тот, который нельзя расширить наследованием. Абстрактный класс - это тот, у которого нельзя создать экземпляры. Проще говоря, класс должен либо сказать: «Ты никогда не сможешь меня сломать; я для тебя черный ящик», либо «Я уже сломан; сначала почини меня, а потом пользуйся».

Здесь нет ничего посередине. Конечный класс - это черный ящик, который нельзя изменить никаким способом. Он работает так, как он работает, и вы либо используете его, либо выбрасываете его. Вы не можете создать другой класс, который унаследует его свойства. Это не разрешено из-за того final модификатора. Единственный способ расширить такой конечный класс - это через декорацию его потомков. Допустим, у меня есть класс HTTPStatus (см. выше), и я не люблю его. Хорошо, я люблю его, но он для меня недостаточно мощный. Я хочу, чтобы он выбрасывал исключение, если статус HTTP превышает 400. Я хочу, чтобы его метод read() делал больше, чем он делает сейчас. Традиционным способом было бы расширить класс и перезаписать его метод:

Почему это неправильно? Это очень неправильно, потому что мы рискуем нарушить логику всего родительского класса, переопределяя один из его методов. Помните, что после переопределения метода read() в дочернем классе все методы родительского класса начинают использовать его новую версию. Мы буквально внедряем новую “реализацию” прямо в класс. Философски говоря, это оскорбление.

С другой стороны, чтобы расширить финальный класс, вы должны относиться к нему как к черному ящику и украшать его своей собственной реализацией (так называемый шаблон Декоратор).

Убедитесь, что этот класс реализует тот же интерфейс, что и исходный: Status. Экземпляр HTTPStatus будет передан ему через конструктор и инкапсулирован. Затем каждый вызов будет перехвачен и реализован по-разному, если это необходимо. В этом дизайне мы рассматриваем исходный объект как черный ящик и никогда не трогаем его внутреннюю логику.

Если вы не используете ключевое слово final, кто угодно (включая вас самого) сможет расширить класс и… обидеть его :( Такой класс без final является плохим дизайном.

Абстрактный класс - это точно противоположный случай - он сообщает нам, что он неполный и мы не можем использовать его “как есть”. Мы должны внедрить нашу собственную реализацию логики в него, но только в те места, которые он разрешает нам трогать. Эти места явно помечены как abstract методы. Например, наш HTTPStatus может выглядеть так:

Как видите, класс не знает, как точно проверить код HTTP, и он ожидает, что мы внедрим эту логику через наследование и переопределение метода isValid(). Мы не собираемся обидеть его этим наследованием, так как он защитил все остальные методы с помощью final (обратите внимание на модификаторы его методов). Таким образом, класс готов к нашему нарушению и отлично защищен от него.

В заключение, ваш класс должен быть либо final, либо abstract — ничего посередине.

Обновление (апрель 2017): Если вы также согласны с тем, что наследование реализации — это зло, все ваши классы должны быть final.

Translated by ChatGPT gpt-3.5-turbo/42 on 2023-12-27 at 04:56

sixnines availability badge   GitHub stars