#12: Налог на наследование: конечные автоматы
Связность — враг изменений: по мотивам «Программиста-прагматика» Александр разбирает признаки высокого каплинга и способы его снижать — от инкапсуляции и отказа от глобального стейта до недооцененных конечных автоматов, observer-паттерна и pub-sub с реактивным программированием. Кульминация — «налог на наследование»: почему наследование классов — самый сильный способ связать код намертво и чем его заменить: интерфейсы, делегирование и миксины (даже в Java — через аннотации, как @Mixin в Picocli). Полезняшка — канал «500 дней геймдева».
Главное
- Связность — враг изменений: искусство инженерии не в том, чтобы написать работающий код (это сделает и нейросеть, и базовый джун), а в том, чтобы его потом было легко менять.
- Знаки высокого каплинга: неочевидные зависимости между модулями, изменения расползаются по кодовой базе, разработчики боятся трогать код, а фичи согласуются митингами на много команд.
- База декаплинга: инкапсулируйте стейт внутри объектов; глобальная переменная — это лишний параметр, добавленный каждому методу кодовой базы; синглтон — «паттерн-антипаттерн».
- Конечные автоматы недооценены: состояния, ивенты и экшены разделены by design, система получается open-closed — и это тот случай, когда стоит написать свою простую реализацию, а не искать библиотеку.
- Observer-паттерн прост, но синхронен и хрупок (упавший обзервер рвёт цепочку); pub-sub добавляет каналы и развязывает источники и слушателей — это и есть реактивное программирование: идеально для UI, на бэкенде — по необходимости.
- Наследование классов — самый сильный способ увеличить связность: наследуются методы, данные и API, и контракт родителя железобетонно сковывает наследника (поменяли getPower у vehicle — рефакторишь весь код, использующий car).
- Альтернативы: интерфейсы для типизации и контрактов, делегирование (композиция) для переиспользования кода, миксины/трейты для подмешивания поведения.
- Базовый класс в тестах — то же наследование с теми же проблемами: extensions в JUnit 5 решают задачу чище.
Ссылки
Расшифровка
[00:20] Здарова! Меня зовут Саша Пахомов, и я инженер, который любит своё дело. Это двенадцатый выпуск подкаста «Тысяча фичей». Сегодня мы попробуем разобраться, что такое связность кода и при чём тут наследование, поговорим про конечные автоматы, observer-паттерн и многое другое. В конце — полезняшка. Поехали!
[00:49] Напомню: я читаю книгу «Программист-прагматик», и когда какие-то главы и мысли мне нравятся, я делюсь ими в подкасте. Это именно тот случай — глава про coupling, связанность. Это когда код настолько связан — модули, классы, методы зависят друг от друга, — что любое изменение приводит к большим рефакторингам в других частях. По сути, одна строчка может поломать целый проект, и чинить вы будете дольше, чем писали саму строчку. Основной посыл прочитанного: связность — это враг изменений. А ведь мы пишем софт для того, чтобы он потом менялся. В этом, как мне кажется, и есть искусство программирования: не просто написать код, выполняющий бизнес-задачу, — с этим уже справится нейросеть или базовый джун. Истинное искусство — написать код так, чтобы в него легко было вносить изменения и он не был переусложнён: чтобы можно было понять, как он работает, осознать, куда внести изменение, и внести его, не поломав половину мира. Каплинг именно этого нам и не позволяет.
[02:08] Как понять, связан ли ваш код, — не единственный же критерий «поменяли — сломалось»? В книге называют знаки. Странные, неочевидные зависимости между модулями или библиотеками. Обычные изменения в одном модуле, приводящие к куче изменений по всей кодовой базе. Разработчики в принципе боятся вносить изменения — не уверены, как поведёт себя система и скомпилируется ли вообще. И наконец: если вы собираете митинги на много-много разработчиков, чтобы согласовать одну фичу, — скорее всего, у вас высокая связанность: масштаб изменений не локализован, изменяемый код расползается по всей кодовой базе. Думаю, никто не спорит: каплинг — метрика, которую нужно оптимизировать в сторону наименьшей связанности.
[03:26] Как добиться? В книге приводится очевидный приём — банальная инкапсуляция. Мы прячем стейт, переменные, знания внутри объекта, а объект предоставляет только интерфейс — методы. Мы можем попросить объект что-то сделать у себя внутри, но не можем контролировать это извне: объект делает сам. Если у нас есть доступ к данным и мы где-то в логике их меняем — мутируем внутреннее состояние объекта явно, — это хорошее место для уменьшения связанности: поместим в объект метод, который мутирует его состояние, а про само состояние знать не будем. Скрываем то, что вызывающей стороне знать не обязательно, — и все изменения стейта локализованы внутри объекта: рефакторишь его и знаешь, что про эти данные больше никто не знает, не нужно смотреть и волноваться в других местах. Второй очевидный приём: не используем глобальный стейт — глобальные переменные, environment-переменные без необходимости, статические поля со статическими классами. Это касается и синглтона: это «паттерн-антипаттерн», использовать его нужно очень аккуратно и только когда других вариантов нет; статический синглтон-объект — знак, что что-то стоит пересмотреть. Почему глобальный стейт мешает лёгким изменениям: если мыслить в категориях чистых функций («принимают что-то на вход, что-то возвращают»), то, создав одну глобальную статическую переменную, мы по сути добавили ещё один параметр всем методам всей кодовой базы — весь мир начал знать про эту переменную. А знать про неё нужно нескольким классам, нескольким местам в коде — там добавление параметра оправдано, везде остальное — нет. Когда думаешь в такой парадигме, от глобальных переменных реально хочется избавляться. Хотя признаю: иногда без этого никак — например, используемая библиотека написана, скажем так, не очень качественно и конфигурируется только через системные properties; придётся. Но в собственном коде я почти никогда не использую статические вещи — и вам не советую.
[06:40] Это была база. Дальше авторы говорят о более современных и сложных техниках уменьшения связанности, на первый взгляд не таких очевидных. Первая — finite state machine, конечные автоматы. Сама концепция — авторы говорят это, и я полностью согласен — недооценена в сообществе разработчиков: прямое использование конечных автоматов встречается редко. И это ошибка, потому что во многих кейсах они избавляют от огромной кучи проблем. Я даже не знаю, почему в Java нет популярной библиотеки конечных автоматов, о которой все бы знали — как знают Spring. Мне неизвестна Java-библиотека конечных автоматов с хорошим API и thread-safe; может, язык не предрасполагает. Если вы такой пользовались — напишите в Telegram-канале «Тысячи фичей», мне нужна такая (сам ресёрчем ещё не занимался — найду, поделюсь). Что такое конечный автомат? Модель, в которой есть набор состояний и ивентов. Состояния: юзер не залогинен, юзер логинится, юзер залогинен — простой enum, например. Ивенты триггерят переходы между состояниями; есть начальное состояние. В нашем простом автомате initial state — «не залогинен»; ивент «пользователь начал вводить логин» триггерит переход во второе состояние; оттуда ивент «залогинился» — переход в «залогинен», а ивенты «неверный пароль» или «неверный юзернейм» — в состояние login failed. В коде это описывается не так сложно: enum состояний, процессор, который принимает ивенты и знает о переходах, и экшены — например, лямбды в Java: что делать при переходе из этого состояния в это. Авторы приводят простую реализацию на Ruby — читается легко. И, кстати, говорят: это как раз тот случай, когда библиотеку использовать не нужно — напишите свою простую реализацию. Может, поэтому и нет знаменитой Java-библиотеки: кому надо, пишут сами. Хотя, возможно, именно необходимость писать самому и отталкивает: штука не самая тривиальная, баг в ней может сильно аукнуться, нужно хорошо тестировать и продумывать модель — а тебе фичу надо деливерить, бизнес ждёт, а ты сидишь конечный автомат пишешь… «Спасибо, конечно, чувак, давай вещи делать, а не состояния свои переключать». Как конечный автомат уменьшает связанность? С моей точки зрения — разделением: ивенты и экшены разделены by design, сильно связать всё уже не получится. Экшены — отдельные функции, которые можно тестировать в отрыве от самого автомата: движок исполнения развязан с исполняемыми экшенами. И система получается идеально open-closed: нужен новый ивент — добавляешь ивент, экшен и правило перехода, а сам движок не меняешь.
[11:14] Дальше — паттерн observer/observable: есть источник ивентов и подписчики. «Хочу подписаться на событие „пользователь залогинился“, на „ввёл неправильный пароль“» — добавляем обзерверов в класс-имплементацию observable, и когда внутри происходят ивенты, он по очереди вызывает весь список подписчиков — синхронно, в том же потоке. Минусов достаточно: модель синхронная — параллельно обзерверы не вызываются; если первый сломался — кинул exception, который аккуратно не прохендлили, — следующие не будут вызваны. Система шаткая, но сама идея — ивенты, слушатели, подписка — хорошая. И в продолжение авторы говорят про publish-subscribe: в observer-паттерн добавляется абстракция каналов, через которую и происходит развязывание синхронного вызова. Источник ивентов шлёт их в канал, а канал распределяет по слушателям — и благодаря этой абстракции всё можно делать параллельно и thread-safe. Получается, я сейчас описал реактивное программирование — то, что в Java реализует, например, RxJava. Есть language-agnostic стандарт — почитать можно на reactivex.io; думаю, все, кто работал с реактивным программированием, туда заходили. Pub-sub-модель тоже декаплит код: мы начинаем разделять тех, кто производит ивенты, и тех, кто их слушает, — и физически не можем собрать всё в одном месте: модель форсит разделение. Полезная штука, но не увлекайтесь: хорошо бы сначала написать собственный небольшой pub-sub-движок, чтобы понимать специфику, — и главное, не превращаться в человека с молотком, которому всё кажется гвоздями. Реактивное программирование идеально для отрисовки интерфейсов — мобильная разработка, сайты: модель туда естественно ложится. А на бэкенде надо смотреть: я бы не брал реактивщину за основу веб-сервиса, а использовал по необходимости — там, где понятно, что она нужна. Моё личное мнение.
[14:42] Дальше авторы говорят про transformation thinking — мышление трансформациями. Это модель восприятия кода как ящичка: пришёл input, вышел output. По сути, всё, что делает программа: берёт что-то на вход, трансформирует и выдаёт. Большинство программ, функций и сервисов именно такие — про всё можно думать так. Типичная история — word count, задача на map-reduce: берём файл, сплитим по строчкам, строчки — по словам, считаем слова, в конце делаем reduce — pipeline за pipeline’ом, и сквозь программу протекает data flow, а мы лишь операторы трансформации над данными. Уметь так думать — полезный навык, но перегибать не стоит: когда задача не подходит — много side effects, глобальный стейт, нужно что-то помимо трансформации данных, — а подход продолжают форсить, код становится неочевидным, и вместо пользы один вред. Головой думать надо. Особо ничего интересного из transformation thinking я не подцепил — а вот где подцепил, так это глава про наследование.
[16:22] Это одна из моих любимых тем — не такая, конечно, любимая, как тестирование. Моя идея: наследование не нужно в принципе — как инструмент ежедневной разработки. Я пишу на Java, в основном на 11-й, и мне хватает других инструментов для решения тех же проблем, которые некоторые решают наследованием. Посмотрим, что думают авторы, потом добавлю своё мнение — а вы сформулируйте собственное. Глава называется inheritance tax — «налог на наследование». Авторы говорят: когда мы используем наследование, мотиваций обычно две. Первая — не хотим печатать много: по сути, don’t repeat yourself — выносим общие методы и функциональность в родителя, в абстрактные классы, а в наследниках используем. Вторая — мы любим типы: хотим типизировать поведение программы, сказать, что car — это vehicle, kitchen — это комната; хотим воплотить в коде всё это прекрасное ООП. Думаю, воплотить получается очень редко: я ещё ни разу не видел в продакшн-коде идеальной ООП-модели реального мира, из которой не торчали бы костыли, где не было бы кастований и которая не выглядела бы монстром. Нормальный пример наследования в самой Java — наверное, collections framework, он плюс-минус неплох (если знаете его плохие стороны — пишите, интересно).
[18:51] Так что с наследованием не так? Основная проблема: наследование классов — самый сильный способ увеличить связность кода, каплинг настолько сильный, насколько вообще возможно. Связность ведь бывает разной: заинжектили класс в конструктор — части кода связаны, но только через вызываемые методы. А когда мы наследуемся, мы наследуем все методы базового класса (которые он позволил), наследуем данные — и наследуем API, который базовый класс задекларировал. Если car — это vehicle с методом getPower, возвращающим int, то и у car есть getPower, возвращающий int. А потом бизнес-требования поменялись, и мы захотели, чтобы vehicle.getPower возвращал не int, а абстракцию, конвертируемую в разные измерения — лошадиные силы и прочее. Меняем vehicle — и весь код, который использовал car как car (потому что нужен был именно car, а не vehicle), тоже нуждается в рефакторинге. Хотя, казалось бы: я использую car, при чём тут vehicle? API железобетонно привязывается между наследником и родителем — очень серьёзное ограничение. Это основная претензия авторов к наследованию классов — и моё личное мнение: так и есть. Каждый раз, когда я использовал наследование в своём коде, через какое-то время это становилось проблемой: базовый класс раздувается, появляются методы непонятно зачем, и он превращается в утилити-класс — который я тоже не очень люблю, — только не статический, а такой, который надо унаследовать, чтобы пользоваться методами. Плюс наследоваться можно только один раз: наследник получает ограничение «ты наследуешься вот от этого — и больше ни от кого». Связь настолько сильная, что связывает руки, и изменять код становится сложнее — это я знаю по своему опыту точно. Я почти никогда не использую наследование классов. Единственное место, где оно у меня ещё бывает, — тесты: базовый тест с набором лайфсайкл-методов, очисткой и подъёмом ресурсов, выразительными ассертами. Но думаю, и это было моей ошибкой: сейчас я вижу, что наследование даже в тестах вносит проблемы. Как избежать? Если вы пишете на JUnit 5 — есть прекрасные extensions, которые встраиваются в жизненный цикл тестов: аннотации, инжект параметров в тестовые методы, которые подхватит инфраструктура. Соберусь писать очередной base-тест — сначала подумаю, не могу ли я написать JUnit-extension; если могу — всегда буду писать его. Наследование в тестах — тоже проблема, хоть и не такая большая: тесты всё-таки не продакшн-код.
[22:42] Теперь об альтернативах. Хорошо, «наследование классов — это плохо, ко-ко-ко». А что делать, когда, например, мы хотим положить в список vehicle’ы — car, bicycle и ещё что-то — и распечатать его? Ответ очевиден: интерфейсы в Java, протоколы в других языках. Это вещи, которые декларируют кусочек API, контракт взаимодействия с классом — но не реализацию, и у них нет полей. Связанность разрывается: класс и интерфейс связывает только набор объявленных методов. Интерфейсы я использую часто — но не «каждому классу пару интерфейс+Impl»: такого культа интерфейсов я не разделяю. А вот задекларировать контракт — да: если это core API или public API, я сделаю интерфейс, даже если у него один наследник, — на будущее: придётся менять или расширять — не придётся менять API. Для внутреннего кода — нет. Итак, интерфейсы дают типизацию. А если мы хотим вынести кусок кода и переиспользовать его во многих местах — авторы предлагают делегирование. Просто кладём себе в класс делегат — объект, умеющий выполнять часть работы, — и просим его, вызывая методы. Простой очевидный способ перестать писать базовые классы и делать композицию вместо наследования. Я постоянно так делаю: нужно парсить строку во многих местах — делаю класс-парсер с методом parse и объявляю зависимость через конструктор. Зачем вводить базовый класс и наследовать его везде ради метода parse? С композицией мы сразу видим все зависимости — они в конструкторе — и понимаем, что нужно классу для существования и функционирования. Мне кажется, это правильный подход. И третий подход — когда хотим подмешать поведение: миксины, трейты, категории, протоколы, экстеншены — в разных языках по-разному. Идея: подмешиваем поведение в объект. Хотим объект с методом log — берём миксин, который определяет log вместе с реализацией, и говорим: класс не наследует логирование, а подмешивает его. Во многих языках это слово with: myService with Logging — и у тебя появился метод log с реализацией. Я использовал такой подход в Groovy — там есть трейты, было плюс-минус удобно, и это всё-таки не наследование. Кстати, трейты в Scala интересно решают проблему множественного наследования — «у двух родителей один и тот же метод: чей вызовется?»: порядок объявления трейтов определяет старшинство — вызывается самый старший. Интересный подход, насколько рабочий — не знаю. В Java миксинов нет, это правда, — но идею использовать можно: через аннотации или свои абстракции. Я, например, использую понятие миксина в command-line-приложении на Picocli: там класс аннотируется @Command и реализует Callable — метод call вызывается при вызове команды. Так вот, набор опций — help, verbose, version — должен присутствовать во многих командах. Вместо того чтобы определять эти три поля в каждой команде или заводить базовый класс и наследоваться, можно использовать миксины: подмешиваем всем командам миксин, который внутри себя определяет эти опции. Идея миксинов, реализованная аннотациями, — рабочий вариант вместо наследования. Такие вот дела.
[28:16] Кажется, на этом выпуск подошёл к концу — такое вот коротенькое включение. Обещанная полезняшка — канал моего хорошего друга Артёма. Он сильный iOS-разработчик, который решил сделать пивот в карьере и стать разработчиком игр, и у нас есть возможность наблюдать за этим в лайве. Канал называется «500 дней геймдева». Кстати, у меня тоже есть Telegram-канал — там я пишу спонтанные мысли, которые мешают нормально работать; называется «Душный энтерпрайз». Все ссылочки будут в описании. Не забывайте писать отзывы и предлагать темы в Telegram-канале «Тысячи фичей», а также делиться подкастом с другими. Давайте прокачивать себя и людей вокруг. Ну а на этом всё. Услышимся!