30 June

Вероятно, хватит рекомендовать «Чистый код»

ProgrammingJavaReading room
Translation
Original author: quarantine 'em
Возможно, мы никогда не сможем прийти к эмпирическому определению «хорошего кода» или «чистого кода». Это означает, что мнение одного человека о мнении другого человека о «чистом коде» обязательно очень субъективно. Я не могу рассматривать книгу Роберта Мартина «Чистый код» 2008 года с чужой точки зрения, только со своей.

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

В третьей главе «Функции» Мартин даёт различные советы для написания хороших функций. Вероятно, самый сильный совет в этой главе состоит в том, что функции не должны смешивать уровни абстракции; они не должны выполнять задачи как высокого, так и низкого уровня, потому что это сбивает с толку и запутывает ответственность функции. В этой главе есть и другие важные вещи: Мартин говорит, что имена функций должны быть описательными и последовательными, и должны быть глагольными фразами, и должны быть тщательно выбраны. Он говорит, что функции должны делать только одно, и делать это хорошо. Он говорит, что функции не должны иметь побочных эффектов (и он приводит действительно отличный пример), и что следует избегать выходных аргументов в пользу возвращаемых значений. Он говорит, что функции обычно должны быть либо командами, которые что-то делают, либо запросами, которые на что-то отвечают, но не обоими сразу. Он объясняет DRY. Это всё хорошие советы, хотя немного поверхностные и начального уровня.

Но в этой главе есть и более сомнительные утверждения. Мартин говорит, что аргументы логического флага — плохая практика, с чем я согласен, потому что неприкрашенные true или false в исходном коде непрозрачны и неясны по сравнению с явными IS_SUITE или IS_NOT_SUITE… но рассуждения Мартина скорее сводятся к тому, что логический аргумент означает, что функция делает больше, чем одну вещь, чего она не должна делать.

Мартин говорит, что должно быть возможно читать один исходный файл сверху вниз как повествование, причем уровень абстракции в каждой функции снижается по мере чтения, а каждая функция обращается к другим дальше вниз. Это далеко не универсально. Многие исходные файлы, я бы даже сказал, большинство исходных файлов, нельзя аккуратно иерархизировать таким образом. И даже для тех, которые можно, IDE позволяет нам тривиально прыгать от вызова функции к реализации функции и обратно, точно так же, как мы просматриваем веб-сайты. Кроме того, разве мы по-прежнему читаем код сверху вниз? Ну, может быть, некоторые из нас так и делают.

А потом это становится странным. Мартин говорит, что функции не должны быть достаточно большими, чтобы содержать вложенные структуры (условные обозначения и циклы); они не должны иметь отступов более чем на два уровня. Он говорит, что блоки должны быть длиной в одну строку, состоящие, вероятно, из одного вызова функции. Он говорит, что идеальная функция не имеет аргументов (но всё равно никаких побочных эффектов??), и что функция с тремя аргументами запутанна и трудна для тестирования. Самое странное, что Мартин утверждает, что идеальная функция — это две-четыре строки кода. Этот совет фактически помещен в начале главы. Это первое и самое важное правило:

Первое правило: функции должны быть компактными. Второе правило: функции должны быть ещё компактнее. Я не могу научно обосновать своё утверждение. Не ждите от меня ссылок на исследования, доказывающие, что очень маленькие функции лучше больших. Я могу всего лишь сказать, что я почти четыре десятилетия писал функции всевозможных размеров. Мне доводилось создавать кошмарных монстров в 3000 строк. Я написал бесчисленное множество функций длиной от 100 до 300 строк. И я писал функции от 20 до 30 строк. Мой практический опыт научил меня (ценой многих проб и ошибок), что функции должны быть очень маленькими.

[...]

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

Весь этот совет завершается листингом исходного кода в конце главы 3. Этот пример кода является предпочтительным рефакторингом Мартина класса Java, происходящего из опенсорсного инструмента тестирования FitNesse.

package fitnesse.html;

import fitnesse.responders.run.SuiteResponder;
import fitnesse.wiki.*;

public class SetupTeardownIncluder {
  private PageData pageData;
  private boolean isSuite;
  private WikiPage testPage;
  private StringBuffer newPageContent;
  private PageCrawler pageCrawler;


  public static String render(PageData pageData) throws Exception {
    return render(pageData, false);
  }

