Поправка. В этой статье я ссылаюсь на главу 2 из книги Роберта Мартина «Чистый код». Но мне недавно сказали, что конкретно эту главу писал Тим Оттингер. Однако Мартин указан как единственный автор книги, а значит, он вполне поддерживает совет той главы.

Несмотря на то, что книга «Чистый код» привнесла в наш лексикон прекрасный термин, она также снискала и дурную славу. Это руководство от 2008 года представляет собой сборник принципов и исследований, которые «дядя Боб» (Uncle Bob, то есть Роберт Мартин) выработал за годы программирования.

В итоге его практики переняли многие разработчики, одни из которых почитают их как святыни, а другие воспринимают, скорее, в качестве ориентиров, нежели строгих правил. Но, как бы вы к этому ни относились, сам дядя Боб смотрит на них не как на руководства. Он следует этим практикам всецело и очень редко допускает исключения.

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

Можно подумать...

Здесь нужно иметь в виду, что Мартин взял эти примеры не с потолка. Это реальные изменения, которые он производил в своей работе, и которые он использует в качестве поучительных.

В статье я буду разбирать самый первый пример рефакторинга из главы 2 «Содержательные имена».

Вот код «до»:

private void printGuessStatistics(char candidate, int count) {
    String number;
    String verb;
    String pluralModifier;
    if (count == 0) {
        number = "no";
        verb = "are";
        pluralModifier = "s";
    } else if (count == 1) {
        number = "1";
        verb = "is";
        pluralModifier = "";
    } else {
        number = Integer.toString(count);
        verb = "are";
        pluralModifier = "s";
    }
    String guessMessage = String.format(
        "There %s %s %s%s", verb, number, candidate, pluralModifier
    );
    print(guessMessage);
}

А вот «после»:

public class GuessStatisticsMessage {
    private String number;
    private String verb;
    private String pluralModifier;
 
    public String make(char candidate, int count) {
        createPluralDependentMessageParts(count);
        return String.format(
            "There %s %s %s%s", 
            verb, number, candidate, pluralModifier );
    }
 
    private void createPluralDependentMessageParts(int count) {
        if (count == 0) {
            thereAreNoLetters();
        } else if (count == 1) {
            thereIsOneLetter();
        } else {
           thereAreManyLetters(count);
        }
    }
 
    private void thereAreManyLetters(int count) {
        number = Integer.toString(count);
        verb = "are";
        pluralModifier = "s";
    }
 
    private void thereIsOneLetter() {
        number = "1";
        verb = "is";
        pluralModifier = "";
    }
 
    private void thereAreNoLetters() {
        number = "no";
        verb = "are";
        pluralModifier = "s";
    }
}

О, боги.

Прежде чем переходить к анализу недочётов, хочу кое-что сказать.

В книге есть целая отдельная глава о разделении функций, но она как-то просочилась сюда. Мартин часто так делает в своих примерах. Его строгая приверженность к использованию реальных примеров изменения кода из своей работы затрудняет понимание преподаваемых им в каждой главе уроков. Он ведь мог сразу грамотно выстроить эти функции и просто изменить их имена.

То же самое во многих других примерах, где он показывает код «до», который имеет много существенных недочётов, а не один, которому посвящена текущая глава. Но Мартин исправляет и их тоже, усложняя для читателя понимание того, насколько эффективен конкретно совет из текущей главы.

Всё это я говорю, потому что буду оценивать код целостно, точно так же, как дядя Боб производит рефакторинг в своих примерах. Думаю, это честно.

Первым бросается в глаза то, что Мартин взял одну, преимущественно ЧИСТУЮ функцию (привет всем поклонникам функционального программирования) и сделал из неё класс. Причём не статический вспомогательный класс или что-то подобное, а ЭКЗЕМПЛЯР, атрибуты которого изменяются для получения конкретного результата.

Зачем он это делает? Всё дело в непреклонном стремлении Мартина к разделению функций и отсутствию аргументов, которое ведёт к тому, что единственным способом присвоить в функции переменные для внешнего использования остаются атрибуты. В Java нет возможности передачи по ссылке, только через классы, но Мартин не хочет даже передавать класс в виде аргумента и изменять его. В результате образуются побочные эффекты (то есть функции вносят изменения вне своих областей видимости).

Похоже, он также выбирает, когда использовать переменные экземпляра, а когда передавать аргументы. Например, почему count, являясь аргументом, не передаётся также в переменную экземпляра? Свою логику Мартин здесь не объясняет. Могу лишь предположить, что count передаётся в класс извне, но почему? Реализация его в виде переменной экземпляра уменьшит число передаваемых аргументов, к чему Мартин вроде как и стремится.

