YAGNIN, but YAGNIL
Monday, 25 Dec 2023
третья часть «четырех главных навыков разработчика»
умение проектировать ПО таким образом, чтобы в первой версии не было ни единой строки «в расчете на будущие изменения», но будущие изменения не затрагивали существующий код никаким образом
Одним из самых омерзительных «приниципов» разработки, появившихся в последнее десятиление, можно с уверенностью назвать «YAGNI». Несмотря на то, что он вырос из кокетливого, но в целом небезыдейного «premature optimization is the root of all evil» Дональда Кнута, озвученного почти 60 лет тому назад, современная интерпретация дарит лентяям и неквалифицированным специалистам (и подлецам) огромный простор для увиливания от корректного решения задач.
Правильная формулировка вынесена в заголовок этой заметки: «You aren’t gonna need it now, but you are gonna need it later». Если бы это было не так, и «architecting for future requirements / applications turns out net-positive» (по выражению Джона Кармака) действительно бы пригождалось крайне редко, мы бы никогда не сталкивались с реакцией «этот код проще написать с нуля, чем изменить». Никогда бы не возникала ситуация «придется рефакторить на три модуля вглубь» при добавлении нового параметра в вызов функции. Люди бы никогда не ломали обратную совместимость, ведь выдернутая из контекста цитата Рона (доступна по ссылке выше) — обещает нам простое добавление новых штук по мере надобности.
К сожалению, ничто из вышеперечисленного не работает. По крайней мере, из коробки, в том виде, в котором нам пытаются это продать экстремисты от программирования.
С другой стороны, Кнут, Джеффрис и Кармак — люди уважаемые, написавшие по груде кода, проверенного десятилетиями, и, скорее всего, совсем уж ерунду нести не станут.
Как обычно, все дело в нюансах. Сразу реализовывать все потенциальные возможности, которые только могут в будущем прийти в голову безумному заказчику — очевидно, задача неблагодарная. Так делать не сто́ит. Да и не угадать все эти потенциальные идеи прямо сейчас. Но к ним необходимо быть готовыми.
Что это значит? — Давайте я попробую пояснить на примере.
Пусть перед нами стоит задача написания публикатора текста в мастодон. Консольная утилита, буквально. Тут — файлик с текстом, там — тут (toot). С синтаксисом вызова наподобие $ post file.txt
.
Первое, с чем необходимо определиться — этим будет пользоваться кто-то еще? Потому что, например, если речь идет про однопользовательскую программу, запускаемую раз в пять лет на моем компьютере — даже ошибки отлаживать не нужно. Вылетит с исключением? — Подправлю и перезапущу, ну.
Но если речь идет про какой-то проект для людей, будь то заказчик, или падкие на опенсорс халявщики, все становится немного сложнее. И дело даже не в ошибках, ошибки лояльные пользователи простят, да их и починить, опять же, несложно. Дело в том, что если проект будет развиваться, надо к этому немного подготовиться. Потому что с каждым новым требованием переписывать все с нуля — весело, но неэффективно.
Итак, куда оно может разростись?
- добавится новый сервис;
- добавится новый формат;
- добавится пакетное выполнение;
- что-то еще, наверное, но пока хватит.
Пришло время произнести два заветных слова: «dependency injection». Везде, где вы ожидаете масштабирование требований вширь, можно подстелить себе соломку не написав ни единой лишней строчки. Вместо вот такого хардкода
@spec publish(String.t()) :: :ok
def publish(text) do
text
|> Markdown.format()
|> Mastodon.publish()
end
можно обколоться зависимостями, с внятным умолчанием.
@spec publish(String.t() | [String.t()], Formatter.t(), Publisher.t()) :: :ok
def publish(texts, formatter \\ Markdown, publisher \\ Mastodon) do
texts
|> List.wrap() # accept a single string as well
|> Enum.each(fn text ->
text
|> formatter.format()
|> publisher.publish()
end)
end
Все, теперь этот код готов к тому, чтобы принимать тексты по нескольку штук (как и по одному), форматировать чем-угодно и публиковать куда-угодно. Мы закрыли все три потенциальных завтрашних требования, которые пришли нам в голову, ни написав ни единой лишней строки кода (ну, символов 60 пришлось добавить, да, пардон).
Разумеется, мы не сможем предугадать все потребности завтрашнего дня. Но некоторые прямо бросаются в глаза настолько, что их можно добавить в код, не вставая со стула. Все знают, что магические константы — это плохо. Поэтому люди делают так:
@pi 3.14159265
def pi, do: @pi
Так делать не нужно. По двум причинам: если это π, оно никогда не изменится, и его никто не спутает с ФИО генерального. А если это что-то, что может измениться завтра (например, максимальное количество символов для перепубликации) — @max_symbols 140
не очень-то поможет: его потребуется найти и изменить прямо в коде. Вот так хорошо:
@max_symbols Application.compile_env(:my_app, :max_symbols, 140)
def truncate(text, symbols \\ @max_symbols),
do: String.slice(0..symbols-1)
Надо изменить значение по умолчанию? — Добро пожаловать в конфиг. Надо иметь возможность отрезать разное количество символов? — Вот тут параметр, привет.
На самом деле, этот пример очень легко масштабируется на любую «серую зону» изменений архитектуры. Архитектура меняться не должна в принципе: должны меняться запчасти. Бизнес молодой, и клиентов пока только пять? — Значит функция отправки новогоднего поздравления по почте (простите) должна быть готова распараллелиться. Это MailSender
, экспортирующий единственную функцию send/2
который пока принимает текст и список почтовых адресов и просто последовательно проходит по нему. Когда придет сотый клиент — мы его перепишем в несколько потоков, не трогая остальной код, — и все.
Старайтесь делать так, чтобы за любую операцию в вашем коде отвечал отдельно взятый, крайне простой, обособленный и протестированный модуль. В конце концов, можно с детства готовиться к тому, как в твоем сортире замочится унитаз, и в совершенстве овладеть навыками его прочистки. А можно, когда настанет час, позвать сантехника, который умеет только это, но умеет хорошо. Это и есть «dependency injection».