  public static String render(PageData pageData, boolean isSuite)
    throws Exception {
    return new SetupTeardownIncluder(pageData).render(isSuite);
  }

  private SetupTeardownIncluder(PageData pageData) {
    this.pageData = pageData;
    testPage = pageData.getWikiPage();
    pageCrawler = testPage.getPageCrawler();
    newPageContent = new StringBuffer();
  }

  private String render(boolean isSuite) throws Exception {
     this.isSuite = isSuite;
    if (isTestPage())
      includeSetupAndTeardownPages();
    return pageData.getHtml();
  }

  private boolean isTestPage() throws Exception {
    return pageData.hasAttribute("Test");
  }

  private void includeSetupAndTeardownPages() throws Exception {
    includeSetupPages();
    includePageContent();
    includeTeardownPages();
    updatePageContent();
  }


  private void includeSetupPages() throws Exception {
    if (isSuite)
      includeSuiteSetupPage();
    includeSetupPage();
  }

  private void includeSuiteSetupPage() throws Exception {
    include(SuiteResponder.SUITE_SETUP_NAME, "-setup");
  }

  private void includeSetupPage() throws Exception {
    include("SetUp", "-setup");
  }

  private void includePageContent() throws Exception {
    newPageContent.append(pageData.getContent());
  }

  private void includeTeardownPages() throws Exception {
    includeTeardownPage();
    if (isSuite)
      includeSuiteTeardownPage();
  }

  private void includeTeardownPage() throws Exception {
    include("TearDown", "-teardown");
  }

  private void includeSuiteTeardownPage() throws Exception {
    include(SuiteResponder.SUITE_TEARDOWN_NAME, "-teardown");
  }

  private void updatePageContent() throws Exception {
    pageData.setContent(newPageContent.toString());
  }

  private void include(String pageName, String arg) throws Exception {
    WikiPage inheritedPage = findInheritedPage(pageName);
    if (inheritedPage != null) {
      String pagePathName = getPathNameForPage(inheritedPage);
      buildIncludeDirective(pagePathName, arg);
    }
  }

  private WikiPage findInheritedPage(String pageName) throws Exception {
    return PageCrawlerImpl.getInheritedPage(pageName, testPage);
  }

  private String getPathNameForPage(WikiPage page) throws Exception {
    WikiPagePath pagePath = pageCrawler.getFullPath(page);
    return PathParser.render(pagePath);
  }

  private void buildIncludeDirective(String pagePathName, String arg) {
    newPageContent
      .append("\n!include ")
      .append(arg)
      .append(" .")
      .append(pagePathName)
      .append("\n");
  }
}

Повторю ещё раз: это собственный код Мартина, написанный по его личным стандартам. Это идеал, представленный нам в качестве учебного примера.

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

Давайте проигнорируем import с подстановочными знаками.

У нас есть два публичных статических метода, один приватный конструктор и пятнадцать приватных методов. Из пятнадцати приватных методов аж тринадцать либо имеют побочные эффекты (они изменяют переменные, которые не были переданы им в качестве аргументов, например buildIncludeDirective, который имеет побочные эффекты на newPageContent), либо вызывают другие методы, которые имеют побочные эффекты (например, include, который вызывает buildIncludeDirective). Только isTestPage и findInheritedPage выглядят как будто без побочных эффектов. Они по-прежнему используют переменные, которые в них не передаются (pageData и testPage соответственно), но, кажется, делают это без побочных эффектов.

На этом этапе вы можете сделать вывод, что, возможно, определение Мартина «побочный эффект» не включает переменные-члены объекта, метод которого мы только что вызвали. Если мы примем это определение, то пять таких переменных, pageData, isSuite, testPage, newPageContent и pageCrawler, неявно передаются каждому вызову приватного метода, и это считается нормальным; любой приватный метод свободен делать всё, что ему нравится, с любой из этих переменных.

Но это неправильное предположение! Вот собственное определение Мартина из более ранней части этой главы:

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

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

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

private String render(boolean isSuite) throws Exception {
   this.isSuite = isSuite;
  if (isTestPage())
    includeSetupAndTeardownPages();
  return pageData.getHtml();
}