Может, мне нужно рассматривать make как конструктор? Это же неправильно, ведь он возвращает String, а не тип GuessStatisticsMessage. Если бы Мартин создал кейс, где эти переменные экземпляра требовались для чего-то ещё, кроме возвращаемой здесь строки, тогда ладно. Но он этого не делает.

Перейдём ко второму сомнительному моменту. Несмотря на то, что глава посвящена правильному именованию, по иронии они в этом примере не так уж хороши.

Что значит make?

Что значат numberverb и pluralModifier в контексте реализуемой задачи?

Почему функции, которые изменяют состояние, начинаются с there?

Как читающий должен понять, что означает GuessStatisticsMessage, не имея контекста вызывающего кода? И почему это имя представляет странную смесь из глагола и существительного?

Список вопросов можно продолжить.

Если вы мыслите как я, то вам тоже пришлось прочесть весь код, чтобы понять действия make. Этот метод генерирует грамматически корректное предложение и описывает количество вхождений конкретного символа (например, «There are 4 Cs», «There are no Ds», «There is 1 B» и так далее). Ничего более. Для меня всё это проще было бы понять как раз по изначальному коду.

Ну и последний интересный момент. Обратите внимание, что Мартин по факту не сделал код понятнее — даже если использованные им имена и были содержательны, то теперь анализировать нужно больше кода.

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

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

Простое прочтение тела createPluralDependentMessageParts (вот это имя!) не прояснит, что конкретно происходит внутри. Всё дело в том, что Мартин относит блоки if к более низкому уровню абстракции, чем окружающая функция, и поэтому считает, что их нужно инкапсулировать (то есть прятать) в собственные функции.

А теперь представьте, что пытаетесь понять этот код впервые. Вы доходите до блоков if и видите, что они делегируют выполнение другим методам. Остановитесь ли вы на этом? Вряд ли.

Любой, кто дочитал код до этого места, захочет узнать, что находится внутри thereAreNoLetters(). В действительности, вы не сможете понять метод make без понимания того, как меняются verb, number и pluralModifier. Вы можете это угадать, глядя на возврат строки и порядок использования этих полей. Но здесь никак не поймёшь, что "no" заменяет 0, пока не копнёшь глубже. И это очень важная деталь для понимания make, которая запрятана на 2 уровня вглубь.

Такое не интуитивное разделение возникает из-за обострённого восприятия Мартином любых уровней абстракции, когда малейшее отклонение уже вызывает тревогу. Но суть в том, что это его личное видение. Большинство программистов, скорее, прибегнут к небольшому абстрагированию, нежели станут фрагментировать простую функцию на stateful-дерево методов, управляемое побочными эффектами.

Изначальный метод по факту был даже лучше. Намного. Но Мартин прав в том, что его можно было улучшить. Просто он пошёл не тем путём. Вот лично моя «идеальная» версия:

private String generateGuessSentence(char candidate, int count) {
    String verbIs = count == 1 ? "is" : "are";
    String countStr = count == 0 ? "no" : Integer.toString(count);
    String sIfPlural = count == 1 ? "" : "s";
    return String.format(
        "There %s %s %s%s",
        verbIs, countStr, candidate, sIfPlural
    );
}

Вы могли заметить в исходном коде некоторую избыточность, в частности то, что значения String в 2 из 3 случаев одинаковы. Одно из правил Мартина гласит, что нужно стремиться сводить повторы к минимуму, и он сам нарушает его без оснований.

Вы также могли обратить внимание, что инструкции if не делают ничего, кроме присваивания значений переменным. В других примерах Мартин использует тернарные операторы, но почему не здесь? Возможно, он решил, что 3 кейса count будут понятнее, чем присваивание каждого значения с помощью отдельного условия, как сделал я.

Странно, не так ли? Мартин вынес логику в функции, прежде чем её оптимизировать. Я не могу прочесть его мысли, но в книге он почти не упоминает альтернативных подходов к рефакторингу. Он просто показывает свою доработанную версию и поверхностно объясняет, почему она является наилучшей. Я же считаю, что эта тема должна быть более раскрыта.

Ладно, если уж Мартин так хочет сохранить эти три кейса, именование и прочие функции, то я могу подыграть. Но и в этом случае его рефакторинг мог бы быть почище, даже по его собственным стандартам. Вот ещё одна моя версия:

public class GuessStatisticsMessage {
    private char candidate;
    private int count;
 
    public GuessStatisticsMessage(char candidate, int count) {
        this.candidate = candidate;
        this.count = count;
    }
 
    public String make() {
        if (count == 0) {
            return thereAreNoLetters();
        } else if (count == 1) {
            return thereIsOneLetter();
        } else {
            return thereAreManyLetters();
        }
    }
 
    private String thereAreManyLetters() {
        return String.format(
            "There are %s %ss", count, candidate
        );
    }
 
    private String thereIsOneLetter() {
        return String.format(
            "There is 1 %s", candidate
        );
    }
 
    private String thereAreNoLetters() {
        return String.format(
            "There are no %ss", candidate
        );
    }
}

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

Предлагаю подытожить. Если вы вдруг соберётесь прочесть эту книгу, игнорируйте предложенные в ней версии рефакторинга и придумывайте свои. Ну и изложенные автором принципы тоже стоит воспринимать с долей скептицизма.

P.S.

Даже не верится, что это моя первая статья.

Мне захотелось её написать, когда я заметил, как часто книгу «Чистый код» безусловно рекомендуют начинающим. К счастью, мне не доводилось работать с теми, кто пишет код таким образом. Но всё равно становится страшно за будущее отрасли, когда думаешь о том, что молодые разработчики будут на ней учиться.

Воспринимать конкретные мнения по части практик разработки ПО нужно критически. Сфера ПО ещё недостаточно зрелая и постоянно меняется. К тому моменту, когда вы станете экспертом, её ландшафт уже перестроится.

Мне также понравилось недавнее интервью Мартина на канале YouTube «ThePrimeTime». Они говорили с ведущим о разном, и в основном это была беспредметная беседа, хотя Primagen местами ставил под сомнение некоторые принципы «чистого кода».

Я всё же ожидал, что Мартин, спустя 16 лет после выхода его книги, ослабит некоторые из своих наиболее…спорных принципов. Но нет. Его напор только усилился, и это стало для меня последней каплей. Я не имею к нему личных претензий, просто считаю, что недостаточно людей озвучивают слабые места его рекомендаций.

Ну а вас я благодарю вас за чтение и желаю приятного дня!

