← назад к разделу

ПРИНЦИПЫ И ПАТТЕРНЫ ПРОЕКТИРОВАНИЯ Clean Code

Обязательно

Clean Code: имена, функции, комментарии

Java · Go · Node · Python

Практики, которые уменьшают стоимость чтения и изменения кода, не превращая чистоту в самоцель.

1. Имя передаёт назначение без расследования

1 / 5

data, tmp и handle заставляют читать реализацию. Хорошее имя называет предмет, единицу измерения и результат операции.

  • timeoutSeconds точнее timeout.
  • activeUsers точнее list.
  • calculateDeliveryCost обещает вычисление, а не сохранение.
data · tmp · flagactiveUserstimeoutSecondshandle()calculateDeliveryCost()

2. Функция делает один шаг сценария

2 / 5

Когда функция проверяет данные, пишет в базу, отправляет письмо и формирует HTTP-ответ, её трудно назвать и проверить. Разделение делает поток видимым.

  • Один уровень абстракции внутри функции.
  • Меньше параметров и скрытых побочных эффектов.
  • Маленькая функция не оправдывает сотню ничего не значащих обёрток.
CheckoutсценарийValidateSaveNotifyResponse

3. Форматирование показывает структуру

3 / 5

Отступы, группировка связанных строк и единый стиль уменьшают шум. Автоформатирование снимает споры и не заменяет осмысленную декомпозицию.

  • Связанные решения расположены рядом.
  • Условия читаются без длинной цепочки операторов.
  • gofmt, goimports и аналоги применяются автоматически.
if err != nil { return err }save(); notify();total := price+taxreturn responsevalidate inputcalculate totalsave ordernotify customerПорядок на экране = порядок понимания

4. Комментарий объясняет причину, а не переводит код

4 / 5

Комментарий к очевидной строке стареет и дублирует реализацию. Полезный текст сохраняет ограничение, компромисс или неочевидное внешнее требование.

  • Сначала улучшают имя и структуру кода.
  • Оставляют почему, если его нельзя выразить типом.
  • Устаревший комментарий опаснее его отсутствия.
Шумувеличиваем i на 1i++ПричинаПровайдер режетзапросы чаще 100/сКод отвечает что, комментарий - почему

5. Чистый код проверяется изменением и тестом

5 / 5

Нельзя сделать идеальный текст раз и навсегда. Читаемость проявляется, когда новый человек безопасно меняет поведение и быстро находит нужное место.

  • Тесты фиксируют договор, а не каждую строку.
  • Ошибки обрабатываются явно и не прячутся ради короткого кода.
  • Правило не применяют против домена и здравого смысла.
Понятный кодТест договораБезопасная правкаЧистота измеряется стоимостью следующего изменения

Код читают чаще, чем пишут: одну строчку напишут раз, а перечитают её при каждой правке рядом — свои же и коллеги, через полгода и без контекста. Clean Code — набор привычек Роберта Мартина о том, как писать так, чтобы этот второй читатель понял код без археологических раскопок. Это не про красоту ради красоты, а про стоимость изменений: чем понятнее код, тем дешевле его менять.

Разберём главные привычки — не как догму, а как ответы на боль «я не понимаю, что тут происходит».

