View on GitHub

wiki

Technical Excellence Wiki

Предположение о порядке преобразований (англ. The Transformation Priority Premise)

Оригинальный текст из статьи Робертом Мартина The Transformation Priority Premise.

19 Декабря 2010 года

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

“В то время, как тесты становятся более конкретными, то код - более общим.”

Недавно я открыл для себя новый смысл этой мантры.

Я придумал это правило, чтобы уберечь своих студентов от приобретения отвратительной привычки писать код, который копирует тесты во время циклов TDD:

@Test
public void primeFactorsOfFour() {
  assertEquals(asList(),    PrimeFactors.of(1));
  assertEquals(asList(2),   PrimeFactors.of(2));
  assertEquals(asList(3),   PrimeFactors.of(3));
  assertEquals(asList(2,2), PrimeFactors.of(4));
  ...
}

public class PrimeFactors {
  public static of(int n) {
    if (n == 1)
      return asList();
    else if (n == 2)
      return asList(2);
    else if (n == 3)
      return asList(3);
    else if (n == 4)
      return asList(2,2);
    ...

Новички в TDD часто задаются вопросом, почему TDD не приводит к if-else коду. Я обычно указываю на правило выше. Такой ответ удовлетворяет студентов, особенно когда я иллюстрирую эту идею катой по написанию кода для поиска простых множителей целого числа (англ. Prime Factors Kata).

Факторизация целых чисел

Я придумал эту кату 10 лет назад, когда мой сын Джастин делал домашнее задание. Он должен был найти простые множители для нескольких целых чисел (выполнить факторизацию). Я сказал ему, что его ответы будет проверять программа, которую я напишу. Предполагалось, что он должен был вводить свой вариант ответа, а программа проверяла бы, верен он или нет.

Разместившись за кухонным столом, я приступил к написанию алгоритма на Ruby, используя новый подход TDD. Это было одно из тех событий, которые позволили посмотреть на некоторые вещи по-новому. При переходе от теста к тесту алгоритм выстраивался совершенно неожиданным образом. Моё волнение усилилось, когда кейс с параметром 8 стал зеленым, сменив всего лишь ключевое слово if на while. Я чувствовал в этом что-то такое, что не мог точно сформулировать. Но думаю, что теперь могу.

“Тупость” (англ. brainlessness)

В течение следующих нескольких лет я написал множество тестов и сотни раз тренировался на катах. Время от времени я вношу небольшие улучшения в эту кату: улучшаю тест и код, делая их логичнее, проще и элегантнее. После стольких тренировок и улучшений я начал кое-что замечать. И это связано с другой претензией к TDD: тупость (англ. brainlessness).

Другая ката “Боулинг” (англ. Bowling Game Kata) начинается c теста, когда шар попадает в желоб, т.е. не сбивает ни одной кегли:

@Test
public void gutterGame() {
  for (int i=0; i<20; i++)
    game.roll(0);
  assertEquals(0, game.score());
}

Во время обучения TDD я спрашиваю: “Что мы должны сделать, чтобы такой тест прошел?”. Новички часто теряются от такого вопроса, ожидая, что мы будем писать алгоритм для расчета очков в боулинге. Но мы удивляем их ещё больше, делая тест зеленым следующим способом:

public int score() {
  return 0;
}

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

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

Последовательность преобразований

Но что я начал осознавать, что возвращать ноль - это не так тупо, как может показаться. Особенно если это делать в соответствующем контексте.

При использовании TDD наш рабочий код проходит через последовательность преобразований. Раньше я думал, что это преобразование из глупого в интеллектуальное. Но я начал понимать, что это совсем не так. Скорее, код проходит через последовательность преобразований от конкретного к более общему.

Возвращать ноль из функции score - это частный случай. Но он представлен в правильной форме. Это целое число, и оно имеет нужное значение. Следовательно, форма алгоритма верна, просто он ещё не был обобщен.

Следующий тест для “Боулинга”:

@Test
public void allOnes() {
  for (int i=0; i<20; i++)
    game.roll(1);
  assertEquals(20, game.score());
}

Чтобы сделать его зеленым, мы добавляем суммирование всех переданных значений в метод roll в переменной score, а затем меняем метод score, чтобы возвращать это значение:

public int score() {
    return score;
}

Обратите внимание, что мы преобразовали константу 0 в переменную score. Алгоритм сохраняет ту же форму (возвращает int), но теперь имеет более общую реализацию. Почему реализация стала более общей? Потому что переменная - это обобщение константы.

Другими словами, произошедшая трансформация - это простое изменение некоторой части решения из более конкретной формы в более общую!

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

Но не будем забегать вперёд. Какой же следующий тест для “Боулинга”?

@Test
public void oneSpare() {
    game.roll(5);
    game.roll(5); // spare
    game.roll(3);
    rollMany(20,0);
    assertEquals(16, g.score());
}

Этот тест заставляет нас забыть о простой реализации метода score в пользу более сложной. Переменная score, значение которой обновлялась в методе roll, теперь уступила место массиву бросков

Опять мы привели конкретную реализацию (переменную, которая хранила рассчитанное значение) к более общей форме - циклу, считающему очки из массива.

Другое общее преобразование может быть найдено в кате “Факторизация” (с неё началась статья). Чтобы выполнить второе условие, мы вставляем конструкцию if. Код превращается из

List factors = new ArrayList();
return factors;

в

List factors = new ArrayList();
if (n>1)
factors.add(2);
return factors;

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

Ката “Факторизация” интересна тем, что преобразование происходит опять в кейсе с 4, когда добавляется условный оператор if, чтобы обрабатывать случаи, когда параметр делится на 2 без остатка:

List factors = new ArrayList();
if (n>1) {
  if (n % 2 == 0) {
    factors.add(2);
    n %= 2;
  }
  if (n > 1)
    factors.add(n);
}
return factors;

Новая ветка покрывает кейс с параметром 4, определяя, что 4 делится на 2. После деления добавляем 2 к массиву множителей, а частное становится новым параметром, позволяя переиспользовать эту ветку.

Еще более интересен кейс с параметром 8, когда внутренний if преобразуется в while. После этого для кейса с параметром 9 преобразуем внешний if также в while. Совершенно очевидно, что while - обобщенная форма if.

Преобразования

Что же такое преобразования? Возможно, мы можем представить их список:

Скорее всего есть и другие.

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

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

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

The Priority Premise

Что в последнее время привлекло мой интерес — идея, что преобразования в верхней части списка должны быть предпочтительнее тех, что в нижней. Лучше (или проще) изменить константу на переменную, чем добавлять оператор if. Поэтому при попытке написать код для выполнения теста Вы пытаетесь воспользоваться преобразованиями, которые проще (выше в списке).

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

Проблема тупика (англ. the impasse problem)

Именно ката “Перенос слов” (англ. word wrap) заставила меня задуматься об этом. Начало этого упражнения кажется простым, но вы быстро сталкиваетесь с дилеммой. Есть одна последовательность тестов и реализации, которая загоняет Вас в тупик, где нет способа заставить следующий текст пройти, не переписав весь алгоритм. Другая последовательность тестов позволяет алгоритму объединиться в пошаговой манере, которую предпочитают TDD-практики. Как выбрать правильную последовательность?

Это довольно распространенная проблема, с которой сталкиваются разработчики TDD. Мы добавляем тест и обнаруживаем, что не знаем, как решить его, не меняя большой объем кода. Чем больше кода мы меняем, тем больше времени пройдет, прежде чем мы вернемся к зеленому. И цикл красный-зеленый-рефакторинг прерывается.

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

Примерный: ката “Перенос слов”

Итак, давайте пройдемся по рассуждениям. Сначала мы выполним кату “Перенос слов” и выберем путь, который ведет в тупик. Затем мы сделаем это снова, но выберем путь, который туда не ведет. В каждом случае мы покажем преобразования.

Первый тест в этой кате — это, очевидно, вырожденный случай. Обратите внимание, что он использует самое первое преобразование ({}–>nil):

@Test
public void WrapNullReturnsEmptyString() throws Exception {
  assertThat(wrap(null, 10), is(""));
}

Как только мы написали красный тест, вы сразу приступаем к написанию кода ({}–>nil):

public static String wrap(String s, int length) {
    return null;
}

Мы можем сделать его зелеными с преобразованием (nil->constant)

public static String wrap(String s, int length) {
    return "";
}

Следующий тест - пустая строка, Заметьте, что это всего лишь преобразование (nil->constant) первого. Новый тест не требует изменений в коде. Я всегда использую такой тип тестов, как индикатор, что ничего не сломалось.

@Test
public void WrapEmptyStringReturnsEmptyString() throws Exception {
    assertThat(wrap("", 10), is(""));
}
@Test
public void OneShortWordDoesNotWrap() throws Exception {
    assertThat(wrap("word", 5), is("word"));
}

Чтобы удовлетворить условиям этого теста, нам придется использовать (unconditional->if) также, как и (constant->scalar).

public static String wrap(String s, int length) {
    if (s == null)
        return "";
    return s;
}

Тупик

В этот момент, если бы мы обращали внимание предположение о порядке преобразований, мы могли бы задаться вопросом, был ли это мудрый шаг. В конце концов, преобразование (безусловный->if) находится довольно далеко от верха списка. Но в этом случае я собираюсь нарушить это предположение, чтобы показать вам тупик.

Следующий тест опять использует преобразование (constant->constant+).

@Test
public void TwoWordsLongerThanLimitShouldWrap() throws Exception {
  assertThat(wrap("word word", 6), is("word\nword"));
}

Мы можем его удовлетворить, использую преобразование (expression->function).

public static String wrap(String s, int length) {
    if (s == null)
        return "";
    return s.replaceAll(" ", "\n");
}

Это один из тех путей, который кажется умным. В его защиту можем сказать, что делаем самую простую вещь, которая могла бы сработать. Но, учитывая предположение о порядке, это уже не так-то просто. Преобразование (expression->function) находится в самом низу списка.

Продолжаем преобразование (constant->constant+).

@Test
public void ThreeWordsJustOverTheLimitShouldWrapAtSecondWord() throws Exception {
    assertThat(wrap("word word word", 9), is("word word\nword"));
}

Но как написать код, чтобы это тест проходил? Текущее решение, похоже, сложно трансформировать таким образом. Если бы у нас был бы метод replaceLast(" ", "\n"), то, возможно, это было бы просто. Но это не помогло бы нам для случая "word word word word".

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

Разрушение тупика

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

@Test
public void OneShortWordDoesNotWrap() throws Exception {
  assertThat(wrap("word", 5), is("word"));
}

В этом тесте нет ничего необычного, что заставило бы нас думать, что он не в порядке. Очевидно, лучшего теста для представления не существует. Однако реализация заставляет нас использовать преобразование (unconditional->if), которое имеет довольно низкий приоритет.

public static String wrap(String s, int length) {
  if (s == null)
    return "";
  return s;
}

Итак, теперь мы должны спросить себя, есть ли другой тест, который мы могли бы написать, чтобы можно было использовать преобразование наивысшим приоритетом. На данный момент реализация просто return "". А есть ли другие входные данные, которые тоже должны приводить к пустой строке?

Длина столбца меньше единицы — это своего рода бессмыслица. Мы могли бы вернуть пустую строку для этого или выдать исключение. Я думаю, что исключение, вероятно, более уместно, но тесты для этого также потребуют преобразования (unconditional->if). Тем не менее, вероятно, было бы хорошей идеей сначала обработать все недопустимые случаи входных данных.

@Test(expected = WordWrapper.InvalidArgument.class)
public void LengthLessThanOneShouldThrowInvalidArgument() throws Exception {
  wrap("xxx", 0);
}

Который можно удовлетворить кодом:

public static String wrap(String s, int length) {
    if (length < 1)
        throw new InvalidArgument();
    return "";
}

Но не приближает нас к цели. Так что, я думаю, что лучшего теста не найти:

@Test
public void OneShortWordDoesNotWrap() throws Exception {
    assertThat(wrap("word", 5), is("word"));
}

И после преобразований (unconditional->if) и (constant->scalar) реализация будет такой:

@Test
public void TwoWordsLongerThanLimitShouldWrap() throws Exception {
  assertThat(wrap("word word", 6), is("word\nword"));
}

В последний раз, когда мы видели этот тест, мы прошли его с помощью преобразования (expression->function). Можно ли его решить с помощью преобразования с более высоким приоритетом? Я так не думаю. Каждое решение, которое я могу придумать, включает в себя какой-то алгоритм.

Есть ли другой тест, который мы могли бы предложить, который можно было бы решить с помощью преобразования с более высоким приоритетом? Да, есть! Так что давайте добавим аннотацию @Ignore к текущему тесту и напишем тот, который предполагает более простое преобразование.

@Test
public void WordLongerThanLengthBreaksAtLength() throws Exception {
  assertThat(wrap("longword", 4), is("long\nword"));
}

Этот тест может быть удовлетворен после преобразования (unconditional->if).

public static String wrap(String s, int length) {
  if (length < 1)
    throw new InvalidArgument();
  if (s == null)
    return "";

  if (s.length() <= length)
    return s;
  else {
    return "long\nword";
  }
}

Это может выглядеть как обман, но это не так. Мы разделили пути выполнения, и новый путь можно рассматривать как изначально совершенно пустой, а затем преобразованный с помощью ({}–>null) и (null->constant). Мы могли бы написать эти преобразования и увидеть, как они терпят неудачу, но зачем?

Следующий тест совершенно очевиден. Нам нужно избавиться от этой константы. Мы можем сделать это, добавив новый оператор к существующему тесту с преобразованием (statement->statements).

@Test
public void WordLongerThanLengthBreaksAtLength() throws Exception {
  assertThat(wrap("longword", 4), is("long\nword"));
  assertThat(wrap("longerword", 6), is("longer\nword"));
}

Для этого потребуется (expression->function). Нет более простого преобразования и более простого теста.

else {
    return s.substring(0, length) + "\n" + s.substring(length);
  }

Следующий тест — множественное число последнего:

@Test
public void WordLongerThanTwiceLengthShouldBreakTwice() throws Exception {
  assertThat(wrap("verylongword", 4), is("very\nlong\nword"));
}

Мы можем удовлетворить его с помощью преобразования (statement->recursion)

return s.substring(0, length) + "\n" + wrap (s.substring(length), length);

Также возможно сделать этот тест зелёным с помощью преобразования (if->while). Действительно, Вы можете спросить, почему я поместил (statement->recursion) выше (if->while). Так что ниже в этой статье мы рассмотрим итеративное решение. Сравнение этих двух вариантов может убедить вас, что рекурсия на самом деле проще итерации.

Итак, теперь давайте вернемся к тесту с аннотацией @Ignored и включим его снова. Как бы мы его сейчас удовлетворили?

f (s.length() <= length)
    return s;
  else {
    int space = s.indexOf(" ");
    if (space >= 0)
      return "word\nword";
    else
      return s.substring(0, length) + "\n" + wrap(s.substring(length), length);
  }

Преобразование (unconditional->if) с последующим (null->constant) делает свое дело. Более того, нет более простого теста для прохождения и более простого преобразования для использования.

Чтобы избавиться от константы, требуется дополнительный тест:

@Test
public void TwoWordsLongerThanLimitShouldWrap() throws Exception {
  assertThat(wrap("word word", 6), is("word\nword"));
  assertThat(wrap("wrap here", 6), is("wrap\nhere"));
}

Который зеленеет с помощью (expression->function). Опять же, нет более простой проверки или преобразования. (Для краткости и чтобы эта статья не звучала как заезженная пластинка, я перестану делать это утверждение. Вы должны принять его на веру.)

int space = s.indexOf(" ");
    if (space >= 0)
      return s.substring(0, space) + "\n" + s.substring(space+1);
    else
      return s.substring(0, length) + "\n" + wrap(s.substring(length), length);

Мы видим, что новое предложение требует преобразование (statement->recursion). Поэтому мы пишем тест, который выявляет проблему:

@Test
public void ThreeWordsEachLongerThanLimitShouldWrap() throws Exception {
  assertThat(wrap("word word word", 6), is("word\nword\nword"));
}

Реализация для него довольно простая.

if (space >= 0)
      return s.substring(0, space) + "\n" + wrap(s.substring(space+1), length);
    else
      return s.substring(0, length) + "\n" + wrap(s.substring(length), length);

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

public class WordWrapper {
  private int length;

  public WordWrapper(int length) {
    this.length = length;
  }

  public static String wrap(String s, int length) {
    return new WordWrapper(length).wrap(s);
  }

  public String wrap(String s) {
    if (length < 1)
      throw new InvalidArgument();
    if (s == null)
      return "";

    if (s.length() <= length)
      return s;
    else {
      int space = s.indexOf(" ");
      if (space >= 0) 
        return breakBetween(s, space, space + 1);
      else
        return breakBetween(s, length, length);
    }
  }

  private String breakBetween(String s, int start, int end) {
    return s.substring(0, start) + 
      "\n" + 
      wrap(s.substring(end), length);
  }

  public static class InvalidArgument extends RuntimeException {
  }
}

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

@Test
public void ThreeWordsJustOverTheLimitShouldBreakAtSecond() throws Exception {
  assertThat(wrap("word word word", 11), is("word word\nword"));
}

Это требует преобразования (expression->function), но это настолько просто, что очевидно.

int space = s.lastIndexOf(" ");

Хотя это и проходит новый тест, это ломает предыдущий тест. Но мы можем выполнить еще одно преобразование (expression->function), чтобы исправить это.

int space = s.substring(0, length).lastIndexOf(" ");

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

@Test
public void TwoWordsTheFirstEndingAtTheLimit() throws Exception {
  assertThat(wrap("word word", 4), is("word\nword"));
}

Это не удается, но может быть выполнено с помощью преобразования (expression->function).

int space = s.substring(0, length+1).lastIndexOf(" ");

Это может не выглядеть как (expression->function), но это так. Добавление — это функция. Мы могли бы также сказать add(length, 1).

Iteration instead of Recursion

Теперь давайте отмотаем время назад и посмотрим, как может развиваться итеративное, а не рекурсивное решение. Помните, что мы ввели (statement->recursion), пытаясь пройти следующий тест:

@Test
public void WordLongerThanTwiceLengthShouldBreakTwice() throws Exception {
  assertThat(wrap("verylongword", 4), is("very\nlong\nword"));
}

The failing code looks like this:

if (s.length() <= length)
    return s;
else {
    return s.substring(0, length) + "\n" + s.substring(length);
}

Мы можем сделать этот проход, используя преобразование (if->while). Если мы собираемся использовать while, то нам нужно инвертировать условный оператор if. Это простой рефакторинг, а не преобразование.

if (s.length() > length) {
    return s.substring(0, length) + "\n" + s.substring(length);
} else {
    return s;
}

Далее нам нужно создать переменную для хранения состояния итерации. Еще раз, это рефакторинг, а не трансформация.

String result = "";
  if (s.length() > length) {
result = s.substring(0, length) + "\n" + s.substring(length);
  } else {
result = s;
  }
          return result;
}

Циклы while не могут иметь предложений else, поэтому нам нужно исключить путь else, сделав меньше в пути if. Опять же, это рефакторинг.

String result = "";
if (s.length() > length) {
    result = s.substring(0, length) + "\n";
    s = s.substring(length);
}
result += s;
```И теперь мы можем использовать преобразование **(if->while)**, чтобы удовлетворить тест.

```java
String result = "";
while (s.length() > length) {
    result += s.substring(0, length) + "\n";
    s = s.substring(length);
}
result += s;

Процесс

Если мы соглашаемся с предположением о порядка преобразований, то нам следует изменить обычный процесс TDD (красный-зеленый-рефакторинг) следующим образом:

Проблемы

Есть некоторое количество проблем с этим.

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

Влияние

Если мои подозрения окажутся верными, то многое станет возможным.

Формальное доказательство

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

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

Заключение

Учитывая процесс TDD (красный-зеленый-рефакторинг), кажется, что зеленая фаза может быть достигнута путем применения фиксированного набора преобразований, изменяющих поведение кода. Эти изменения перемещают код из определенной формы в более общую. Также кажется, что эти преобразования имеют предпочтительный порядок, основанный на сложности. Этот порядок может использоваться как в красной, так и в зеленой фазах TDD. Во время зеленой фазы мы предпочитаем более простые преобразования. Во время красной фазы мы предпочитаем тесты, которые могут быть удовлетворены более простыми преобразованиями. Предположение этой статьи заключается в том, что если тесты будут выбраны и реализованы в этом предпочтительном порядке преобразований, то тупиковые ситуации TDD будут сокращены или устранены.

Адаптировал: Кротов Артём.

Остались вопросы? Задавай в нашем чате.