Почему этот метод имеет побочный эффект установки значения this.isSuite? Почему бы просто не передать isSuite как логическое значение более поздним вызовам метода? Почему мы возвращаем pageData.getHtml() после того, как потратили три строки кода, ничего не сделав с pageData? Мы могли бы сделать обоснованное предположение, что includeSetupAndTeardownPages имеет побочные эффекты на pageData, но тогда что? Мы не можем знать ни того, ни другого, пока не посмотрим. И какие ещё побочные эффекты это оказывает на другие переменные-члены? Неопределённость так возрастает, что мы внезапно задаёмся вопросом, может ли isTestPage тоже иметь побочные эффекты. (А что это за выступ? А где фигурные скобки?)

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

private WikiPage findInheritedPage(String pageName) throws Exception {
  return PageCrawlerImpl.getInheritedPage(pageName, testPage);
}

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

Во-вторых, содержимое pageData уничтожается. В отличие от переменных-членов (isSuite, testPage, newPageContent и pageCrawler), мы на самом деле не владеем pageData для изменения. Он первоначально передаётся в общедоступные методы визуализации верхнего уровня внешним вызывающим объектом. Метод рендеринга выполняет большую работу и в конечном итоге возвращает строку HTML. Однако во время этой работы в качестве побочного эффекта pageData деструктивно модифицируется (см. updatePageContent). Конечно, было бы предпочтительнее создать совершенно новый объект PageData с нашими желаемыми модификациями и оставить оригинал нетронутым? Если вызывающий объект попытается использовать pageData для чего-то другого, он может быть очень удивлён тем, что произошло с его содержимым. Но именно так вёл себя исходный код до рефакторинга Мартина. Он сохранил это поведение, хотя и закопал его очень эффективно.

*
Неужели вся книга такая?

В основном, да. «Чистый код» смешивает обезоруживающую комбинацию сильных, вечных советов и советов, которые очень сомнительны или устарели. Книга фокусируется почти исключительно на объектно-ориентированном коде и призывает к достоинствам SOLID, исключая другие парадигмы программирования. Он фокусируется на коде Java, исключая другие языки программирования, даже другие объектно-ориентированные языки. Есть глава «Запахи и эвристические правила», которая представляет собой не более чем список довольно разумных признаков, которые следует искать в коде. Но есть несколько в основном пустословных глав, где внимание сосредоточено на трудоёмких отработанных примерах рефакторинга Java-кода. Есть целая глава, изучающая внутренние компоненты JUnit (книга написана в 2008 году, так что вы можете себе представить, насколько это актуально сейчас). Общее использование Java в книге очень устарело. Такого рода вещи неизбежны — книги по программированию традиционно быстро устаревают — но даже для того времени предоставленный код плох.

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

@Test
  public void turnOnLoTempAlarmAtThreashold() throws Exception {
    hw.setTemp(WAY_TOO_COLD);
    controller.tic();
    assertTrue(hw.heaterState());
    assertTrue(hw.blowerState());
    assertFalse(hw.coolerState());
    assertFalse(hw.hiTempAlarm());
    assertTrue(hw.loTempAlarm());
  }

и гордо переделывает его:

@Test
  public void turnOnLoTempAlarmAtThreshold() throws Exception {
    wayTooCold();
    assertEquals(“HBchL”, hw.getState());
  }

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

*
Автор представляет три закона TDD:

Первый закон. Не пишите код продукта, пока не напишете отказной модульный тест.

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

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

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

… но Мартин не обращает внимания на тот факт, что разбиение задач программирования на крошечные тридцатисекундные кусочки в большинстве случаев безумно трудоёмко, часто очевидно бесполезно, а зачастую невозможно.

*
Есть глава «Объекты и структуры данных», где автор приводит такой пример структуры данных:

public class Point {
  public double x;
  public double y;
}

и такой пример объекта (ну, интерфейс для одного объекта):

public interface Point {
  double getX();
  double getY();
  void setCartesian(double x, double y);
  double getR();
  double getTheta();
  void setPolar(double r, double theta);
}

Он пишет:

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

И… это всё?

Да, вы всё правильно поняли. Определение Мартина «структуры данных» расходится с определением, которое используют все остальные! В книге вообще ничего не говорится о чистом кодировании с использованием того, что большинство из нас считает структурами данных. Эта глава намного короче, чем вы ожидаете, и содержит очень мало полезной информации.

*
Я не собираюсь переписывать все остальные мои заметки. У меня их слишком много, и перечислять всё, что я считаю неправильным в этой книге, заняло бы слишком много времени. Я остановлюсь на ещё одном вопиющем примере кода. Это генератор простых чисел из главы 8:

package literatePrimes;

import java.util.ArrayList;

public class PrimeGenerator {
  private static int[] primes;
  private static ArrayList<Integer> multiplesOfPrimeFactors;

  protected static int[] generate(int n) {
    primes = new int[n];
    multiplesOfPrimeFactors = new ArrayList<Integer>();
    set2AsFirstPrime();
    checkOddNumbersForSubsequentPrimes();
    return primes;
  }

  private static void set2AsFirstPrime() {
    primes[0] = 2;
    multiplesOfPrimeFactors.add(2);
  }

  private static void checkOddNumbersForSubsequentPrimes() {
    int primeIndex = 1;
    for (int candidate = 3;
         primeIndex < primes.length;
         candidate += 2) {
      if (isPrime(candidate))
        primes[primeIndex++] = candidate;
    }
  }

  private static boolean isPrime(int candidate) {
    if (isLeastRelevantMultipleOfNextLargerPrimeFactor(candidate)) {
      multiplesOfPrimeFactors.add(candidate);
      return false;
    }
    return isNotMultipleOfAnyPreviousPrimeFactor(candidate);
  }

  private static boolean
  isLeastRelevantMultipleOfNextLargerPrimeFactor(int candidate) {
    int nextLargerPrimeFactor = primes[multiplesOfPrimeFactors.size()];
    int leastRelevantMultiple = nextLargerPrimeFactor * nextLargerPrimeFactor;
    return candidate == leastRelevantMultiple;
  }

  private static boolean
  isNotMultipleOfAnyPreviousPrimeFactor(int candidate) {
    for (int n = 1; n < multiplesOfPrimeFactors.size(); n++) {
      if (isMultipleOfNthPrimeFactor(candidate, n))
        return false;
    }
    return true;
  }

  private static boolean
  isMultipleOfNthPrimeFactor(int candidate, int n) {
   return
     candidate == smallestOddNthMultipleNotLessThanCandidate(candidate, n);
  }

  private static int
  smallestOddNthMultipleNotLessThanCandidate(int candidate, int n) {
    int multiple = multiplesOfPrimeFactors.get(n);
    while (multiple < candidate)
      multiple += 2 * primes[n];
    multiplesOfPrimeFactors.set(n, multiple);
    return multiple;
  }
}

Что это за код? Каковы названия методов? set2AsFirstPrime? smallestOddNthMultipleNotLessThanCandidate? Это должен быть чистый код? Ясный, интеллектуальный способ просеивания простых чисел?

Если таково качество кода, который создаёт этот программист — на досуге, в идеальных условиях, без давления реальной производственной разработки программного обеспечения — тогда зачем вообще обращать внимание на остальную часть его книги? Или другие его книги?

*
Я написал это эссе, потому что постоянно вижу, как люди рекомендуют «Чистый код». Я почувствовал необходимость предложить антирекомендацию.

Первоначально я читал «Чистый код» в группе на работе. Мы читали по главе в неделю в течение тринадцати недель.

Так вот, вы не хотите, чтобы группа к концу каждого сеанса выражала только единодушное согласие. Вы хотите, чтобы книга вызвала какую-то реакцию у читателей, какие-то дополнительные комментарии. И я предполагаю, что в определённой степени это означает, что книга должна либо сказать что-то, с чем вы не согласны, либо не раскрыть тему полностью, как вы считаете должным. Исходя из этого, «Чистый код» оказался годным. У нас состоялись хорошие дискуссии. Мы смогли использовать отдельные главы в качестве отправной точки для более глубокого обсуждения актуальных современных практик. Мы говорили о многом, что не было описано в книге. Мы во многом расходились во мнениях.

Порекомендовал бы я вам эту книгу? Нет. Даже в качестве текста для начинающих, даже со всеми оговорками выше? Нет. Может быть, в 2008 году я рекомендовал бы вам эту книгу? Могу ли я рекомендовать его сейчас как исторический артефакт, образовательный снимок того, как выглядели лучшие практики программирования в далёком 2008 году? Нет, я бы не стал.

*
Итак, главный вопрос заключается в том, какую книгу(и) я бы рекомендовал вместо этого? Я не знаю. Предлагайте в комментариях, если только я их не закрыл.

См. также:

Tags:Роберт МартинЧистый кодантирекомендация
Hubs: Programming Java Reading room
+137
77.9k 262
Comments 427
Top of the last 24 hours