Почему я против объектов. Часть вторая, техническая
Это продолжение предыдущего поста на тему ООП. Напомню, что пишу его под воздействием книги Егора “Elegant Objects”. Прошлая публикация была немного абстрактной и слегка резковатой. Полагаю, так случается со всеми после чтения какого-либо из текстов Егора. Недаром его идеи будоражат интернет: каждый пост становится предметом бесконечных обсуждений.
В этой части поговорим на более технические темы, поэтому тональность будет поспокойней. Напомню, это не полноценная рецензия на книгу; их уже написано достаточно. Скорее, ниже приведены возражения к некоторым тезисам из “Elegant Objects”.
Сама по себе книга очень понравилась. Хоть я и далек от ООП, но прочел с удовольствием. Польза от материала в том, что он действительно продвигает вперед, заставляет задумываться о смысле обыденных паттернов и приемов. Через всю книгу красной нитью проходит тема поддержки кода, что редко встречается в любой другой литературе на тему разработки.
Выражаясь терминами Ильяхова, в книге описан (и вызывает в читателе) чувственный опыт, то есть все то, что автор пережил и испытал лично. Это делает материал очень убедительным. Возникает особое доверие, которого нет при чтении очередного руководства.
Напомню, Егор предлагает строить программы при помощи неизменяемых
объектов. Каждый такой объект представляет интересы реальной и, возможно,
меняющейся сущности реального мира. Объект должен запрашивать минимум входных
данных, в противном случае он становится композицией других объектов поменьше. С
этими же “элегантными” объектами связан отказ от NULL
, getters/settets
,
наследования и других вещей из промышленного ООП, с чем я полностью согласен.
В то же время не могу не возразить по следующим тезисам.
В стремлении дробить объекты на мелкие сущности мы неизбежно придем к ситуации,
когда их станет очень много. Знания о каждом объекте будут просты, но
потребуется знание о том, как их совмещать. Эту информацию можно будет
зафиксировать разве что в readme
или wiki
к какой-нибудь библиотеке, и
большая часть кода будет набираться банальной копи-пастой.
Думаю, каждый сталкивался с ситуацией в проекте на Java, когда на каждый квант информации заведен класс, и совершенно не ясно, как построить общую картину. Аргумент выше я позаимствовал из блога Дмитрия Бушенко, который тоже писал отзыв на книгу. Здесь мне нечего добавить к аргументу Дмитрия: помню, разбираясь с Хаскелом, я тоже был поражен взрывному росту типов по мере роста программы. На каждую пару есть функция-конвертор, но пока ее найдешь…
Примеры, которые приводит Егор с классами Cash
и пр. действительно смотрятся
красиво. То же самое можно сказать про класс Max
, который инкапсулирует два
числа и сам притворяется числом, производя вычисления только когда к нему
обращаются как к числу. Сама элегантность! Но все же у меня остались сомнения,
что средних размеров проект, выстроенный на маленьких объектах, не начнет
пошатываться из-за множества внутренних связей.
Рассмотрим код, который соответствует критериям Егора на элегантность. Вот он. Это копирование данных из одного источника данных в другой. Взят из блога Егора.
new LengthOfInput(
new TeeInput(
new BytesAsInput(
new TextAsBytes(
new StringAsText(
"Hello, world!"
)
)
),
new FileAsOutput(
new File("/tmp/hello.txt")
)
)
).value(); // happens here
Оставим за скобками вопрос о длине кода (императивная версия займет три строки); нас пока это не интересует.
Главное преимущество подхода Егора выражается в декларативности: мы выстраиваем дерево объектов, которое ничего не делает при создании. Такое дерево можно построить, где-то хранить, передавать куда-то или даже сериализовать. Типичному Lisp-программисту это напомнит принцип “код как данные”, хотя очень условно.
Остается только нажать на крючок, который каскадно спустит весь механизм.
Недостатки метода вытекают из его же достоинств. Прежде всего, дерево лениво. А ленивость не всегда хороша. Чем ленивей структура данных, тем больше будет дистанция между исполнением и ошибкой.
В примере выше все действие совершается в финальном вызове .value()
верхнего
объекта. Сказать, что это неочевидно, значит ничего не сказать. Помню, мне
приходилось отлаживать схожий код с отложенными вычислениями. Много сложности
вносило то, что при выводе объектов в консоль совершались скрытые действия,
например, итерация по коллекции, чтение потоков. Это было очень не очевидно, а
потому тяжело в поддержке.
Те, кто программирует на Питоне, должны помнить, что с выходом третьей версии
все методы, ранее возвращающие списки, теперь стали возвращать ленивые
итераторы, по которым можно пройтись один раз. Приходилось каждый такой вызов
оборачивать в list()
, чтобы скопировать элементы в фиксированную
коллекцию. Фактически это борьба с ленивостью. Особенно раздражало, когда в
интерактивном сеансе выводишь коллекцию в терминал, она считывается целиком,
выводится на экран и становится пустой.
Словом, я не уверен в достоинствах дерева объектов.
Следующий момент, на который я бы хотел обратить внимание: приглядитесь к именам
классов из примера выше. Мне бросается в глаза одинаковый паттерн именования:
ThisAsThat
, то есть Одна сущность в Другую. У экземпляров этих классов всего
лишь один метод .value()
(или .text()
, .bytes()
, что угодно), который
возвращает результат типа второй сущности.
Налицо утилитарность таких объектов: их жизненный цикл состоит исключительно в том, чтобы перевести данные из одного типа в другой. Нужен ли этот класс в дальнейшем? Нет. Это просто преобразование из А в Б. Такой объект даже не заслуживает “уважительного”, как пишет Егор, отношения. Это просто преобразователь, конвертор, читатель, что угодно.
Такие утилитарные объекты нарушают принцип из статьи “перестаньте писать классы”, на которую я ссылался в предыдущей части. Если у класса один метод, то он должен быть функцией. Потому что если мы заинтересованы в одном методе, нет смысла хранить состояние и вообще оборачивать исходные данные в какую-то абстракцию.
Этот принцип кажется мне слишком весомым, чтобы его нарушать.
Взгляните на код: буквально на ровном месте мы возвели множество сущностей:
Text
, Bytes
, Input
, TeaInput
. А ведь всего-то требовалось скопировать
данные. С точки зрения программиста, у которого в проекте десяток библиотек и
фреймворков, это должна быть автомарная операция.
Сейчас я попрошу вас, не перематывая страницу вверх, мысленно построить это же
дерево. Что было первым? Пишу по-честному: сначала LengthOfInput
, под ним
TeaInput
, а дельше цепочка из Одного в Другое. То ли байты в текст, то ли
строка из текста или наоборот. Кто на ком стоял? Теперь подумайте о том, что
копирование данных происходит постоянно и во многих местах. Неужели придется
держать порядок в голове?
Как я уже писал, появляется потребность в особом знании о том, как организовать
такие классы. Напрашивается идея о “схлопывании” такого дерева в отдельный
объект-копировальщик, какой-нибудь DataCopier
, что, конечно, противоречит
тезисам Егора. Как тут быть, я не знаю.
В третьей части я коснусь темы структур данных и функционального подхода, которым Егор тоже уделил внимание в книге.
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter
Yegor Bugayenko, 19th Jan 2018, link
Спасибо большое за анализ, действительно интересный взгляд и есть над чем подумать. Будем работать дальше)
Kapralov Sergey, 20th Jan 2018, link
> Как я уже писал, появляется потребность в особом знании о том, как организовать
такие классы. Напрашивается идея о “схлопывании” такого дерева в отдельный
объект-копировальщик, какой-нибудь DataCopier, что, конечно, противоречит
тезисам Егора. Как тут быть, я не знаю.
Если дополнить принципы ЕО немного отсебятинкой, то можно очень просто схлапывать куски любой композиционной пирамидки в отдельные реюзабельные модули, не теряя преимуществ ЕО. Например вот так:
https://github.com/project-...
Принцип прост:
1. Правило "класс может быть абстрактным либо финальным" заменяем правилом: "класс может содержать только финальные методы, но сам не должен быть финальным".
2. Позволяем наследовать классы, но с важным условием. Наследник ни в коем случае не должен добавлять новых свойств, методов, имплементировать новые интерфейсы. Разрешены только новые конструкторы.
В этом случае наследник обладает интересными свойствами. Он и родитель не просто взаимозаменяемы по LSP, они идентичны (разве что за исклюлчением имени класса, что не имеет значения пока не используется рефлексия и instanceof). Контент каплинга не происходит - наследник просто не имеет средств завязаться на родителя где то кроме как на его конструктор. Профит же в том что наследник записывается в клиентском коде компактнее чем пирамидка которую он замещает.
A S, 29th Mar 2018, link
Вот после прочтения "Elegant Objects" и похожих выводов я пошел читать Effective Java (3nd Edition) by Joshua Bloch и стало нормально, все почти тоже самое излагается - инкапсуляция повсеместно, иммутабельность повсюду, композиция вместо наследования, чувственный опыт красной ниткой и так далее, но главное не так радикально как у Егора.
И еще, когда уже Егор кложу выучит?)
Ivan Grishaev, 29th Mar 2018, link , parent
Да судя по всему -- не собирается ;-)
Михаил Попов, 22nd Feb 2020, link
ООП возникло для управления сложностью.
Если задача решается процедурно, без ООП, то значит, для конкретного программиста, ещё не пришёл черёд ООП.
Вот когда процедурно станет сложно и неохота, тогда ООП прям будет "в жилу".
Это как закупить лодку, мотор, спиннинги, а не уметь ни заводить мотор, ни закидывать спиннинг.
Реально кошёлкой в ручье быстрее и больше будет улов :-)
А кто умеет обращаться с мотором и спиннингом, тот и в ручье не пропадёт..
Ivan Grishaev, 23rd Feb 2020, link , parent
Интересно, почему тогда новые языки (Golang, Rust) предлагают только записи и функции? Куда делась сложность?
Pavel Semenov, 4th Jan 2023, link
Ivan, никуда не делась. Golang язык для небольших сервисов, который изначально задумывался как батя микросервисов. А Rust, как и C++ следует догме Kiss. Поэтому не приравнивай их к EO подходу)