Комментарии (152)


  1. ohrenet
    30.11.2025 09:10

    Чистая архитектура это прекрасная иллюстрация поговорки "Заставь дурака б-гу молиться - лоб расшибёт".


    1. Nansch
      30.11.2025 09:10

      Кстати, о стекле...


    1. Tishka17
      30.11.2025 09:10

      Это две разные книги


      1. SadOcean
        30.11.2025 09:10

        Но комментарий на самом деле верен для обеих.
        В чистом коде многие рекомендации просто устарели, некоторые особенности синтаксиса не требуется подсвечивать, потому что современные IDE крайне хороши в этом. Многие спорны.
        Аналогично с чистой архитектурой - очень хорошо бы прочитать и понять, в чем причина той или иной рекомендации, почему она приносит пользу. И когда она не бесплатна.


        1. gen1lee
          30.11.2025 09:10

          Очевидно что плохими они были уже на момент написания книги. На С можно переписать намного проще, на фортране и паскале, и даже на java.


          1. SadOcean
            30.11.2025 09:10

            Проще для чего? Для кого?
            Там вполне понятно подается мысль, которую вы можете сформулировать и сами, что у разных проектов - разные требования.
            Проще для одного разработчика - сложнее для 5, невозможно для 50.
            Проще для сервиса нотификаций для диеты - невозможно для большого магазина.
            Проще для вас - сложнее для вашего коллеги (а нужно всем одинаково)
            Сам факт того, что вам нужно распределять разработку уже накладывает ограничения на архитектуру и предьявляет оной требования.


            1. gen1lee
              30.11.2025 09:10

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

              И это одинаково плохо как для небольшого скрипта, так и для огромного монолита на миллионы строк.


              1. SadOcean
                30.11.2025 09:10

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


    1. blind_oracle
      30.11.2025 09:10

      У меня был коллега, который твёрдо следовал принципу "один интерфейс - один метод".

      На Go, где утиная типизация и на котором он раньше писал, оно ещё как-то где-то, хотя тоже очень громоздко.

      Но потом он это и на Rust начал делать, где это ещё менее красиво выходит.

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


      1. DenSigma
        30.11.2025 09:10

        С именем вроде "Invoke". Это настолько частое заблуждение из разряда "Заставь дурака богу молиться", что сам Дядя Боб ругался на такой стиль программирования и ролик даже заснял (не могу сейчас найти).

        Этот "принцип" никаким боком к идеям Дяди Боба не относятся.


      1. DandyDan
        30.11.2025 09:10

        Так Rust чуть менее, чем полностью состоит из трейтов, где только один метод.
        Исключения - трейты, где вообще нет методов.


        1. blind_oracle
          30.11.2025 09:10

          Да как-то не особо, вот стандартные трейты Read, Write, Seek, BufRead - там по 5-7 методов. Трейт Iterator - 76 методов! :) 75 из них автоматически реализованы, правда. В tokio всё аналогично.

          То, о чём я говорю - когда человек делает какой-то объект в модуле и приватные трейты вида:

          trait StoresFoo {
            fn store(&self, foo: &str);
          }
          
          trait RetrievesFoo {
            fn retrieve(&self, foo: &str);
          }
          
          trait DeletesFoo {
            fn delete(&self, foo: &str);
          }

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

          В итоге всё это раскидано по модулю и понять логику крайне сложно. Найти какие методы есть у объекта просто посмотрев на его impl тоже сложно, ибо это всё разамазано по многим страницам.


  1. mapchelka
    30.11.2025 09:10

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


  1. tenzink
    30.11.2025 09:10

    Если честно, все варианты в тексте заставляют скакать взглядом вверх-вниз по коду с мысленным "inline", чтобы понять какая-же получается строка на выходе. Интересно, почему не так?

    private String generateGuessSentence(char candidate, int count) {
        
        if (count == 0) {
            return String.format("There are no %ss", candidate);
        } else if (count == 1) {
            return String.format("There is 1 %s", candidate);
        } else {
            return String.format("There are %d %ss", count, candidate);
        }
    }


    1. novoselov
      30.11.2025 09:10

      import static java.lang.String.format;
      
      public String generateGuessSentence(char candidate, int count) {
          return switch (count) {
              case 0 -> format("There are no %ss", candidate);
              case 1 -> format("There is 1 %s", candidate);
              default -> format("There are %d %ss", count, candidate);
          };
      }


      1. artptr86
        30.11.2025 09:10

        String generateGuessSentence(char candidate, int count) {
            String pattern = "{0,choice,0#There are no {1}s|1#There is 1 {1}|1<There are {0} {1}s}";
            return MessageFormat.format(pattern, count, candidate);
        }


        1. Legomegger
          30.11.2025 09:10

          дада мы поняли какой вы крутой, но тут о читаемости и лакончиности идет речь


          1. venanen
            30.11.2025 09:10

            Попросил чатгопоту написать еще более усложненный и нечитаемый пример. Джаву не знаю, но корректность не ручаюсь

            Скрытый текст
            private static final long MAGIC_MASK = 0xCAFEBABECAFEL; // потому что можем
            private static final int COUNT_SHIFT = 42;              // почему 42? Потому что смысл жизни
            private static final int CHAR_MASK = 0xFF;              // кандидат как байт
            private static final int NO_CASE = 0b00;
            private static final int ONE_CASE = 0b01;
            private static final int MANY_CASE = 0b10;
            
            private String generateGuessSentence(char candidate, int count) {
                // Сливаем данные в “единый регистр”, как будто это важно
                long packed = ((long) (count & 0xFFFFFFFF) << COUNT_SHIFT)
                                ^ (candidate & CHAR_MASK)
                                ^ MAGIC_MASK;
            
                // Извлекаем обратно (по факту — просто разворачиваем бессмысленную обёртку)
                int unpackedCount = (int) ((packed ^ MAGIC_MASK) >>> COUNT_SHIFT);
                char unpackedChar = (char) ((packed ^ MAGIC_MASK) & CHAR_MASK);
            
                // Определяем состояние через сверхсложную таблицу истинности
                int caseType =
                        unpackedCount == 0 ? NO_CASE :
                        unpackedCount == 1 ? ONE_CASE :
                                             MANY_CASE;
            
                // Вместо простого switch — битовые пляски
                StringBuilder sb = new StringBuilder(64);
                sb.append(((caseType & ONE_CASE) != 0)
                        ? "There is 1 "
                        : ((caseType & MANY_CASE) != 0)
                            ? "There are "
                            : "There are no ");
            
                // Разные ветки дописывают по-разному
                if (caseType == MANY_CASE) {
                    // Симулируем «оптимизацию памяти» — формируем число вручную
                    sb.append(unpackedCount);
                    sb.append(' ');
                    sb.append(unpackedChar);
                    sb.append('s');
                } else if (caseType == ONE_CASE) {
                    sb.append(unpackedChar);
                } else { // NO_CASE
                    sb.append(unpackedChar);
                    sb.append('s');
                }
            
                return sb.toString();
            }
            

            Но комментарии мне особо понравились:
            Краткое объяснение (которое не делает ситуацию лучше)

            • Мы запаковываем count и candidate в одно 64-битное число с помощью XOR и сдвигов — без причины.

            • Потом распаковываем их обратно, создавая видимость какой-то бинарной сериализации.

            • Вместо обычного switch(count) — архитектура на базе битовых масок.

            • MAGIC_MASK используется вообще ради красоты.

            • Логика усложнена всем, чем возможно, чтобы будущие разработчики поседели.


            1. aakolov
              30.11.2025 09:10

              АСТАНАВИТЕСЬ! Ведь на этом коде когда-нибудь будет учиться какой-нибудь GPT!


              1. Okeu
                30.11.2025 09:10

                Ведь на этом коде когда-нибудь будет учиться какой-нибудь GPT!

                думаете ему это навредит?)


              1. goldexer
                30.11.2025 09:10

                Ахахах вот тут всхохотнул, ведь и правда забавно!


        1. DandyDan
          30.11.2025 09:10

          Плюсанул.
          Конечно, с 19+ идиотами мне не совладать, но как уж как мог.


      1. ADEXITUM
        30.11.2025 09:10

        Это же DRY! Не повтори слово "there" аж три раза! Создай целый билдер для строки из трёх с половиной слов!


        1. Sau
          30.11.2025 09:10

          Сначала билдер для строки из произвольного количества слов. Причём словами могут быть любые типы данных.


          1. d3d11
            30.11.2025 09:10

            и фабрику билдеров строк


      1. unC0Rr
        30.11.2025 09:10

        Заодно возможность локализации появилась! Хотя правильнее count передавать в движок локализации, т.к. у разных языков разные правила.


    1. Yevvv
      30.11.2025 09:10

      private String generateGuessSentence(char candidate, int count) {
          if (count == 0) {
              return String.format("There are no %ss", candidate);
          }
          if (count == 1) {
              return String.format("There is 1 %s", candidate);
          }
      
          return String.format("There are %d %ss", count, candidate);
      }


      1. jin_x
        30.11.2025 09:10

        private String formatCountSentence(char letter, int count) {
            if (count <= 1) {
                if (count == 1) {
                    return String.format("There is 1 %s", letter);
                }
                return String.format("There are no %ss", letter);
            }
            return String.format("There are %d %ss", count, letter);
        }

        Меньше ветвлений для значений > 1, отрицательное значение обрабатывается как ноль (это можно сделать контрактом).


      1. jin_x
        30.11.2025 09:10

        Мысль убрать else, конечно, хорошая, но глобально это не повлияет на результат. Оптимизатор всё равно уберёт else (и даже если отключить оптимизацию, то может быть будет лишний прыжок после return, который всё равно недостижим). При этом какой код читабельнее — вопрос спорный.


        1. Andrey_Solomatin
          30.11.2025 09:10

          Для Питона я решил делать без елсе, так как на это есть линтер и можно сделать консистентно для всего проекта.

          Видел ещё предложение иметь только один return в функции, так проще следить за потоком управления, но получается больше бойлерплейта. Надо будет попробовать на каком-нибудь пет проекте.


          1. Tishka17
            30.11.2025 09:10

            Я все ещё считаю что эта рекомендация линтера - плохая. Концептуально я вижу два разных кода
            а) выбор из нескольких вариантов
            б) особые случаи и дефолт

            В варианте а я бы хотел чтобы разные варианты были оформлены одинаково и в целом будут достаточно похожи. И else тут помогает сохранять отступы. В вараинте b дефолтный кейс будет и так сильно выделяться и тут имеет место early return и отстутствие else.

            Линтер же не видит разницы между двумя ситуациями и заставляет убирать эту семантику в угоду своим правилам. В реальности конечно же все пишут как попало и о семантике не думают, но тем не менее.


            1. Andrey_Solomatin
              30.11.2025 09:10

              Линтер же не видит разницы между двумя ситуациями и заставляет убирать эту семантику в угоду своим правилам. В реальности конечно же все пишут как попало и о семантике не думают, но тем не менее.

              Поэтому я и выбираю консистентность, ведь её можно реально достичь.