комментарий-заголовок — это имя функции, которая просится наружу void handleOrder(o) { // проверить заказ validate(o); // посчитать скидку total = applyDiscount(o); // записать в базу save(o, total); // отправить письмо notifyCustomer(o, total); } validate — только правила applyDiscount — только счёт save — только запись notifyCustomer — только письмо

Каждый кусок, который приходилось подписывать комментарием, уезжает в свою функцию с этим же именем. Сама handleOrder остаётся оглавлением из четырёх вызовов — её читают сверху вниз и не проваливаются в детали.

Имена, которые не надо расшифровывать

Имя — самый частый комментарий в коде. Хорошее имя объясняет смысл без пояснений:

// было — что такое d? в днях? в чём? а 86400 — это что?
int d = (now - created) / 86400;

// стало — имя отвечает на вопрос само, а тип отвечает за единицы
long daysSinceRegistration = ChronoUnit.DAYS.between(registeredAt, now);
// было — что такое d? в днях? в чём? а 86400 — это что?
d := (now - created) / 86400

// стало — имя отвечает на вопрос само, а тип отвечает за единицы
daysSinceRegistration := int(now.Sub(registeredAt).Hours() / 24)
// было — что такое d? в днях? в чём? а 86400 — это что?
const d = (now - created) / 86400;

// стало — имя отвечает на вопрос само, а библиотека отвечает за единицы
const daysSinceRegistration = differenceInCalendarDays(now, registeredAt);
# было — что такое d? в днях? в чём? а 86400 — это что?
d = (now - created) // 86400

# стало — имя отвечает на вопрос само, а тип отвечает за единицы
days_since_registration = (now - registered_at).days

Заодно ушла арифметика на секундах: registeredAt и now — моменты времени, а не голые числа, и гадать «в чём тут меряют» больше не надо. Для дат и длительностей в любом языке есть своя библиотека — считать дни должна она, а не деление на 86400.

int d = (now - created) / 86400; что за d? в чём меряем? откуда число?

К одной строке у читателя три вопроса; имя переменной и типы времени снимают все три сразу.

Несколько правил, которые сразу дают эффект:

  • Имя раскрывает намерение. list → activeUsers, flag → isEmailConfirmed, data → orderPayload.
  • Без загадочных сокращений. calcTot() экономит четыре буквы и стоит секунды на каждое чтение; calculateTotal() — не стоит.
  • Длина под область видимости. Счётчик в трёхстрочном цикле может быть i; поле класса, живущее по всему модулю, — нет.
  • Единый словарь. Если в одном месте user, в другом customer, а в третьем client — читатель гадает, это одно и то же или нет. Договоритесь об одном термине — в DDD это Ubiquitous Language.

Функция делает одно

Функция, которая и достала данные, и посчитала, и отправила письмо, ломается при изменении любого из трёх дел, а тест на неё требует базы и почтового сервера сразу: это три функции, слипшиеся в одну. Правило одно: одна функция — одно дело, на одном уровне абстракции.

«Один уровень абстракции» — это про то, на каком языке говорят соседние строки. Либо все они называют шаги сценария («проверить», «посчитать скидку», «сохранить»), либо все возятся с деталями одного шага («взять подстроку», «сравнить с нулём»). Смешение слышно на слух: рядом стоят «отправить письмо покупателю» и «склеить строку темы» — первое из сценария, второе из внутренностей письма.

Обработка заказа по шагам вместо одного тела, где подряд идут валидация, скидка, запись в базу и письмо: каждый шаг проверяют и меняют отдельно.

живой пример

public class OrderFlow {
    record Order(String id, int amount) {}

    public static void main(String[] args) {
        handleOrder(new Order("ord-042", 1500));
    }

    static void handleOrder(Order o) {
        validate(o);
        int total = applyDiscount(o);
        save(o, total);
        notifyCustomer(o, total);
    }

    static void validate(Order o) {
        if (o.amount() <= 0) throw new IllegalArgumentException("сумма должна быть больше нуля");
    }

    static int applyDiscount(Order o) {
        return o.amount() >= 1000 ? o.amount() - o.amount() / 10 : o.amount();
    }

    static void save(Order o, int total) {
        System.out.println("сохранили " + o.id() + ", к оплате " + total);
    }

    static void notifyCustomer(Order o, int total) {
        System.out.println("письмо: заказ " + o.id() + " на сумму " + total);
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import "fmt"

type order struct {
	id     string
	amount int
}

func main() {
	handleOrder(order{"ord-042", 1500})
}

func handleOrder(o order) {
	validate(o)
	total := applyDiscount(o)
	save(o, total)
	notifyCustomer(o, total)
}

func validate(o order) {
	if o.amount <= 0 {
		panic("сумма должна быть больше нуля")
	}
}

func applyDiscount(o order) int {
	if o.amount >= 1000 {
		return o.amount - o.amount/10
	}
	return o.amount
}

func save(o order, total int) {
	fmt.Printf("сохранили %s, к оплате %d\n", o.id, total)
}

func notifyCustomer(o order, total int) {
	fmt.Printf("письмо: заказ %s на сумму %d\n", o.id, total)
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

function handleOrder(order) {
  validate(order);
  const total = applyDiscount(order);
  save(order, total);
  notifyCustomer(order, total);
}

function validate(order) {
  if (order.amount <= 0) throw new Error('сумма должна быть больше нуля');
}

function applyDiscount(order) {
  return order.amount >= 1000 ? order.amount - Math.floor(order.amount / 10) : order.amount;
}

function save(order, total) {
  console.log(`сохранили ${order.id}, к оплате ${total}`);
}

function notifyCustomer(order, total) {
  console.log(`письмо: заказ ${order.id} на сумму ${total}`);
}

handleOrder({ id: 'ord-042', amount: 1500 });
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

from dataclasses import dataclass


@dataclass(frozen=True)
class Order:
    id: str
    amount: int


def handle_order(order: Order) -> None:
    validate(order)
    total = apply_discount(order)
    save(order, total)
    notify_customer(order, total)


def validate(order: Order) -> None:
    if order.amount <= 0:
        raise ValueError("сумма должна быть больше нуля")


def apply_discount(order: Order) -> int:
    return order.amount - order.amount // 10 if order.amount >= 1000 else order.amount


def save(order: Order, total: int) -> None:
    print(f"сохранили {order.id}, к оплате {total}")


def notify_customer(order: Order, total: int) -> None:
    print(f"письмо: заказ {order.id} на сумму {total}")


handle_order(Order("ord-042", 1500))
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Признаки, что функцию пора делить: она не помещается на экран, у неё несколько уровней вложенности if, приходится писать комментарий-заголовок вроде // теперь считаем скидку — этот комментарий и есть имя будущей функции.

Число аргументов — второй по силе признак после размера. Ноль, один, два — нормально. Три уже заставляют помнить порядок: transfer(from, to, amount) ещё читается, а transfer(from, to, amount, currency, comment) — нет, и однажды кто-нибудь поменяет местами два соседних String, и компилятор промолчит. Дальше трёх аргументы обычно собираются в объект: не createOrder(customerId, items, address, promoCode, comment), а createOrder(CreateOrderCommand command) — заодно появляется место, где проверить их совместно. Отдельный случай — когда три аргумента всюду путешествуют вместе (city, street, building): это не длинный список, а ненаписанный тип Address.

И правило, которое ловит целый класс сюрпризов: функция либо отвечает на вопрос, либо меняет состояние, но не то и другое сразу. isEmpty(), getTotal(), findById() ничего не меняют — их безопасно звать в логе, в отладчике и дважды подряд. save(), cancel(), send() меняют — их зовут осознанно и один раз. Смешение выглядит невинно (getConnection(), который заодно открывает соединение, если его ещё нет), а стоит дорого: такой метод нельзя убрать из кода, нельзя позвать лишний раз и нельзя доверять его имени. Если менять и возвращать всё-таки надо, пусть имя об этом говорит: nextId(), acquireConnection(), pollOrder() вопросами не притворяются.

Флаг-аргумент: send(message, true) — что значит true? Булев параметр обычно означает, что функция делает две разные вещи; чаще честнее две функции — sendNow(message) и scheduleSend(message).

Ошибки: явный сигнал вместо кода и пустого значения

Функция, которая молча вернула null или код -1, переложила обработку ошибки на вызывающего — и он обязательно забудет. Три привычки отсюда стоят дороже всех остальных вместе.

Об ошибке сообщает исключение, а не код возврата. Код возврата проверяют, пока помнят; исключение нельзя не заметить — оно либо обработано, либо дошло до общего обработчика и попало в журнал вместе с причиной. Худший вариант — смешение: часть вызовов проверяют, часть нет, и по коду не видно, какие именно.

Не возвращать null. null означает «не знаю, разбирайся сам», и разбирательство заканчивается NullPointerException в чужом модуле через пять слоёв. Вместо него: Optional там, где отсутствие — нормальный исход (findById не нашёл), исключение там, где отсутствие — ошибка (getById обязан найти), пустой список вместо null-списка всегда.

Не принимать null. Метод, который допускает null в аргументах, обречён начинаться с проверок, и каждая такая проверка — след неясного контракта. Честнее объявить аргумент обязательным (Objects.requireNonNull в конструкторе), а необязательность выразить отдельной перегрузкой или объектом-параметром.

Подробнее про иерархию исключений и try-with-resources — в статье исключения в Java, про проглоченный catch и потерянный стектрейс — в типичных ошибках обработки исключений, про то, как исключение превращается в ответ клиенту, — в модели ошибок приложения.

Функция, которая молча вернула nil или код -1, переложила обработку ошибки на вызывающего — и он обязательно забудет. В Go ошибка это значение, а не исключение, поэтому привычки звучат иначе, но их тоже три.

Ошибка возвращается вторым значением и проверяется сразу. value, err := do() и if err != nil на следующей строке: не код -1, не bool, не panic. Паника остаётся для ошибок программиста (обращение по nil, выход за срез), а не для «файла нет» и «запись не найдена». Худший вариант — _ = do(): ошибку выбросили, и по коду не видно, что она вообще возможна. Линтер errcheck внутри golangci-lint ловит это на сборке.

Не возвращать nil как ответ. nil вместо объекта означает «не знаю, разбирайся сам», и разбирательство заканчивается nil pointer dereference в чужом пакете через пять слоёв. Вместо него: именованная ошибка там, где отсутствие — нормальный исход (FindByID возвращает ErrNotFound, вызывающий проверяет errors.Is(err, ErrNotFound)), обёрнутая ошибка с контекстом там, где отсутствие — поломка (fmt.Errorf("заказ %s: %w", id, err)), пустой срез вместо nil-среза в ответах, которые уходят в JSON: [] и null для клиента разные вещи.

Не принимать nil. Функция, которая допускает nil в аргументах, обречена начинаться с проверок, и каждая такая проверка — след неясного контракта. Честнее принимать значение, а не указатель, а необязательность выразить функцией-опцией (WithTimeout(…)) или структурой параметров, где нулевое значение и есть умолчание.

Подробнее про error, обёртывание и errors.Is — в статье ошибки в Go, про то, как ошибка превращается в ответ клиенту, — в ошибках и HTTP, а спор «значения против исключений» разобран в стандарте обработки ошибок.

Функция, которая молча вернула null или код -1, переложила обработку ошибки на вызывающего — и он обязательно забудет. Три привычки отсюда стоят дороже всех остальных вместе.

Об ошибке сообщает исключение, а не код возврата. throw new Error(…) проверять не нужно: исключение либо обработано, либо дошло до общего обработчика и попало в журнал вместе с причиной. В асинхронном коде это отклонённый промис, и await превращает его в тот же throw; худший вариант — коллбэк (err, result) рядом с промисами и .catch(() => {}), который глотает всё. Бросают объекты Error и его наследников, а не строки: у строки нет стека.

Не возвращать null. null и undefined означают «не знаю, разбирайся сам», и разбирательство заканчивается Cannot read properties of undefined в чужом модуле через пять слоёв. Вместо них: честный тип Order | undefined в TypeScript там, где отсутствие — нормальный исход (findById не нашёл), исключение там, где отсутствие — ошибка (getById обязан найти), пустой массив вместо null-массива всегда.

Не принимать null. Функция, которая допускает null в аргументах, обречена начинаться с проверок, и каждая такая проверка — след неясного контракта. Честнее объявить аргумент обязательным (тип без | undefined при включённом strictNullChecks), а необязательность выразить объектом параметров со значениями по умолчанию.

Подробнее про классы ошибок и их иерархию — в стандарте обработки ошибок для Node, про то, как исключение превращается в ответ клиенту, — в статье про фильтры исключений NestJS.

Функция, которая молча вернула None или код -1, переложила обработку ошибки на вызывающего — и он обязательно забудет. Три привычки отсюда стоят дороже всех остальных вместе.

Об ошибке сообщает исключение, а не код возврата. Код возврата проверяют, пока помнят; исключение нельзя не заметить — оно либо обработано, либо дошло до общего обработчика и попало в журнал вместе с причиной. Бросают свои классы, наследники Exception, а не голый Exception и не строки; худший вариант — except Exception: pass, который глотает всё, включая опечатку в имени переменной.

Не возвращать None. None означает «не знаю, разбирайся сам», и разбирательство заканчивается AttributeError: 'NoneType' object has no attribute в чужом модуле через пять слоёв. Вместо него: честная аннотация Order | None там, где отсутствие — нормальный исход (find_by_id не нашёл), и mypy, который заставит проверить; исключение там, где отсутствие — ошибка (get_by_id обязан найти); пустой список вместо None-списка всегда.

Не принимать None. Функция, которая допускает None в аргументах, обречена начинаться с проверок, и каждая такая проверка — след неясного контракта. Честнее объявить аргумент обязательным (без | None в аннотации), а необязательность выразить именованным параметром со значением по умолчанию или объектом параметров.

Подробнее про иерархию исключений и контекстные менеджеры — в статье исключения и контекст в Python, про то, как исключение превращается в ответ клиенту, — в обработке ошибок в FastAPI.

Комментарии: почему, а не что

Хороший комментарий объясняет то, что код сказать не может, — почему так сделано. Плохой пересказывает что делает код, дублируя его, и со временем начинает врать: код поправили, комментарий забыли.

// плохо: пересказ кода — устареет при первой правке
// увеличиваем счётчик на 1
counter++;

// хорошо: объясняет неочевидное «почему»
// Повторяем ровно 3 раза: у платёжного шлюза лимит 3 попытки на идемпотентный ключ.
retry(3, () -> gateway.charge(token));
// плохо: пересказ кода — устареет при первой правке
// увеличиваем счётчик на 1
counter++

// хорошо: объясняет неочевидное «почему»
// Повторяем ровно 3 раза: у платёжного шлюза лимит 3 попытки на идемпотентный ключ.
err := retry(3, func() error { return gateway.Charge(ctx, token) })
// плохо: пересказ кода — устареет при первой правке
// увеличиваем счётчик на 1
counter++;

// хорошо: объясняет неочевидное «почему»
// Повторяем ровно 3 раза: у платёжного шлюза лимит 3 попытки на идемпотентный ключ.
await retry(3, () => gateway.charge(token));
# плохо: пересказ кода — устареет при первой правке
# увеличиваем счётчик на 1
counter += 1

# хорошо: объясняет неочевидное «почему»
# Повторяем ровно 3 раза: у платёжного шлюза лимит 3 попытки на идемпотентный ключ.
retry(3, lambda: gateway.charge(token))

Лучший комментарий — тот, который удалось не писать: тянет пояснить кусок — сначала вынесите его в функцию с говорящим именем. Комментарии оправданы для «почему», для предупреждений о неочевидных последствиях и для публичного API. Закомментированный «на всякий случай» код — не комментарий, а мусор: для истории есть git.

Три прикладных вида комментариев, которые встречаются чаще всего, и что с каждым делать.

Документация публичного API оправдана и нужна: тот, кто вызывает функцию из другого модуля, исходник не откроет — у него есть только подсказка редактора. Писать её стоит про контракт, а не про реализацию: что принимает, что возвращает, какой ошибкой отвечает и чего не делает.

В Java это Javadoc:

/**
 * Возвращает заказ или бросает OrderNotFoundException, если заказа нет.
 * Строки заказа не подгружает: для них есть findWithLines.
 */
Order getById(OrderId id);

В Go это комментарий над экспортируемой функцией, начинающийся с её имени; его показывают go doc и pkg.go.dev:

// GetByID возвращает заказ или ErrOrderNotFound, если заказа нет.
// Строки заказа не подгружает: для них есть FindWithLines.
func (r *Repository) GetByID(ctx context.Context, id OrderID) (Order, error)

В TypeScript это JSDoc, его показывает подсказка редактора:

/**
 * Возвращает заказ или бросает OrderNotFoundError, если заказа нет.
 * Строки заказа не подгружает: для них есть findWithLines.
 */
getById(id: OrderId): Promise<Order>;

В Python это docstring, его показывают help() и подсказка редактора:

def get_by_id(self, order_id: OrderId) -> Order:
    """Возвращает заказ или бросает OrderNotFoundError, если заказа нет.

    Строки заказа не подгружает: для них есть find_with_lines.
    """

TODO и FIXME — это задачи, записанные не туда. Они полезны ровно до конца работы над текущей правкой; дальше их перестают замечать, и в любом живущем годами проекте их сотни, включая пометки от людей, которые давно ушли. Правило простое: пережила рабочий день — заводите задачу в трекере, а в коде оставляйте ссылку на неё или не оставляйте ничего.

Закомментированный код — отдельный случай, и объяснение у него короткое: git помнит всё. Строка, закомментированная «на всякий случай», мешает поиску, попадает в изменения и заставляет каждого читателя гадать, почему её не удалили: она сломана, она про будущее или её просто забыли. Удалённая строка находится за десять секунд — git log -S 'кусок кода' покажет коммит, где она исчезла, вместе с объяснением автора.

Форматирование и структура

Читаемость — это и визуальный порядок. Связанные строки держат рядом, пустой строкой отделяют смысловые блоки, вложенность держат неглубокой. Глубокие if внутри if разворачивают ранним выходом: неподходящие случаи отсекают сверху, а основная логика остаётся слева, без лесенки.

guarded(u) пропускаем u == null пропускаем не активен пишем имя дошли сюда

Каждый неподходящий случай уходит вбок сразу, а главная строка остаётся последней и без отступов.

живой пример

public class Guards {
    record User(String name, boolean active) {}

    public static void main(String[] args) {
        User[] users = {new User("Анна", true), new User("Иван", false), null};
        for (User u : users) {
            System.out.println(nested(u) + "  |  " + guarded(u));
        }
    }

    static String nested(User u) {
        if (u != null) {
            if (u.active()) {
                return "пишем " + u.name();
            }
        }
        return "пропускаем";
    }

    static String guarded(User u) {
        if (u == null) return "пропускаем";
        if (!u.active()) return "пропускаем";
        return "пишем " + u.name();
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import "fmt"

type user struct {
	name   string
	active bool
}

func main() {
	users := []*user{{"Анна", true}, {"Иван", false}, nil}
	for _, u := range users {
		fmt.Println(nested(u) + "  |  " + guarded(u))
	}
}

func nested(u *user) string {
	if u != nil {
		if u.active {
			return "пишем " + u.name
		}
	}
	return "пропускаем"
}

func guarded(u *user) string {
	if u == nil {
		return "пропускаем"
	}
	if !u.active {
		return "пропускаем"
	}
	return "пишем " + u.name
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const users = [{ name: 'Анна', active: true }, { name: 'Иван', active: false }, null];
for (const u of users) console.log(nested(u) + '  |  ' + guarded(u));

function nested(u) {
  if (u != null) {
    if (u.active) {
      return 'пишем ' + u.name;
    }
  }
  return 'пропускаем';
}

function guarded(u) {
  if (u == null) return 'пропускаем';
  if (!u.active) return 'пропускаем';
  return 'пишем ' + u.name;
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

from dataclasses import dataclass


@dataclass(frozen=True)
class User:
    name: str
    active: bool


def nested(u: User | None) -> str:
    if u is not None:
        if u.active:
            return "пишем " + u.name
    return "пропускаем"


def guarded(u: User | None) -> str:
    if u is None:
        return "пропускаем"
    if not u.active:
        return "пропускаем"
    return "пишем " + u.name


for u in (User("Анна", True), User("Иван", False), None):
    print(nested(u) + "  |  " + guarded(u))
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Обе отвечают одинаково — это видно в выводе. Разница в том, сколько условий читатель держит в голове, дойдя до главной строки.

Единый стиль в проекте держит автоформаттер, а не споры на ревью.

Класс: размер, поля, одна тема

Между функцией и архитектурой есть уровень, про который в разговоре о чистоте забывают чаще всего, — класс.

Размер измеряют не строками, а числом ответственностей, но строки — честный индикатор: класс на восемьсот строк почти наверняка делает несколько дел. Второй индикатор точнее — поля. Чем их больше, тем меньше шанс, что каждый метод пользуется всеми; если половина методов работает с тремя полями, а другая половина — с четырьмя оставшимися, внутри класса живут два класса, и линия разреза уже проведена, её осталось прочитать.

Третий признак — имя. Класс, который честно называется OrderCancellation или PriceCalculator, обычно связен: имя обещает одну тему и не даёт дописать туда постороннее. Имя OrderService не обещает ничего — поэтому в него дописывают всё, и через год это те самые восемьсот строк.

Порядок внутри класса читается так же, как порядок внутри функции: поля сверху, публичные методы за ними, приватные помощники ниже в порядке вызова. Читатель идёт сверху вниз и проваливается в детали только там, где ему это нужно.

Дублирование и границы

DRY (Don't Repeat Yourself): одно знание живёт в одном месте. Три скопированных блока с расчётом цены — это три места, где завтра забудут поправить налог. Где у правила проходит граница и почему склейка случайно похожего хуже дублирования — разобрано отдельно, в статье про DRY, KISS и YAGNI.

Работу с внешним миром — базой, чужим API, файлами — прячут за тонкой границей (репозиторий, клиент), чтобы детали не растекались по коду: тогда замена библиотеки или базы затрагивает одно место, а не половину проекта.

Тесты — тоже код

Тесты стареют хуже основного кода: их правят реже, читают только когда упали, и они тихо превращаются в то, что проще удалить, чем понять. Поэтому правила чистоты к ним применимы полностью, а два из них — сильнее, чем к обычному коду.

Один сценарий на тест. Тест, который проверяет пять вещей, при падении сообщает только про первую, а про остальные четыре вы узнаете через четыре запуска. Имя при этом становится предложением: отменаОплаченногоЗаказаВозвращаетДеньги говорит и что проверяют, и чего ждут, а testCancel2 не говорит ничего.

Читаемость важнее краткости. Внутри теста хорошо работает разделение на три части: подготовили данные, выполнили одно действие, проверили результат. Пустая строка между ними экономит больше времени, чем любая хитрая подготовка. А вот надстройки — общая подготовка на весь класс, базовые тестовые классы с наследованием, циклы внутри проверок — как раз мешают: упавший тест приходится читать вместе с тремя файлами вокруг.

Одно отличие от обычного кода всё же есть: дублирование в тестах терпят охотнее. Две похожие подготовки лучше одной общей, если общая заставляет прыгать вверх по файлу, чтобы понять, что вообще подано на вход. Как устроены сами тесты и где проходит граница между быстрым и интеграционным — в статье пирамида тестов.

Когда чистота превращается в догму

Clean Code — ориентиры, а не свод законов. Любой можно довести до абсурда:

  • Дробление на функции по две строки, когда логика читается лучше в одной, — уже не ясность, а прыжки по файлу.
  • «Ни одного комментария» — плохо ровно так же, как «комментарий на каждой строке»: неочевидное «почему» должно быть записано.

Главная претензия к книге — как раз про дробление, и её стоит разобрать на примере. В книге функции по три-четыре строки, и там, где их шесть, это читается; там, где их сорок, читатель получает оглавление без текста.

// было: шесть строк, видно всё сразу
static boolean canCancel(Order order) {
    return order.status() == Status.CREATED
        && order.paidAt() == null
        && order.createdAt().isAfter(LocalDateTime.now().minusDays(30));
}

// стало «по книге»: три прыжка вместо одного взгляда
static boolean canCancel(Order order) {
    return isNew(order) && isUnpaid(order) && isFresh(order);
}
// было: шесть строк, видно всё сразу
func canCancel(order Order) bool {
	return order.Status == StatusCreated &&
		order.PaidAt.IsZero() &&
		order.CreatedAt.After(time.Now().AddDate(0, 0, -30))
}

// стало «по книге»: три прыжка вместо одного взгляда
func canCancel(order Order) bool {
	return isNew(order) && isUnpaid(order) && isFresh(order)
}
// было: шесть строк, видно всё сразу
function canCancel(order: Order): boolean {
  return order.status === Status.Created
    && order.paidAt === undefined
    && order.createdAt > subDays(new Date(), 30);
}

// стало «по книге»: три прыжка вместо одного взгляда
function canCancel(order: Order): boolean {
  return isNew(order) && isUnpaid(order) && isFresh(order);
}
# было: шесть строк, видно всё сразу
def can_cancel(order: Order) -> bool:
    return (order.status == Status.CREATED
            and order.paid_at is None
            and order.created_at > datetime.now() - timedelta(days=30))


# стало «по книге»: три прыжка вместо одного взгляда
def can_cancel(order: Order) -> bool:
    return is_new(order) and is_unpaid(order) and is_fresh(order)

Второй вариант выигрывает, если каждое из трёх правил нетривиально и используется ещё где-то. Он проигрывает, если правила одноразовые: чтобы ответить на вопрос «а тридцать дней или шестьдесят?», теперь надо открыть три метода вместо одного выражения. Поэтому числовые нормативы вроде «функция не длиннее четырёх строк» и не работают: длина — следствие, а не цель. Рабочий критерий один: после деления вопрос читателя должен закрываться быстрее, чем до него.

Ориентир простой: чистый код — тот, при чтении которого следующий человек не задаёт лишних вопросов. Правило помогает этому — применяйте; читать стало труднее — правило проиграло.

Дополнительно: при первом чтении можно пропустить

Глубже: запахи кода и приёмы рефакторингарасширенное

Вся фаза учит замечать неправильное: класс на восемьсот строк, switch по типу, цепочку геттеров, функцию, которая не помещается на экран. У каждого такого признака есть имя, и имена стоит знать: в ревью «здесь Feature Envy» короче и точнее, чем абзац объяснений.

Самые частые запахи. God Object, класс, который знает и делает всё: OrderService на две тысячи строк с платежами, доставкой и письмами. Feature Envy, метод, который больше работает с чужими данными, чем со своими: calculateTotal в контроллере, который дёргает пять геттеров заказа, логике место в заказе. Shotgun Surgery, одно изменение требует править десять классов: добавили поле в заказ, и трогать пришлось DTO, маппер, валидатор, сериализатор, тест. Primitive Obsession, деньги как число с плавающей точкой и валюта как строка вместо Money, email как строка без проверки. Long Parameter List, семь аргументов, из которых три всегда идут вместе. Data Clumps, те самые три аргумента, которые всюду путешествуют вместе и просятся в объект. Switch Statements, разбор по типу, повторённый в нескольких местах.

Приёмы, которыми это чинят, тоже поимённые, и почти все они из книги Фаулера «Рефакторинг». Extract Method вырезает кусок функции с говорящим именем. Extract Class делит God Object по ответственностям. Move Method переносит метод к данным, с которыми он работает, и лечит Feature Envy. Introduce Parameter Object собирает Data Clumps в класс. Replace Primitive with Object заводит Money и Email. Replace Conditional with Polymorphism меняет switch по типу на реализации интерфейса, это и есть GRASP Polymorphism из соседней статьи. Каждый приём в IDE делается с гарантией сохранения поведения: IntelliJ IDEA выполняет Extract Method и Move за одну команду и сама обновляет вызовы.

Правило, без которого рефакторинг превращается в переписывание: одно преобразование за раз, тесты зелёные до и после, коммит на каждый шаг. Рефакторинг не меняет поведение, он меняет форму; если по ходу захотелось «заодно починить баг», это отдельный коммит.

Коротко

  • Код читают чаще, чем пишут; чистота — это про стоимость будущих изменений, а не про эстетику.
  • Имена раскрывают намерение и не требуют расшифровки; единый словарь важнее краткости.
  • Функция делает одно на одном уровне абстракции; ноль-один-два аргумента нормально, дальше — объект-параметр; булев флаг — сигнал разделить надвое; функция либо отвечает на вопрос, либо меняет состояние.
  • Комментарий объясняет «почему», а не пересказывает «что»; документация публичного API (Javadoc, doc-комментарий Go, JSDoc, docstring) — про контракт, TODO живёт до конца дня, закомментированный код удаляют: его помнит git.
  • Об ошибке сообщает явный сигнал языка (исключение, в Go значение error), а не код возврата; пустое значение (null, nil, None) не возвращают и не принимают: честный необязательный тип там, где пусто нормально, ошибка там, где это поломка.
  • Класс режут по полям: если половина методов работает с одними полями, а половина с другими, внутри живут два класса.
  • Тест — код первого сорта: один сценарий на тест, имя-предложение, подготовка-действие-проверка; дублирование в тестах терпят охотнее.
  • Ранние выходы вместо глубокой вложенности, единый формат — на автоформаттере; DRY — одно знание в одном месте, но без склейки того, что меняется по разным причинам.
  • Всё это — ориентиры, а не догма: правило, из-за которого код читать труднее, проиграло.
  • У запахов есть имена (God Object, Feature Envy, Shotgun Surgery, Primitive Obsession, Data Clumps) и парные приёмы (Extract Method и Class, Move Method, Introduce Parameter Object, Replace Conditional with Polymorphism); шаг за шагом, тесты зелёные до и после.

Что почитать дальше