
Когда я в прошлом году услышал, что Дядя Боб планирует выпустить вторую редакцию «Чистого кода», то был восхищён, а это для меня редкость. Я считал, что и первый выпуск был хорош, хотя сам читаю редко.
Возможно, причиной восторга стала мысль о том, что я смогу снова разнести его примеры кода, как сделал в своей первой статье.
Или же меня обнадёжило данное Мартином обещание доработать руководства из предыдущей книги. Знаете, то удовольствие, когда читаешь заметки к долгожданным патчам для рабочего ПО.
А может, это была глубинная надежда, что кто-то, наконец, пересмотрел его идеи и осознал необходимость изменения подхода Мартина к написанию «чистого кода». Всё же это была самая жестокая критика первой редакции книги с момента её публикации более 17 лет назад.
Несмотря на весь свой цинизм и любовь постебаться, я искренне уважаю тех, кто может признать свои ошибки и взглянуть на вещи по-новому. Я испытываю глубокую радость, когда мой посыл доходит до умов людей и меняет их взгляд на вопросы, в которых они грубо заблуждаются (хотя порой мне кажется, что мой напористый подход может, наоборот, этому мешать).
Так что представьте, каково было моё разочарование, когда я потратил $60 на электронную версию этой книги, в которой Боб не просто не изменил своей позиции по большинству спорных практик, но и продолжил топить за них ещё круче!
Невероятно!
Но я забегаю вперёд…
Плюсы
Если смотреть в общем, то я согласен с большинством взглядов Дяди Боба.
Он говорит о том, что профессионалы должны предотвращать загнивание ПО, активно применяя принципы чистого кода, даже когда это замедляет вас в моменте.
Мне особенно понравилась его гипотетическая шутка на больную тему о том, как код стал настолько плохим, что пришлось создать отдельный рабочий процесс для его переписывания. Естественно, это ведёт к появлению багов и истощающим издержкам из-за необходимости согласования переписанной версии с легаси-кодом в плане новых фич и исправлений ошибок.
Мартин говорит о важности чистого кода не только из соображений продуктивности, но и по этическим причинам, имея в виду вред, который ошибки в софте могут нанести нашему программно-зависимому обществу.
Его приверженность к написанию чистого кода явно происходит из печального опыта. И первую главу стоит прочесть, хотя бы ради знакомства с его принципами.
Эти принципы архитектуры и проектирования известны как SOLID, ничего нового, и если вас интересуют тонкости создания хорошей архитектуры, то я рекомендую почитать эти разделы тоже.
Видно, что вторая редакция была обновлена под современные реалии. В ней затрагивается тема LLM и их роли в разработке ПО. Боб даже привлекает к работе Grok и Copilot, чтобы сравнить свои версии рефакторинга с теми, которые предлагает ИИ.
Особенно же мне понравилось, что он активно старается выражать свои идеи с использованием современных языков и конструкций. К примеру, он не придерживается исключительно Java, а также обращается к Golang, Python и JavaScript. И даже при работе с Java он задействует более современные конструкты вроде лямбд, потоков, классов Record и сопоставлений с шаблоном. Мартин определённо освоил м��огие принципы функционального программирования, и это приятно видеть.
Он разбивает каждый процесс рефакторинга на понятные этапы и подробно объясняет свой ход мысли между ними. Меня бесит, когда эксперты сразу переходят к итоговым решениям и обосновывают их постфактум. Хорошо, что Боб так не делает.
Обсуждая каждую свою идею, которая может показаться спорной, он старается развеять возможные контраргументы других людей. В процессе чтения я нередко замечал сомнительные моменты в его нити мысли, и буквально в следующем абзаце он приводил для них доводы. Это признак человека, который умеет вести технические дискуссии, и в этом плане книга определённо лучше первого издания.
Причём в последнем её разделе фактически приводится стенограмма его нашумевшего диалога с Джоном Остерхаутом, который ставил под сомнение принципы чистого кода. Неплохой бонус.
Всё, что вам могло нравиться в первой редакции, здесь тоже есть, причём этого больше.
Минусы
Но вернулось и всё то, что вы ругали — с лихвой.
Мартин повторно использовал некоторые примеры из первой книги, в частности, отвратительные классы GuessStatisticsMessage и PrimeGenerator. Он до сих пор считает, что они достаточно чисты для упоминания в подобном руководстве.
Но вместо того, чтобы ворошить старый код, я взгляну на один из новых примеров. Ниже показан первый крупный пример из книги, конкретно из второй главы «Clean That Code!». С помощью ИИ Мартин намеренно написал грязный код в целях демонстрации:
public class FromRoman {
public static int convert(String roman) {
if (roman.contains("VIV") ||
roman.contains("IVI") ||
roman.contains("IXI") ||
roman.contains("LXL") ||
roman.contains("XLX") ||
roman.contains("XCX") ||
roman.contains("DCD") ||
roman.contains("CDC") ||
roman.contains("MCM")) {
throw new InvalidRomanNumeralException(roman);
}
roman = roman.replace("IV", "4");
roman = roman.replace("IX", "9");
roman = roman.replace("XL", "F");
roman = roman.replace("XC", "N");
roman = roman.replace("CD", "G");
roman = roman.replace("CM", "O");
if (roman.contains("IIII") ||
roman.contains("VV") ||
roman.contains("XXXX") ||
roman.contains("LL") ||
roman.contains("CCCC") ||
roman.contains("DD") ||
roman.contains("MMMM")) {
throw new InvalidRomanNumeralException(roman);
}
int[] numbers = new int[roman.length()];
int i = 0;
for (char digit : roman.toCharArray()) {
switch (digit) {
case 'I' -> numbers[i] = 1;
case 'V' -> numbers[i] = 5;
case 'X' -> numbers[i] = 10;
case 'L' -> numbers[i] = 50;
case 'C' -> numbers[i] = 100;
case 'D' -> numbers[i] = 500;
case 'M' -> numbers[i] = 1000;
case '4' -> numbers[i] = 4;
case '9' -> numbers[i] = 9;
case 'F' -> numbers[i] = 40;
case 'N' -> numbers[i] = 90;
case 'G' -> numbers[i] = 400;
case 'O' -> numbers[i] = 900;
default -> throw new InvalidRomanNumeralException(roman);
}
i++;
}
int lastDigit = 1000;
for (int number : numbers) {
if (number > lastDigit) {
throw new InvalidRomanNumeralException(roman);
}
lastDigit = number;
}
return Arrays.stream(numbers).sum();
}
public static class InvalidRomanNumeralException extends RuntimeException {
public InvalidRomanNumeralException(String roman) {
}
}Вот, что он делает:
Проверяет входную строку на наличие недопустимых последовательностей знаков в римских числах.
Заменяет двухзначные римские числа, включающие вычитание, на специальные однозначные символы.
Проверяет строку на присутствие излишних повторений определённых знаков («IIII» должно записываться как «IV»).
Перебирает каждый символ и преобразует его в десятичный эквивалент, в том числе полученные ранее кастомные символы, после чего помещает результат в массив.
Следит, чтобы числа в полученном массиве располагались не в порядке возрастания (выявляя недопустимые записи вроде «VX»).
Складывает все числа и возвращает результат.
В течение оставшейся части главы Боб пошагово описывает предлагаемый им рефакторинг. Я эти шаги показывать не стану. Если интересно, можете почитать книгу.
Примечание. Хочу отметить, что это довольно хитрый пример. Если вы заведомо не знаете, какой алгоритм окажется оптимальным, то будет сложно определить, какой рефакторинг здесь необходим — очистительный или алгоритмический. Первый ведёт к сокращению повторов, более лаконичному выражению синтаксиса, выносу функций или переменных и так далее. А второй означает пересмотр самой логики в поиске более простого решения или оптимизации.
Вот версия рефакторинга от Дяди Боба:
public class FromRoman2 {
private String roman;
private List<Integer> numbers = new ArrayList<>();
private int charIx;
private char nextChar;
private Integer nextValue;
private Integer value;
private int nchars;
Map<Character, Integer> values = Map.of(
'I', 1,
'V', 5,
'X', 10,
'L', 50,
'C', 100,
'D', 500,
'M', 1000);
public FromRoman2(String roman) {
this.roman = roman;
}
public static int convert(String roman) {
return new FromRoman2(roman).doConversion();
}
private int doConversion() {
checkInitialSyntax();
convertLettersToNumbers();
checkNumbersInDecreasingOrder();
return numbers.stream().reduce(0, Integer::sum);
}
private void checkInitialSyntax() {
checkForIllegalPrefixCombinations();
checkForImproperRepetitions();
}
private void checkForIllegalPrefixCombinations() {
checkForIllegalPatterns(
new String[]{"VIV", "IVI", "IXI", "IXV", "LXL", "XLX",
"XCX", "XCL", "DCD", "CDC", "CMC", "CMD"});
}
private void checkForImproperRepetitions() {
checkForIllegalPatterns(
new String[]{"IIII", "VV", "XXXX", "LL", "CCCC", "DD", "MMMM"});
}
private void checkForIllegalPatterns(String[] patterns) {
for (String badString : patterns)
if (roman.contains(badString)) throw new InvalidRomanNumeralException(roman);
}
private void convertLettersToNumbers() {
char[] chars = roman.toCharArray();
nchars = chars.length;
for (charIx = 0; charIx < nchars; charIx++) {
nextChar = isLastChar() ? 0 : chars[charIx + 1];
nextValue = values.get(nextChar);
char thisChar = chars[charIx];
value = values.get(thisChar);
switch (thisChar) {
case 'I' -> addValueConsideringPrefix('V', 'X');
case 'X' -> addValueConsideringPrefix('L', 'C');
case 'C' -> addValueConsideringPrefix('D', 'M');
case 'V', 'L', 'D', 'M' -> numbers.add(value);
default -> throw new InvalidRomanNumeralException(roman);
}
}
}
private boolean isLastChar() {
return charIx + 1 == nchars;
}
private void addValueConsideringPrefix(char p1, char p2) {
if (nextChar == p1 || nextChar == p2) {
numbers.add(nextValue - value);
charIx++;
} else
numbers.add(value);
}
private void checkNumbersInDecreasingOrder() {
for (int i = 0; i < numbers.size() - 1; i++)
if (numbers.get(i) < numbers.get(i + 1))
throw new InvalidRomanNumeralException(roman);
}
public static class InvalidRomanNumeralException extends RuntimeException {
public InvalidRomanNumeralException(String roman) {
super("Invalid Roman numeral: " + roman);
}
}
}Похоже, он ничему не научился. Что делает этот код:
Проверяет наличие в строке недопустимых последовательностей числовых символов (неправильных префиксов и лишних повторений).
Перебирает символы римской записи и в каждой итерации:
Определяет текущую букву и следующую (если следующая есть).
Если текущая буква «I», «X» либо «C», проверяет, является ли она префиксом для следующей буквы. Если да, вычитает значение текущей буквы из значения следующей, добавляет его в список и при очередной итерации следующую букву пропускает.
Если следующей буквы нет, просто добавляет текущую в список.
В завершение проверяет, чтобы в списке никакое число не было больше предшествующего ему.
Первым делом он взял чистую функцию и вместо явной передачи аргументов превратил её в метод экземпляра с атрибутами. В тот раз он сделал то же самое, но теперь он приводит обоснования, которые я прокомментирую позже.
Далее всё, как и в прошлый раз — он зачем-то реализовал декомпозицию методов.
К примеру, метод doConversion вызывает три других метода, но они не ведут к уменьшению повторений и не делают понятнее сам метод. Естественно, выглядит всё как высокоуровневый список шагов, но если код будут читать люди без технического бэкграунда, то такой подход лишь затрудняет понимание того, КАК происходит преобразование.
Когда читающий дойдёт до метода doConversion, он уже будет догадываться, что внутри творится безобразие. Слово «conversion» это проясняет, поэтому произвольное вынесение этих деталей в отдельные функции только впустую тратит время. Для того, чтобы понять, как работает всё это преобразование, мне нужно углубиться на три метода, каждый из которых содержит слово «convert». Зачем?
Если придание коду эстетичности делает его «мутным», то лучше пожертвовать эстетикой ради ясности.
Признаю, в этом примере всё не так уж плохо. После прочтения каждого метода я счёл их имена вполне интуитивными. Но вы поняли, в чём суть?
После того, как я прочёл каждый метод.
Поскольку аргументов нет, как нет и гарантии чистоты (о чём говорит избыток переменных экземпляра), мне приходится слепо верить, что имя каждого метода правильно описывает его действие без каких-либо побочных эффектов.
Если вы, как и Дядя Боб, смотрите через призму хиндсайта, то это не проблема. Но я считаю, что код должен быть равноценно понятным как для тех, кто уже с ним знаком, так и для тех, кто видит его впервые. А одним из важнейших элементов читаемости является уверенность в том, что каждый метод делает именно то, о чём заявляет. Без уверенности читающие будут вынуждены прочитывать все эти методы, и польза от созданной Мартином абстракции будет сведена на нет, а издержки останутся.
Очевидно, что чистые функции не заслуживают доверия по умолчанию, но это не бинарный вопрос. Чистота больше способствует детерминизму и самодостаточности, что, в свою очередь, способствует большему доверию.
Вот что происходит, когда вы оптимизируете код с акцентом на поверхностную читаемость (хорошие имена методов, скрывающие сложность), позволяя расцветать непредсказуемому поведению, ведущему к «запутанности состояний».
Я мог бы построчно перебрать весь код и объяснить каждый момент, на котором у меня возникало недоумение типа «да ну?» или «что, серьёзно?». Но наверняка будет нагляднее, если я просто приведу собственную версию рефакторинга с сохранением общей логики Боба.
public class FromRoman3 {
private static final Map<Character, Integer> ROMAN_NUMERALS = Map.of(
'I', 1,
'V', 5,
'X', 10,
'L', 50,
'C', 100,
'D', 500,
'M', 1000);
private static final Map<Character, Character> NUMERAL_PREFIXES = Map.of(
'V', 'I',
'X', 'I',
'L', 'X',
'C', 'X',
'D', 'C',
'M', 'C'
);
private static final String[] ILLEGAL_PREFIX_COMBINATIONS = new String[]{
"VIV", "IVI", "IXI", "IXV", "LXL", "XLX",
"XCX", "XCL", "DCD", "CDC", "CMC", "CMD"
};
private static final String[] IMPROPER_REPETITIONS = new String[]{
"IIII", "VV", "XXXX", "LL", "CCCC", "DD", "MMMM"
};
public static int convert(String roman) {
if (containsIllegalPatterns(roman, ILLEGAL_PREFIX_COMBINATIONS) ||
containsIllegalPatterns(roman, IMPROPER_REPETITIONS)) {
throw new InvalidRomanNumeralException(roman);
}
List<Integer> numbers = new ArrayList<>();
int i = 0;
while (i < roman.length()) {
char currentLetter = roman.charAt(i);
char nextLetter = i == roman.length() - 1 ? 0 : roman.charAt(i + 1);
if (!ROMAN_NUMERALS.containsKey(currentLetter)) {
throw new InvalidRomanNumeralException(roman);
} else if (NUMERAL_PREFIXES.getOrDefault(nextLetter, (char) 0) == currentLetter) {
int num = ROMAN_NUMERALS.get(nextLetter) - ROMAN_NUMERALS.get(currentLetter);
numbers.add(num);
i += 2;
} else {
int num = ROMAN_NUMERALS.get(currentLetter);
numbers.add(num);
i += 1;
}
}
if (containsIncreasingNumbers(numbers)) {
throw new InvalidRomanNumeralException(roman);
}
return numbers.stream().mapToInt(Integer::intValue).sum();
}
private static boolean containsIllegalPatterns(String roman, String[] patterns) {
for (String badString : patterns)
if (roman.contains(badString)) return true;
return false;
}
private static boolean containsIncreasingNumbers(List<Integer> numbers) {
for (int i = 0; i < numbers.size() - 1; i++)
if (numbers.get(i) < numbers.get(i + 1)) return true;
return false;
}
public static class InvalidRomanNumeralException extends RuntimeException {
public InvalidRomanNumeralException(String roman) {
super("Invalid Roman numeral: " + roman);
}
}
}Первым делом я заменил все переменные экземпляра локальными, передаваемыми через аргументы. Одно только это сделало код куда более читаемым.
Затем я извлёк каждое явное упоминание римских цифр в константы, отчасти из соображений производительности. Основная же цель была в том, чтобы в итоге работать с цифрами через имена констант, а не имена функций, как у Боба.
После этого я перекроил структуру функции.
Я превратил функцию преобразования в самую жирную во всём классе. Вы же залезли в эти дебри, чтобы узнать, как работает числовое преобразование — так вот оно, во всей своей красе.
Я изменил checkForIllegalPatterns на containsIllegalPatterns и перенёс механизм выброса исключений в основную функцию. Мне показалось, так будет прозрачнее. Слово «check» никак не проясняет, что происходит, если проверка проваливается, а «contains», вместе с остальной частью сигнатуры, чётко говорит о том, что делает функция.
Я также изменил checkNumbersInDecreasingOrder на containsIncreasingNumbers и вынес исключение наружу по аналогии с этапами перед проверкой. Но здесь же нет повторов, так зачем я сохранил этот метод? По двум причинам:
Его можно понять сам по себе.
Пост-проверочный этап — это не главная цель функции конвертации.
Самым проблемным оказался цикл преобразования. Боб написал его так, что если я просто встрою addValueConsideringPrefix, то возникнет много повторений. Мне же хотелось подчистить этот момент, не меняя его алгоритм.
Первой мыслью было использовать Map<Character, Character[]> для сопоставления каждой префиксной буквы с потенциальной следующей. Но в ходе реализации этой задумки я понял, что могу просто реверсировать сопоставление, сопоставляя каждую букву с её префиксом. И поскольку такое реверсивное сопоставление было уникальным, мне не потребовалось использовать Character[] в качестве типа значения.
После этого осталось лишь сохранить всю логику внутри цикла (который я изменил на while, чтобы сделать инкрементацию индекса более явной).
Естественно, в результате потерялась лаконичность сопоставления с шаблоном, но отсутствие абстрагирования того стоило.
К слову, Боб не поленился привести обширный набор тестов, который оказался весьма кстати при таком витиеватом алгоритме. Так что я не менее уверен в своём коде, чем он в своём.
import fromRoman.FromRoman.InvalidRomanNumeralException;
import org.junit.jupiter.api.Test;
import static fromRoman.FromRoman.convert;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertThrows;
public class FromRomanTest {
@Test
public void valid() throws Exception {
assertThat(convert(""), is(0));
assertThat(convert("I"), is(1));
assertThat(convert("II"), is(2));
assertThat(convert("III"), is(3));
assertThat(convert("IV"), is(4));
assertThat(convert("V"), is(5));
assertThat(convert("VI"), is(6));
assertThat(convert("VII"), is(7));
assertThat(convert("VIII"), is(8));
assertThat(convert("IX"), is(9));
assertThat(convert("X"), is(10));
assertThat(convert("XI"), is(11));
assertThat(convert("XII"), is(12));
assertThat(convert("XIII"), is(13));
assertThat(convert("XIV"), is(14));
assertThat(convert("XV"), is(15));
assertThat(convert("XVI"), is(16));
assertThat(convert("XIX"), is(19));
assertThat(convert("XX"), is(20));
assertThat(convert("XXX"), is(30));
assertThat(convert("XL"), is(40));
assertThat(convert("L"), is(50));
assertThat(convert("LX"), is(60));
assertThat(convert("LXXIV"), is(74));
assertThat(convert("XC"), is(90));
assertThat(convert("C"), is(100));
assertThat(convert("CXIV"), is(114));
assertThat(convert("CXC"), is(190));
assertThat(convert("CD"), is(400));
assertThat(convert("D"), is(500));
assertThat(convert("CDXLIV"), is(444));
assertThat(convert("DCXCIV"), is(694));
assertThat(convert("CM"), is(900));
assertThat(convert("M"), is(1000));
assertThat(convert("MCM"), is(1900));
assertThat(convert("MCMXCIX"), is(1999));
assertThat(convert("MMXXIV"), is(2024));
}
@Test
public void invalid() throws Exception {
assertInvalid("ABE"); // I added this one
assertInvalid("IIII");
assertInvalid("VV");
assertInvalid("XXXX");
assertInvalid("LL");
assertInvalid("CCCC");
assertInvalid("DD");
assertInvalid("MMMM");
assertInvalid("XIIII");
assertInvalid("LXXXX");
assertInvalid("DCCCC");
assertInvalid("VIIII");
assertInvalid("MCCCC");
assertInvalid("VX");
assertInvalid("IIV");
assertInvalid("IVI");
assertInvalid("IXI");
assertInvalid("IXV");
assertInvalid("VIV");
assertInvalid("XVX");
assertInvalid("XVV");
assertInvalid("XIVI");
assertInvalid("XIXI");
assertInvalid("XVIV");
assertInvalid("LXL");
assertInvalid("XLX");
assertInvalid("XCX");
assertInvalid("XCL");
assertInvalid("CDC");
assertInvalid("DCD");
assertInvalid("CMC");
assertInvalid("CMD");
assertInvalid("MCMC");
assertInvalid("MCDM");
}
private void assertInvalid(String r) {
assertThrows(InvalidRomanNumeralException.class, () -> convert(r));
}
}Боб утверждает, что его код чище оригинала. Но это не так. Мне даже нравится открытость изначальной функции. Она ничего не разбивает на части, но при этом почти не содержит ветвлений, что делает её более понятной. Это преобразование из двухзначных чисел в однозначные даже показалось мне…элегантным, что ли?
Серьёзно. Всё благодаря тому, что цикл преобразования чисел в десятичную запись почти не содержит логики. Его вполне можно представить таблицей. Боб отмечает, что изначальный код не проходит все тестовые кейсы, но я смог это исправить внесением лишь незначительных изменений.
Это не единственный пример, в котором Боб перегибает с декомпозицией. Но я бы писал статью до ночи, если бы разбирал каждый.
Жёсткий разнос
После приведённого выше рефакторинга он пишет:
Функциональные программисты могли ужаснуться от того, что функции не «чистые». Но в действительности функция преобразования чиста настолько, насколько это возможно. Остальные же — это лишь небольшие вспомогательные методы, которые работают внутри одного вызова этой общей чистой функции. Удобство используемых переменных экземпляра в том, что они позволяют отдельным методам общаться, не прибегая к передаче аргументов. Это показывает, что одним из удачных применений объекта является возможность реализовать взаимодействие внутри чистой функции через переменные его экземпляра.
И в главе 7 «Clean Functions» он утверждает, что следующие три варианта сигма-функции одинаково грязные:
public static double sigma(double… ns) {
var mu = mean(ns);
var deviations = Arrays.stream(ns)
.map(x->(x-mu)*(x-mu))
.boxed().mapToDouble(x->x);
double variance = deviations.sum() / ns.length;
return Math.sqrt(variance);
}public static double sigma(double… ns) {
double mu = mean(ns);
double variance = 0;
for (double n : ns) {
var deviation = n - mu;
variance += deviation * deviation;
}
variance /= ns.length;
return Math.sqrt(variance);
}public static double sigma(double… ns) {
return new SigmaCalculator(ns).invoke();
}
private static class SigmaCalculator {
private double[] ns;
private double mu;
private double variance = 0;
private double deviation;
public SigmaCalculator(double… ns) {
this.ns = ns;
}
public double invoke() {
mu = mean(ns);
for (double n : ns) {
deviation = n - mu;
variance += deviation * deviation;
}
variance /= ns.length;
return Math.sqrt(variance);
}
}Вы можете задуматься, как он мог прийти к такому выводу?
Всё просто. Он неверно трактует само понятие «чистая функция». То есть он вроде бы и понимает, о чём оно, но потом говорит:
Как создаётся чистая функция? Ничего сложного. Просто не нужно менять значения никаких переменных. Или, перефразируя известную реплику из кинофильма «Дорогая мамочка!»: «Никаких присваиваний, никогда!». Ещё можно сказать просто: «Чистые функции иммутабельны».
На что он опирается?
На книгу «Чистая архитектура. Искусство разработки программного обеспечения», Роберта С. Мартина.
Итого Дяд�� Боб нарушил два принципа функционального программирования.
Первый — это принцип чистоты, который означает отсутствие побочных эффектов, то есть функции не должны вносить изменения вне своей области видимости.
Второй — это иммутабельность, то есть отсутствие повторных присваиваний переменных и изменений существующих значений.
Можно следовать первому, нарушая второй.
Вроде бы ничего сверхсерьёзного, но Мартин во многом строит на этом свои доводы. Поскольку инструкции присваивания в его понимании «грязные», он приравнивает в этом смысле два первых примера к третьему.
Теперь у нас есть переменные экземпляра и всевозможные манипуляции с ними. Тем не менее сигма-функция чиста. Ни одна из «грязных» операций не выпирает за её пределы. Суть в том, что чистота — это внешняя характеристика функции, а не внутренняя. Неважно, насколько запачкана функция внутри — она будет оставаться чистой, пока вся эта грязь скрыта от внешних наблюдателей (включая другие потоки).
Он коверкает определение чистой функции, по сути, применяя её только с позиции публичных методов.
Я в полном негодовании.
Принцип чистоты должен применяться ко всем функциям, а не только ко внешним. Его смысл — упростить понимание кода, включая детали реализации.
Боб утверждает, что передача переменных экземпляра несёт меньше издержек, чем передача аргументов функции. И не удивительно. Ведь он считает, что присвоил методам имена настолько точные и описательные, что аргументы просто излишни.
Представим, что кто-нибудь решает залезть в метод, чтобы понять деталь его реализации.
Он видит, что метод усыпан ссылками на переменные экземпляра, и задумывается, какие значения у этих переменных были раньше.
Возможно, этот метод зависит от того, чтобы конкретные переменные инициализировались определённым образом в определённых случаях.
Возможно, для правильного вызова этого метода сначала нужно вызвать какие-то другие методы.
Иными словами, каждый метод зависит от общего состояния.
Неужели Боб ожидает, что люди будут прочитывать каждый метод, прежде чем начнут разбирать тот, который хотят понять. Не лишает ли это абстракцию её изначального смысла?
Боб заменил издержки, связанные с аргументами метода, ещё большей проблемой общего состояния. Тот факт, что это состояние существует только внутри конкретного экземпляра класса, не делает такой подход приемлемым.
Чистые функции читаются как контракты (и аргументы являются частью такого контракта). Они получают конкретный ввод, в каком бы состоянии он ни находился, выполняют с ним определённые операции и каждый раз выводят один и тот же результат. Это упрощает их понимание в виде отдельных компонентов и уменьшает когнитивную нагрузку. Единственное «общее» состояние, если его вообще можно так назвать, это то, что функция более высокого уровня передаёт в виде аргументов в своих вызовах.
Stateful-методы без аргументов просят принимать их имена за чистую монету, как бы умышленно отвлекая вас от внутренней неразберихи. С каждым очередным шагом внутрь метода вам нужно добавлять в свой ментальный граф выполнения дополнительный узел (что вы в любом случае делаете), но при этом ещё и учитывать состояние каждой переменной экземпляра между вызовами функции.
Это может не создавать особых проблем, если общее состояние возникает только между двух функций, вызываемых последовательно. Но что случится, если оно уйдёт на четыре уровня вглубь в четырёх разных цепочках вызовов? У Боба, должно быть, чертовски крутая оперативная память, чтобы удерживать и обрабатывать в голове всю эту информацию.
Вы можете поспорить, что в плане когнитивной нагрузки между передачей локальных аргументов и ссылками на переменные экземпляра нет ощутимой разницы. Но она есть и связана с областью видимости. Делая область переменной максимально узкой, вы сокращаете участок, в течение которого читающему код нужно удерживать её в памяти для рассуждения.
Будь это не так, вы могли бы просто расширять область видимости каждой переменной до уровня класса, и никаких проблем. Но даже Боб не стал бы писать такой код.
Так что он либо не в курсе этих когнитивных издержек, либо же просто их СИЛЬНО недооценивает. Я склоняюсь ко второму, но это в любом случае должно быть стыдно.
Заключение
Моё мнение с прошлого раза не изменилось.
Следуйте рекомендациям Мартина в общих чертах, но примеры игнорируйте. Если вы ожидаете найти в них улучшения, то их там нет.
Признаю, в этой редакции Боб менее догматичен в своих примерах рефакторинга, и это радует. Но я также не думаю, что при виде такого плачевного «улучшенного» кода занять позицию «пусть каждый останется при своём мнении» нельзя. Понимаю, звучит грубо, но вежливо это никак не выразишь.
Ну а вас я снова благодарю за чтение и желаю приятного дня!
P.S.
Прошло пять дней с момента размещения книги на Amazon, и я ещё не видел по ней ни одной статьи или видео. Какое-то радиомолчание. Даже сам Дядя Боб не продвигает своё детище. Мне даже немного не по себе.
Она так одинока.

