Эдсгер Дейкстра: «Грязно и быстро — мне это не понравится»
«Чтобы иметь право называть себя профессионалом, вы должны писать чистый код. Нет никаких разумных оправданий тому, чтобы не стремиться к лучшему». Clean Code
В этом эссе я хочу рассказать о том, как пишу код. Я буду называть свою методику «грязным кодом», потому что часто нарушаю рекомендации
«чистого кода» — популярной концепции написания кода.
Вообще, я на самом деле не считаю свой код абсолютно грязным: местами он немного уродлив, но по большей части я им доволен, и он достаточно прост в поддержке, обеспечивая при этом разумные уровни качества.
Кроме того, я не пытаюсь своим эссе убедить
вас писать грязный код. Скорее, я хочу показать, что таким способом можно писать достаточно успешное ПО, и, надеюсь обеспечить некий баланс в обсуждениях методологий разработки ПО.
Я программирую уже довольно давно и видел разнообразные подходы к обеспечению работоспособности ПО. Кто-то любит объектно-ориентированное программирование (я тоже), другие умные люди его ненавидят. Кому-то нравится выразительность динамических языков, кого-то она бесит. Кто-то успешно выпускает программы, строго следуя концепции Test Driven Development, другие добавляют в конце проекта несколько сквозных тестов, а многие остаются где-то посередине этих крайних точек.
Я был свидетелем проектов, выпускавших и поддерживавших успешное ПО на основе всех этих разнообразных подходов.
Поэтому повторюсь, что моя цель не убедить вас, что мой способ кодинга единственно возможный, а показать вам (и в особенности начинающим разработчикам, которых легко запугать терминами наподобие «чистого кода»), что можно иметь успешную карьеру программиста, пользуясь множеством различных подходов, и что мой — один из них.
TLDR
В этом эссе я расскажу о трёх «грязных» практиках кодинга:
- На самом деле, большие функции (некоторые) — это хорошо.
- Следует предпочитать интеграционные тесты юнит-тестам.
- Не стоит завышать количество классов/интерфейсов/концепций.
Я люблю большие функции
Я считаю, что большие функции — это нормально. На самом деле, я считаю, что
некоторые большие функции — это благо для кодовой базы.
Это противоречит принципу чистого кода, который гласит:
«Первое правило функций — они должны быть маленькими. Второе правило функций — они должны быть ещё меньше».
Разумеется, это всегда зависит от типа выполняемой работы, но обычно я упорядочиваю свои функции следующим образом:
- Несколько крупных «базовых» функций, настоящее «мясо» модуля. Я не ограничиваю количество строк кода (LOC) этих функций, но начинаю ощущать дискомфорт, когда они становятся длиннее примерно 200-300 LOC.
- Приличное количество «поддерживающих» функций в пределах 10-20 LOC.
- Приличное количество «вспомогательных» функций в пределах 5-10 LOC.
В качестве примера «базовой» функции рассмотрим
issueAjaxRequest()
из проекта
htmx. Эта функция состоит почти из 400 строк!
Её точно нельзя считать «чистой»!
Однако в этой функции хранится много связанного контекста, и он выстроен в последовательность конкретных шагов, которые должны выполняться довольно линейным образом. Ничего из неё нельзя использовать повторно, даже если разбить её на несколько других функций; к тому же я считаю, что это повредит понятности функции (а также удобству отладки, что важно для меня).
▍ Важные вещи должны быть большими
Серьёзная причина моей любви к большим функциям заключается в том, что, по моему мнению, в ПО при прочих равных условиях важные вещи должны быть большими, а неважные — маленькими.
Давайте взглянем на визуальную схему сравнения «чистого» и «грязного» кода:
Три категории функций: важные (красные), средней важности (оранжевые) и неважные (зелёные)
Если разбить функции на множество маленьких реализаций равного размера, мы размажем важные части реализации по модулю, даже если они идеально выражались в большой функции.
Всё начинает выглядеть одинаково: определение сигнатуры функции, за которым следует конструкция if или цикл for, возможно, вызовы одной-двух функций, и возврат.
Если позволить важным «базовым» функциям оставаться большими, то их будет проще выделять в море других функций, ведь они очевидно важны: поглядите, насколько они большие!
Кроме того, во всех трёх категориях в общем случае будет меньшее количество функций, так как бо́льшая часть кода объединена в крупные функции. Для сигнатур конкретных типов (которые могут со временем меняться) требуется меньшее количество строк кода, и проще хранить в голове имена и сигнатуры важных и средних по важности функций. К тому же в этом случае обычно снижается и общее количество LOC.
Если мне нужно знакомиться с новым модулем, я предпочитаю, чтобы он был «грязным»: я быстрее пойму его, и мне проще будет запоминать важные части.
▍ Эмпирическое доказательство
А как насчёт эмпирических (пугающее слово в разработке ПО!) доказательств идеального размера функций?
В
разделе 4 главы 7 книги
«Совершенный код» Стив Макконнелл приводит доводы за и против больших функций. Результаты спорные, но во многих из цитируемых им исследований метрика «ошибки на строку» лучше для
больших функций, нежели для маленьких.
Есть и
более новые исследования, свидетельствующие в пользу маленьких функций (<24 LOC), но упор в них делается на то, что исследователи называют «предрасположенность к изменениям». Что же касается багов, то исследователи говорят следующее:
Корреляции между SLOC и предрасположенностью к багам (то есть #BuggyCommits) существенно ниже, чем четыре индикатора предрасположенности к изменениям.
И, разумеется, в длинных функциях содержится больше кода, так что корреляция предрасположенности к багам
на строку кода будет ещё ниже.
▍ Реальные примеры
А как насчёт примеров из реального сложного и успешного ПО?
Возьмём функцию
sqlite3CodeRhsOfIn()
из популярной опенсорсной базы данных
SQLite. В ней содержится > 200 LOC, а просмотрев кодовую базу SQLite, мы сможем найти множество других примеров больших функций. SQLite считается крайне высококачественным и хорошо поддерживаемым проектом.
Или рассмотрим функцию
ChromeContentRendererClient::RenderFrameCreated()
из веб-браузера
Google Chrome. Похоже, в ней тоже больше 200 LOC. Покопавшись в кодовой базе, мы тоже найдём множество других длинных функций. Chrome решает одну из самых сложных задач в мире ПО: он стал хорошим гипермедиа-клиентом общего назначения. Тем не менее, его код не кажется мне особо «чистым».
Далее взглянем на функцию
kvstoreScan()
из
Redis. Она меньше, порядка 40 LOC, но всё равно намного больше, чем рекомендует чистый код. Бегло просмотрев кодовую базу Redis, мы сможем найти множество других «грязных» примеров.
Всё это проекты на C, так что, возможно, правило маленьких функций применимо только к объектно-ориентированным языкам наподобие Java?
Что ж, давайте взглянем на функцию
update()
из класса
CompilerAction
IntelliJ, состоящую примерно из 90 LOC. Поискав в её кодовой базе, мы снова найдём множество больших функций длиной более 50 LOC.
SQLite, Chrome, Redis и IntelliJ…
Всё это важные, сложные, успешные и хорошо поддерживаемые проекты; тем не менее во всех них можно найти большие функции.
Я не подразумеваю, что разработчики этих проектов были бы как-то согласны с этим эссе, но считаю, что у нас есть достаточно веские доказательства того, что длинные функции приемлемы в программных проектах. Можно сказать, что разбивать функции лишь для того, чтобы они были маленькими, не так уж необходимо. Разумеется, это можно делать по другим причинам, например, для повторного использования кода, но малый размер ради малого размера — это не необходимость.
Я предпочитаю интеграционные тесты юнит-тестам
Я огромный фанат тестирования и крайне рекомендую тестирование ПО в качестве ключевого компонента создания удобных в поддержке систем.
Сам htmx стал возможен только благодаря тому, что у нас есть качественный
набор тестов, помогающий гарантировать стабильность библиотеки в процессе нашей работы над ней.
Если взглянуть на
набор тестов, можно заметить почти полное отсутствие
юнит-тестов. У нас очень мало тестов, напрямую вызывающих функции объекта htmx. Вместо этого в основном применяются
интеграционные тесты: они подготавливают конкретную конфигурацию DOM с некими атрибутами htmx, а затем, например, нажимают кнопку и проверяют разные аспекты состояния DOM.
Это противоречит рекомендации «Чистого кода» по обширному применению
юнит-тестирования в сочетании с Test-First Development:
Первый закон Нельзя писать код продакшена, пока вы не написали проваливающийся юнит-тест.
Второй закон Нельзя писать больший объём юнит-теста, чем достаточно для провала, и невозможность компиляции тоже считается провалом.
Третий закон Нельзя писать больше кода продакшена, чем было бы достаточно для текущего проваливающегося теста.
Обычно я избегаю подобного, особенно на ранних этапах проектов. В начале проекта мы часто не представляем, какими будут нужные абстракции предметной области, и нам нужно попробовать несколько разных подходов, чтобы понять, что мы делаем. Если использовать методику «сначала тесты», то у вас появится куча тестов, которые будут ломаться в процессе исследования пространства задачи и попыток найти подходящие абстракции.
Кроме того, юнит-тестирование мотивирует к подробному тестированию каждой написанной вами функции, поэтому часто бывает так, что у вас больше тестов, привязанных к конкретной реализации, чем к высокоуровневому API или к концептуальным идеям модуля кода.
Разумеется, можно и нужно рефакторить тесты в процессе изменения кода, но в реальности этот большой разрастающийся набор тестов набирает собственную массу и инерцию в проекте (особенно когда к нему присоединяются другие разработчики), что всё больше усложняет внесение изменений. В результате вам приходится писать для кода тестов вспомогательные тесты, имитации и так далее.
Весь этот код и вся эта сложность обычно привязывают вас к конкретной реализации.
▍ Грязное тестирование
Во многих проектах я предпочитаю на ранних этапах писать юнит-тесты, но не очень много, и ждать, пока кристаллизуются базовый API и концепции модуля.
После этого я тщательно тестирую API интеграционными тестами.
По моему опыту, такие интеграционные тесты гораздо полезнее юнит-тестов, потому что они остаются стабильными и полезными даже при изменении реализации. Они не так сильно привязаны к текущей базе данных, и скорее выражают высокоуровневые инварианты, которые гораздо проще переживают рефакторинги.
Кроме того, я выяснил, что после создания нескольких высокоуровневых интеграционных тестов можно заниматься Test-Driven development, но на более высоком уровне: вы думаете не о юнитах кода, а об API, который хотите получить, пишете тесты для этого API, а затем реализуете его так, как вам это кажется уместным.
Поэтому я считаю, что мы должны откладывать написание больших наборов тестов на более поздние этапы проектов, и что этот набор тестов должен работать на более высоком уровне, чем рекомендует Test-First Development.
Обычно, если я могу написать высокоуровневый интеграционный тест для демонстрации бага или фичи, то попробую сделать это, надеясь, что высокоуровневый тест дольше будет полезен в проекте.
Я предпочитаю минимизировать классы
Последняя стратегия написания кода, которую я использую, заключается в том, что обычно я стремлюсь минимизировать количество классов/интерфейсов/концепций в своём проекте.
«Чистый код» прямыми словами заявляет, что необходимо максимизировать количество классов в системе, но многие его рекомендации приводят к следующему результату:
- «Предпочитайте полиморфизм конструкциям If/Else или Switch/Case»
- «Первое правило классов — они должны быть маленькими. Второе правило классов — они должны быть даже меньше».
- «Принцип единственной ответственности (SRP) гласит, что класс или модуль должен иметь одну и только одну причину для изменений».
- «Первое, что вы можете заметить — программа стала намного длиннее. Вместо одной страницы её длина составляет три».
Как и в случае с функциями, я не считаю, что классы должны быть особо маленькими или что мы должны предпочитать полиморфизм простой (или даже длинной и ужасной) конструкции if/else, или что отдельный модуль или класс должен иметь только одну причину для изменения.
И я считаю, что последнее предложение из цитаты вполне явно намекает на причины этого: у вас получается намного больше кода, который практически не приносит реальной пользы системе.
▍ «Божественные» объекты
Люди часто критикуют идею
«божественных объектов» и я, разумеется, понимаю причины этой критики: несогласованный класс или модуль с трясиной несвязанных друг с другом функций — это очевидно плохо.
Однако я считаю, что боязнь «божественных объектов» приводит к возникновению противоположной проблемы: ПО, чрезмерно разбитому на части.
Чтобы сбалансировать эту боязнь, давайте взглянем на один из моих самых любимых программных пакетов —
Active Record.
Active Record позволяет отображать объекты Ruby в базу данных, это так называемый инструмент
объектно-реляционного отображения.
И на мой взгляд, он прекрасно справляется с этой задачей: простые задачи в нём выполняются просто, средние по сложности достаточно просты, а когда возникают трудности, можно без особых проблем перейти к сырому SQL.
(Это отличный пример того, что я называю
«слоистостью» API.)
Но объекты Active Record хороши не только этим: они также предоставляют превосходную функциональность для создания HTML в
слое представления Rails. Они не содержат
специфичной для HTML функциональности, однако предоставляют функциональность, полезную на стороне представления, например, обеспечивают API для получения сообщений об ошибках даже на уровне полей.
При написании приложений Ruby on Rails мы просто передаём экземпляры Active Record наружу представлению/шаблонам.
Сравним это с более разбитой на части реализацией, в которой ошибки валидации обрабатываются как отдельные аспекты. Теперь для надлежащей генерации HTML необходимо передать две разные вещи (или хотя бы получить к ним доступ). В сообществе разработчиков на Java часто происходит так, что используется паттерн
DTO и есть отдельное множество объектов, полностью отличающееся от слоя ORM, который передаётся представлению.
Мне нравится подход, реализованный в Active Record. Пусть с точки зрения пуриста он и не
разделяет ответственность, но
меня чаще всего заботит ответственность передачи данных из базы данных в документ HTML, и Active Record прекрасно справляется с этой работой, не заставляя меня по пути иметь дело с кучей других объектов.
Это помогает мне минимизировать общее количество объектов, которые нужны для работы с системой.
Может ли в модель вкрасться функциональность, которая связана с «представлением»?
Да, но это не конец света, и это снижает количество слоёв и концепций, с которыми мне придётся иметь дело. Наличие одного класса, который обрабатывает получение данных из базы данных, хранит логику предметной области и служит средством для передачи информации в слой представления, сильно упрощает мою работу.
Заключение
Я привёл три примера моей методологии «грязного кода»:
- На самом деле большие функции (некоторые) — это хорошо
- Следует предпочитать интеграционные тесты юнит-тестам.
- Не стоит завышать количество классов/интерфейсов/концепций.
Повторюсь, я привёл их не для того, чтобы убедить
вас писать код так, как пишу его
я, или чтобы сказать, что мой способ кодинга хоть в каком-то смысле «оптимален».
Скорее, я хотел дать вам, и в особенности начинающим разработчикам, понимание того, что для успешной карьеры в разработке ПО вы не
обязаны писать код так, как рекомендуют многие лидеры мнений.
Не нужно пугаться, если кто-то назовёт ваш код «грязным»: многие успешные примеры ПО были написаны таким способом, и если вы сосредоточитесь на
ключевых идеях разработки ПО, то, вероятно, обретёте успех, каким бы «грязным» ни был код, а может, и благодаря этому!
Telegram-канал со скидками, розыгрышами призов и новостями IT 💻