Чистый код и поддерживаемость: почему читаемость важнее скорости
Как писать поддерживаемый код: понятные имена, небольшие функции, явные зависимости, простые интерфейсы, тесты и рефакторинг без усложнения проекта.
В разработке программного обеспечения стоимость кода определяется не только тем, сколько времени потребовалось его написать.
Гораздо важнее, сколько времени команда потратит позже, когда потребуется изменить поведение, исправить ошибку, добавить новую интеграцию или разобраться в неожиданном результате.
Код большую часть своей жизни находится не в моменте написания, а в состоянии изменения и сопровождения. Поэтому понятность исходного кода — это не эстетика и не вопрос вкуса. Это характеристика, которая непосредственно влияет на стоимость разработки.
В инженерных практиках Google, например, при code review отдельно оцениваются дизайн, сложность, тесты, именование, комментарии и документация. Один из критериев сформулирован очень практично: сможет ли другой разработчик легко понять и использовать этот код в будущем.
Чистый код — это не набор правил форматирования
Под «чистым кодом» часто понимают отступы, порядок импортов, длину функций или соблюдение конкретного style guide.
Это только поверхность.
Поддерживаемый код должен позволять разработчику быстро ответить на несколько вопросов:
Что делает этот код?
Почему он это делает?
Какие данные он принимает?
Что возвращает?
От чего зависит?
Что произойдёт при ошибке?
Где находится бизнес-правило?
Чем проще ответить на эти вопросы по самому коду и его структуре, тем меньше времени требуется на изменение системы.
Поэтому цель чистого кода — не сделать программу визуально красивой. Цель — снизить стоимость понимания и изменения программы.
Код пишется один раз, а читается постоянно
Популярная формулировка о том, что программисты якобы «читают код в десять раз чаще, чем пишут», имеет хождение как инженерная эвристика, но не стоит превращать её в точную научную константу.
Для практики важнее другое: почти любое изменение начинается с чтения существующей системы.
Перед тем как добавить новую функцию, разработчику приходится найти:
где находится нужная логика;
какие данные участвуют;
кто вызывает этот код;
какие побочные эффекты существуют;
какие тесты уже описывают поведение.
И только после этого начинается изменение.
Поэтому плохая читаемость имеет накопительный эффект. Каждый следующий разработчик платит за неё временем на восстановление контекста.
Хорошее имя уменьшает количество комментариев
Имя переменной, функции, класса или модуля — это часть архитектуры программы.
Сравним:
const data = getData()
и:
const customerOrders = loadCustomerOrders(customerId)
Во втором случае код сообщает больше информации непосредственно из интерфейса.
Хорошее имя должно отвечать хотя бы на один конкретный вопрос:
Что это?
Что делает?
Для кого?
В каком состоянии?
Какой результат возвращает?
Google в рекомендациях по code review отдельно рассматривает именование как один из критериев качества и рекомендует выбирать имена, достаточно длинные для передачи смысла, но не настолько длинные, чтобы ухудшать читаемость.
Это хороший пример простой инженерной практики с высокой отдачей.
Переименование не ускоряет выполнение программы.
Но оно может существенно сократить время, необходимое человеку для понимания кода.
Структура проекта должна отражать смысл системы
Та же идея работает на уровне файлов и каталогов.
Структура:
controllers/
services/
repositories/
models/
utils/
сама по себе ничего не говорит о предметной области.
В большом проекте разработчику может быть полезнее увидеть:
users/
billing/
orders/
notifications/
integrations/
В первом случае структура рассказывает о технических слоях.
Во втором — о бизнес-областях.
Это особенно важно для больших платформ, где код постепенно перестаёт помещаться в голове одного человека.
Хорошая структура не обязательно должна следовать одному конкретному архитектурному паттерну. Важнее, чтобы она помогала быстро найти место, ответственное за конкретное поведение.
Маленькая функция — не самоцель
Иногда принцип «маленьких функций» превращают в механическое правило: функция должна содержать ровно несколько строк.
Такой подход тоже может ухудшить код.
Проблема длинной функции не в количестве строк как таковом.
Проблема возникает, когда в одном месте смешаны разные уровни абстракции.
Например:
function processOrder(order) {
// вычисление суммы
// проверка скидки
// работа с базой
// отправка email
// запись логов
// формирование HTTP-ответа
}
Здесь одновременно находятся расчёт, бизнес-правила, инфраструктурные операции и представление результата.
Гораздо понятнее разделить ответственность:
function processOrder(order) {
const total = calculateOrderTotal(order)
const finalTotal = applyDiscount(order.customer, total)
saveOrder(order, finalTotal)
notifyCustomer(order.customer, finalTotal)
return finalTotal
}
Теперь основной сценарий читается почти как последовательность бизнес-операций.
А детали находятся в функциях с конкретным назначением.
Это и есть полезная идея маленьких функций: не уменьшать количество строк, а уменьшать количество мыслей, которые разработчик должен держать в голове одновременно.
Уровни абстракции должны быть последовательными
Есть ещё одна распространённая проблема:
function processOrder(order) {
validateOrder(order)
const hash = crypto.createHash('sha256')
hash.update(JSON.stringify(order))
updateStatus(order.id, 'processing')
if (order.items.length > 10) {
// ...
}
}
В одной функции смешаны бизнес-операции и технические детали.
Читателю приходится одновременно понимать:
- зачем заказ валидируется;
- зачем вычисляется hash;
- почему меняется статус;
- что означает условие.
Когда разные уровни абстракции перемешиваются, код становится сложнее читать.
Лучше скрыть технические детали за понятными интерфейсами:
function processOrder(order) {
validateOrder(order)
prepareOrderSignature(order)
markOrderAsProcessing(order.id)
if (hasBulkOrder(order)) {
handleBulkOrder(order)
}
}
Теперь основной код описывает намерение.
Явные зависимости лучше скрытой магии
Поддерживаемость сильно страдает, когда код зависит от вещей, которые не видны из его интерфейса.
Например:
function createOrder(data) {
return OrderService.create(data)
}
На первый взгляд непонятно:
- откуда берётся
OrderService; - какая база используется;
- есть ли глобальное состояние;
- вызывает ли метод внешний API;
- можно ли безопасно вызвать функцию в тесте.
Для небольшого скрипта это может быть приемлемо.
Но в большой системе явные зависимости обычно делают поведение предсказуемее:
class OrderService {
constructor(
private readonly orders: OrderRepository,
private readonly notifier: OrderNotifier,
) {}
async create(data: CreateOrderInput) {
// ...
}
}
Теперь зависимости видны непосредственно в контракте объекта.
Это не означает, что dependency injection нужно применять абсолютно везде. Смысл в другом: важные зависимости не должны быть скрыты настолько, что для понимания функции приходится исследовать полпроекта.
Контракты делают изменения безопаснее
Хороший интерфейс должен сообщать:
что принимает функция;
какие состояния допустимы;
что возвращается;
какие ошибки возможны.
В языках с сильной системой типов часть этого контракта можно выразить непосредственно в типах:
type CreateOrderInput = {
customerId: string
items: OrderItemInput[]
}
type CreateOrderResult = {
id: string
total: number
}
Теперь часть требований проверяет уже компилятор.
Но типы не заменяют бизнес-правила.
Например:
type PaymentStatus = 'pending' | 'paid' | 'failed'
не говорит, можно ли перевести paid обратно в pending.
Такой инвариант должен быть отражён в самой бизнес-логике.
Поэтому хороший контракт — это не только сигнатура функции. Это ещё и определённое поведение.
Избавляйтесь от неявного состояния
Одна из самых дорогих форм сложности — глобальное состояние, которое может неожиданно измениться из любого места.
Например:
currentUser
currentTenant
currentLocale
globalConfig
Если функция использует их неявно, её поведение зависит от состояния приложения в момент вызова.
Сравним:
calculatePrice(order)
с:
calculatePrice(order, pricingContext)
Второй вариант может быть чуть многословнее, зато зависимость становится явной.
Это особенно важно в:
- параллельной обработке;
- фоновых задачах;
- тестах;
- очередях;
- серверных приложениях с несколькими запросами.
Чем больше система, тем дороже становится скрытое состояние.
Комментарии нужны не для объяснения плохого кода
Хороший комментарий отвечает на вопрос «почему?», когда причина решения не очевидна из самого кода.
Например:
// Нельзя удалять запись сразу:
// внешний сервис повторяет webhook до подтверждения обработки.
Такой комментарий объясняет ограничение, которое иначе легко нарушить.
Плохой комментарий просто дублирует код:
// Увеличиваем счётчик на единицу
counter++
Если код невозможно понять без большого количества таких комментариев, сначала стоит проверить структуру самого кода.
В инженерных рекомендациях Google комментарии рассматриваются как отдельный аспект code review, но при этом одновременно оцениваются дизайн, сложность, именование и документация. Это хорошо показывает, что комментарий не должен заменять понятную структуру программы.
Тесты — это тоже часть поддерживаемости
Тесты часто рассматривают исключительно как способ убедиться, что программа работает.
На практике хороший тест выполняет ещё одну функцию: фиксирует контракт поведения.
Например:
it('применяет скидку для заказа от 10 000 ₽', () => {
// ...
})
Через несколько месяцев разработчику не нужно восстанавливать бизнес-правило только из реализации.
Он видит его в тесте.
Но тесты сами становятся кодом, который нужно поддерживать. Google прямо рекомендует оценивать качество тестов на code review и отдельно напоминает, что тестовый код также должен оставаться понятным и не создавать лишней сложности.
Поэтому плохой тест может создавать почти столько же проблем, сколько плохой production-код.
Рефакторинг — не переписывание
Рефакторинг часто понимают как:
«Старый код плохой, давайте перепишем его нормально».
Это слишком рискованный подход.
В классическом определении Martin Fowler рефакторинг — это изменение внутренней структуры программы без изменения наблюдаемого поведения. На практике он предлагает выполнять его последовательностью небольших преобразований, каждое из которых сохраняет поведение системы.
Например:
Было
↓
переименовать функцию
↓
обновить вызовы
↓
вынести часть логики
↓
запустить тесты
↓
изменить интерфейс
↓
запустить тесты
↓
Готово
А не:
Удалили модуль
↓
переписали всё
↓
через две недели выяснили,
что сломали три сценария
Маленькие изменения проще проверять, откатывать и ревьюить.
Рефакторинг лучше делать там, где код будет меняться
Не весь старый код необходимо приводить к идеалу.
Если модуль стабилен, редко меняется и выполняет свою задачу, масштабная переделка может не дать никакой практической отдачи.
Fowler отдельно подчёркивает, что основная ценность рефакторинга связана с тем, чтобы сделать код проще для будущих изменений; поэтому особенно разумно рефакторить участки, которые действительно будут развиваться.
Практически это выглядит так:
Нужно изменить старый модуль
↓
Понимаем, что текущая структура мешает
↓
Сначала улучшаем структуру
↓
Добавляем новое поведение
Это намного полезнее, чем устраивать отдельный «месяц чистого кода».
Не смешивайте рефакторинг и изменение поведения без необходимости
Допустим, нужно исправить ошибку в расчёте скидки.
Плохой pull request:
+ исправление скидки
+ переименование 15 классов
+ перенос файлов
+ новая архитектура
+ изменение SQL
+ обновление framework
Даже если результат работает, невозможно быстро понять, какое изменение что сломало.
Google рекомендует разделять существенные рефакторинги и изменения функциональности на отдельные изменения. Небольшие локальные улучшения могут быть частью feature change, но крупный structural refactoring лучше проводить отдельно.
Это помогает и автору, и reviewer.
Размер изменения тоже влияет на качество
Большой pull request сложнее проверить не потому, что разработчики ленивы.
Просто количество взаимосвязей растёт.
Google приводит несколько практических причин в пользу небольших изменений: их быстрее и тщательнее ревьюить, в них проще обнаруживать ошибки, их легче откатывать и отдельно проверять дизайн.
Поэтому хороший инженерный workflow выглядит примерно так:
Одна задача
↓
Небольшое изменение
↓
Тесты
↓
Code review
↓
Merge
↓
Следующее изменение
А не:
Три недели работы
↓
огромный pull request
↓
десятки взаимосвязанных замечаний
Маленький change set — это не бюрократия. Это способ уменьшить количество неизвестных одновременно.
Простота важнее количества абстракций
Плохая реакция на сложность — добавлять новые уровни абстракции.
Например:
Controller
→ Facade
→ Manager
→ Service
→ DomainService
→ Repository
→ Provider
→ Factory
Для простой операции это может создать больше кода, чем самой бизнес-логики.
Абстракция полезна, когда она изолирует изменяющийся аспект, упрощает понимание или защищает границу системы.
Если она существует только потому, что «так правильно по паттерну», она может стать дополнительным источником сложности.
Хороший критерий:
Могу ли я объяснить, какую конкретную сложность убирает этот слой?
Если ответа нет, возможно, слой не нужен.
Читаемость не означает «самый простой код»
Иногда максимально короткое решение читать тяжелее.
Например:
const result = items
.filter((x) => x.active)
.map((x) => x.price)
.reduce((a, b) => a + b, 0)
может быть абсолютно корректным.
Но если операция является важной частью бизнес-логики, вариант с промежуточными именами иногда лучше:
const activeItems = items.filter((item) => item.active)
const prices = activeItems.map((item) => item.price)
const total = prices.reduce((sum, price) => sum + price, 0)
Здесь больше строк, но каждая стадия названа.
Поэтому цель — не минимизировать количество строк.
Цель — минимизировать когнитивную нагрузку при чтении.
Code review — часть поддерживаемости
Code review имеет смысл не только как поиск ошибок перед merge.
Это ещё один механизм, который не даёт кодовой базе постепенно деградировать.
Google прямо формулирует цель code review как поддержание и постепенное улучшение общего уровня здоровья кодовой базы. При этом от reviewer ожидается не поиск абсолютного совершенства, а проверка того, что изменение в целом улучшает или как минимум не ухудшает maintainability, readability и understandability системы.
Практически полезно смотреть как минимум на:
Дизайн
Сложность
Именование
Контракты
Тесты
Обработку ошибок
Побочные эффекты
Документацию
А не только на вопрос:
"Работает ли код?"
Работающий код может быть очень дорогим в сопровождении.
Как поддерживаемость влияет на скорость разработки
На коротком горизонте быстрее написать так:
дублируем код
зашиваем значение
добавляем условие
делаем глобальную переменную
На длинном горизонте стоимость начинает расти.
Условно:
Первые изменения
↓
почти одинаковая скорость
20-е изменение
↓
сложность начинает накапливаться
100-е изменение
↓
каждое новое изменение затрагивает
всё больше старого кода
Именно поэтому maintainability влияет на скорость продукта не через скорость выполнения программы, а через скорость безопасных изменений.
Хорошая кодовая база позволяет быстрее понять место изменения, оценить последствия и выполнить его с меньшим риском.
Практический подход, который работает лучше «большого рефакторинга»
Поддерживаемость проще улучшать постепенно.
Перед изменением кода:
1. Найти фактическое поведение.
2. Найти существующие тесты.
3. Определить границу изменения.
4. Сначала улучшить структуру, если это необходимо.
5. Добавить новое поведение.
6. Проверить тесты.
Если во время работы обнаружился очевидный локальный запах:
неудачное имя
дублирование
слишком большая функция
непонятный интерфейс
его часто стоит устранить сразу, если изменение остаётся небольшим и не размывает задачу.
Это соответствует подходу opportunistic refactoring — небольшим улучшениям кодовой базы по мере работы с конкретным участком. Fowler описывает именно такой способ как практичную альтернативу редким крупным рефакторингам.
Что я считаю хорошим критерием качества
Я не оцениваю код только по тому, насколько он «красиво написан».
Для прикладной разработки важнее несколько вопросов:
Можно ли быстро понять код?
Можно ли безопасно его изменить?
Понятны ли его зависимости?
Есть ли тесты на критичное поведение?
Можно ли локализовать ошибку?
Понятно ли, где находится бизнес-правило?
Не создаёт ли изменение ненужный каскад зависимостей?
Если ответы положительные, код, скорее всего, будет нормально жить в production независимо от того, насколько эффектно он выглядит в примере из книги.
Итог
Чистый код — это не соревнование по количеству паттернов, маленьких функций или строк без комментариев.
Это прежде всего управление сложностью.
Хороший код:
- говорит о своих намерениях через имена и структуру;
- имеет явные зависимости и контракты;
- разделяет разные уровни ответственности;
- покрывает важное поведение тестами;
- допускает небольшие безопасные изменения;
- постепенно улучшается через рефакторинг;
- не требует держать в голове весь проект для изменения одного модуля.
При этом не существует единственного формата «идеального кода». Практики должны соответствовать языку, команде, архитектуре и жизненному циклу проекта.
Главный критерий значительно проще:
Хороший код — тот, который другой разработчик сможет понять и безопасно изменить без археологических раскопок по всему проекту.
Именно поэтому поддерживаемость — не противоположность скорости разработки.
На зрелом проекте она и есть часть скорости: чем меньше времени уходит на понимание существующей системы и восстановление контекста, тем быстрее команда может безопасно выпускать новые изменения.
О подходах к архитектуре, разработке и сопровождению сложных веб-платформ — в разделе услуг.
Релевантные разделы
Читайте также
Архитектура веб-платформ: как проектировать систему, которая растёт
Как проектировать архитектуру веб-платформ, которая выдерживает рост функциональности, команды и нагрузки: модульность, границы ответственности, данные, интеграции, наблюдаемость и выбор между монолитом и микросервисами.
Автоматизация бизнес-процессов: с чего начать и где искать эффект
Как находить процессы для автоматизации, оценивать экономический эффект, выбирать между готовыми платформами и собственной разработкой и строить автоматизацию, устойчивую к ошибкам и изменениям.