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

Код читают чаще, чем пишут: одну строчку напишут раз, а перечитают её при каждой правке рядом — свои же и коллеги, через полгода и без контекста. 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);

Заодно ушла арифметика на секундах: 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);
    }
}
Запустить

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

Признаки, что функцию пора делить: она не помещается на экран, у неё несколько уровней вложенности 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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();
    }
}
Запустить

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

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

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

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

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

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

Третий признак — имя. Класс, который честно называется 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);
}

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

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

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

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

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

Самые частые запахи. God Object, класс, который знает и делает всё: OrderService на две тысячи строк с платежами, доставкой и письмами. Feature Envy, метод, который больше работает с чужими данными, чем со своими: calculateTotal в контроллере, который дёргает пять геттеров заказа, логике место в заказе. Shotgun Surgery, одно изменение требует править десять классов: добавили поле в заказ, и трогать пришлось DTO, маппер, валидатор, сериализатор, тест. Primitive Obsession, деньги как BigDecimal и валюта как String вместо 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 за одну команду и сама обновляет вызовы.

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

Коротко

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

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