Как стать автором
Обновить

Идеальная работа. Программирование без прикрас. Стек

Время на прочтение11 мин
Количество просмотров9.2K
image Привет, Хабр! Сдали в типографию новую книгу Роберта Мартина «Идеальная работа. Программирование без прикрас». Дядюшка Боб создал исчерпывающее руководство по хорошей работе для каждого программиста. Он объединяет дисциплины, стандарты и вопросы этики, необходимые для быстрой и продуктивной разработки надежного, эффективного кода, позволяющего испытывать гордость за программное обеспечение, которое вы создаете каждый день.

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

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

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

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

Стек


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

Итак, начнем:

// T: 00:00 StackTest.java
package stack;
import org.junit.Test;
public class StackTest {
   @Test
   public void nothing() throws Exception {
   }
}

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

Затем возникает вопрос, что тестировать, ведь у нас пока нет кода.

Ответить на него очень просто. Предположим, нам уже известен код, который мы собираемся написать: public class stack. Здесь мы вспоминаем первый закон и пишем тест, который будет успешно проходить такой код.

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


Правило 1 — не высшая математика. Ничего сложного для понимания в нем нет. Если можно написать строку кода, то можно написать и тест, который будет ее проверять. И ничто не мешает написать его первым. Так и сделаем:

// T:00:44 StackTest.java
public class StackTest {
   @Test
   public void canCreateStack() throws Exception {
      MyStack stack = new MyStack();
   }
}

Полужирным шрифтом я выделяю изменения или добавления, показывая таким способом фрагменты, из-за которых код не компилируется. Я назвал переменную MyStack, так как название Stack в языке Java уже зарезервировано и используется в качестве ключевого слова.
Обратите внимание, что во фрагменте кода я дал тесту более описательное название. Теперь в соответствии со вторым законом нужно создать стек, поскольку без этого код MyStack компилироваться не будет. При этом нужно придерживаться третьего правила: не писать больше, чем требуется для прохождения теста:

// T: 00:54 Stack.java
package stack;
public class MyStack {
}

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

image

Имя MyStack — не самый лучший выбор, но с его помощью мы избежали конфликта имен. Теперь, когда оно объявлено в пакете stack, изменим его обратно на Stack. У меня это заняло 15 секунд. Тест все еще прекрасно проходит.

// T:01:09 StackTest.java
public class StackTest {
   @Test
   public void canCreateStack() throws Exception {
      Stack stack = new Stack();
   }
}
// T: 01:09 Stack.java
package stack;
public class Stack {
}

Здесь мы подошли к следующему правилу: красный → зеленый → рефакторинг. Никогда не упускайте возможность навести порядок.

Правило 2. Сделайте так, чтобы тест перестал проходить. Сделайте так, чтобы он снова начал проходить. Очистите код.

Написать работающий код достаточно сложно. Написать работающий и чистый код еще сложнее. К счастью, выполнение этой задачи можно разбить на два этапа. Сначала пишем работающий код, не обращая внимания на его качество. Затем, благодаря наличию тестов, мы легко можем почистить этот код, сохранив его работоспособность.

То есть на каждом витке цикла TDD мы пользуемся возможностью навести порядок в созданном собственными руками беспорядке.

Вы могли заметить, что наш тест не утверждает никакого поведения. Он компилируется и проходит, но не дает информации о созданном нами стеке. Это можно исправить за 15 секунд:

// T: 01:24 StackTest.java
public class StackTest {
   @Test
   public void canCreateStack() throws Exception {
      Stack stack = new Stack();
      assertTrue(stack.isEmpty());
   }
}

Здесь вступает в дело второй закон, и нам нужно скомпилировать этот код:

// T: 01:49
import static junit.framework.TestCase.assertTrue;
public class StackTest {
   @Test
   public void canCreateStack() throws Exception {
      Stack stack = new Stack();
      assertTrue(stack.isEmpty());
   }
}
// T: 01:49 Stack.java
public class Stack {
   public boolean isEmpty() {
     return false;
   }
}

Двадцать пять секунд спустя тест компилируется, но проваливается. Это сделано преднамеренно. Я специально добавил утверждение isEmpty, возвращающее значение false, поскольку, согласно первому закону, тестирование должно окончиться неудачей. Зачем это нужно? Чтобы убедиться, что в ситуациях, когда тест не должен проходить, все так и есть. И мы наполовину проверили, как он работает. Проверим вторую половину, изменив утверждение isEmpty таким образом, чтобы оно возвращало значение true:

// T: 01:58 Stack.java
public class Stack {
   public boolean isEmpty() {
      return true;
   }
}

Все, теперь тест проходит. Мне потребовалось всего 9 секунд, чтобы убедиться, что тест функционирует как нужно.

Как правило, когда программисты сначала видят значение false, а потом true, они смеются, поскольку происходящее напоминает какие-то странные уловки. На самом же деле это всего лишь проверка функционирования теста. Если мы можем убедиться, что там, где он должен, он проходит, а там, где не должен, проваливается, то почему этого не сделать?

Что дальше? Мне нужно добавить функцию push, поэтому в соответствии с правилом 1 я напишу тест, который будет проверять ее работу:

// T 02:24 StackTest.java
@Test
public void canPush() throws Exception {
   Stack stack = new Stack();
   stack.push(0);
}

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

// T: 02:31 Stack.java
public void push(int element) {
}

Теперь тест компилируется, но в нем отсутствует утверждение. Поэтому нужно добавить предикат, что после однократного применения метода push стек перестает быть пустым:

// T: 02:54 StackTest.java
@Test
public void canPush() throws Exception {
   Stack stack = new Stack();
   stack.push(0);
   assertFalse(stack.isEmpty());
}

Разумеется, такой тест не пройдет, поскольку метод isEmpty возвращает значение true. Нужна более интеллектуальная проверка, например, добавим для отслеживания пустоты стека логический флаг:

// T: 03:46 Stack.java
public class Stack {
   private boolean empty = true;
   public boolean isEmpty() {
      return empty;
   }
   public void push(int element) {
      empty=false;
   }
}

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

// T: 04:24 StackTest.java
public class StackTest {
   private Stack stack = new Stack();
   @Test
   public void canCreateStack() throws Exception {
      assertTrue(stack.isEmpty());
   }
   @Test
   public void canPush() throws Exception {
      stack.push(0);
      assertFalse(stack.isEmpty());
   }
}

Эта операция заняла 30 секунд, и теперь тест благополучно проходит. Но мне не совсем нравится его имя canPush, я предпочитаю его поменять.

// T: 04:50 StackTest.java
@Test
public void afterOnePush_isNotEmpty() throws Exception {
   stack.push(0);
   assertFalse(stack.isEmpty());
}

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

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

// T: 05:17 StackTest.java
@Test
public void afterOnePushAndOnePop_isEmpty() throws Exception {
   stack.push(0);
   stack.pop()
}

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

// T: 05:31 Stack.java
public int pop() {
   return -1;
}

Третий закон позволит нам закончить тест:

// T: 05:51
@Test
public void afterOnePushAndOnePop_isEmpty() throws Exception {
   stack.push(0);
   stack.pop();
   assertTrue(stack.isEmpty());
}

Тест провален, поскольку флаг empty так и остается со значением true. Исправим эту недоработку:

// T: 06:06 Stack.java
public int pop() {
   empty=true;
   return -1;
}

Тестирование благополучно завершено. С момента предыдущего теста прошло 76 секунд.
Очистка тут не требуется, поэтому действуем в соответствии со вторым законом. После двух применений метода push размер стека должен стать равным 2:

// T: 06:48 StackTest.java
@Test
public void afterTwoPushes_sizeIsTwo() throws Exception {
   stack.push(0);
   stack.push(0);
   assertEquals(2, stack.getSize());
}

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

// T: 07:23 Stack.java
public int getSize() {
   return 0;
}

Теперь все компилируется, но тест не проходит.

Разумеется, для прохождения теста достаточно тривиальной правки:

// T: 07:32 Stack.java
public int getSize() {
   return 2;
}

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

Но полученное решение более чем примитивно, поэтому согласно правилу 1 я поищу лучший вариант. Ну да, с первого раза не получилось (можете надо мной посмеяться):

// T: 08:06 StackTest.java
@Test
public void afterOnePushAndOnePop_isEmpty() throws Exception {
   stack.push(0);
   stack.pop();
   assertTrue(stack.isEmpty());
   assertEquals(1, stack.getSize());
}

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

Зато сейчас я полностью уверен, что мои тесты хорошо работают, так что можно внести в код изменения, при которых они пройдут успешно:

// T: 08:56
public class Stack {
   private boolean empty = true;
   private int size = 0;
   public boolean isEmpty() {
      return size == 0;
   }
   public void push(int element) {
      size++;
   }
   public int pop() {
      --size;
   return -1;
   }
   public int getSize() {
      return size;
    }
}

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

// T: 09:28 StackTest.java
@Test
public void afterOnePushAndOnePop_isEmpty() throws Exception {
   stack.push(0);
   stack.pop();
   assertTrue(stack.isEmpty());
   assertEquals(0, stack.getSize());
}

Все тесты проходят благополучно. С момента предыдущего тестирования прошло 3 минуты и 22 секунды.

Для полноты картины я решил добавить еще и проверку размера:

// T: 09:51 StackTest.java
@Test
public void afterOnePush_isNotEmpty() throws Exception {
   stack.push(0);
   assertFalse(stack.isEmpty());
   assertEquals(1, stack.getSize());
}

Разумеется, тест был пройден.

Вернемся к первому закону. Что должно произойти при опустошении стека? Следует ожидать исключения, информирующего о недостаточном наполнении буфера:

// T: 10:27 StackTest.java
@Test(expected = Stack.Underflow.class)
public void poppingEmptyStack_throwsUnderflow() {
}

Следуя второму закону, добавим это исключение:

// T: 10:36 Stack.java
public class Underflow extends RuntimeException {
}

В результате сможем выполнить такой тест:

// T: 10:50 StackTest.java
@Test(expected = Stack.Underflow.class)
public void poppingEmptyStack_throwsUnderflow() {
   stack.pop();
}

Тест, разумеется, провалится, но это легко исправить:

// T: 11:18 Stack.java
public int pop() {
   if (size == 0)
      throw new Underflow();
   --size;
   return -1;
}

Тест проходит. С момента предыдущего тестирования прошла 1 минута и 27 секунд.

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

// T: 11:49 StackTest.java
@Test
public void afterPushingX_willPopX() throws Exception {
   stack.push(99);
   assertEquals(99, stack.pop());
}

Тест провален, поскольку метод pop в настоящее время возвращает –1. Для прохождения теста сделаем так, чтобы он возвращал 99:

// T: 11:57 Stack.java
public int pop() {
   if (size == 0)
      throw new Underflow();
   --size;
   return 99;
}

Этого явно недостаточно, поэтому в соответствии с правилом 1 добавим к тесту необходимый минимум кода, который сделает его немного умнее:

// T: 12:18 StackTest.java
@Test
public void afterPushingX_willPopX() throws Exception {
   stack.push(99);
   assertEquals(99, stack.pop());
   stack.push(88);
   assertEquals(88, stack.pop());
}

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

// T: 12:50 Stack.java
public class Stack {
   private int size = 0;
   private int element;
   public void push(int element) {
      size++;
      this.element = element;
   }
   public int pop() {
      if (size == 0)
         throw new Underflow();
      --size;
      return element;
   }
}

Теперь тест проходит. С момента предыдущего тестирования прошло 92 секунды.

Подозреваю, что к этому моменту я вам изрядно надоел. Возможно, вы мысленно кричите на меня: «Перестань маяться дурью и просто напиши этот проклятый стек!» Но я всего лишь следую правилу 3.

Правило 3. Не гонитесь за золотом.

Любого новичка в TDD посещает огромное искушение первым делом заняться сложными или интересными вещами. Например, в случае написания стека так соблазнительно начать с тестирования поведения FILO (first-in-last-out, «первым пришел — последним вышел»). Именно такой подход называется «погоней за золотом». Я думаю, вы обратили внимание, что я намеренно избегал тестирования чего-либо, напоминающего стек. Я сосредоточился на вспомогательных элементах, таких как пустота и размер.

Почему я не погнался за золотом? Зачем вообще придумали правило 3? Дело в том, что слишком рано погнавшись за золотом, вы, как правило, упускаете множество деталей. Скоро я вам покажу, что вместе с этим вы упускаете и возможность упростить код.

Впрочем, сейчас в соответствии с первым законом нужно написать неработающий тест. И самый очевидный кандидат для тестирования на этом этапе — поведение FILO:

// T: 13:36 StackTest.java
@Test
public void afterPushingXandY_willPopYthenX() {
   stack.push(99);
   stack.push(88);
   assertEquals(88, stack.pop());
   assertEquals(99, stack.pop());
}

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

// T: 13:51 Stack.java
public class Stack {
   private int size = 0;
   private int[] elements = new int[2];
   public void push(int element) {
      size++;
      this.elements = element;
   }
   public int pop() {
      if (size == 0)
         throw new Underflow();
      --size;
      return elements;
   }
}

По очереди рассмотрим ошибки компиляции этого кода. Переменной elements в методе push не хватает квадратных скобок:

// T: 14:01 Stack.java
public void push(int element) {
   size++;
   this.elements[] = element;
}

В эти скобки нужно что-то поместить. Хм-м-м. В предыдущем фрагменте кода есть инкремент переменной size++:

// T: 14:07 Stack.java
public void push(int element) {
   this.elements[size++] = element;
}

Метод pop также содержит переменную elements, нуждающуюся в квадратных скобках:

// T: 14:13
public int pop() {
   if (size == 0)
      throw new Underflow();
   --size;
   return elements[];
}

О, смотрите! Сюда можно поместить декремент переменной --size:

// T: 14:24
public int pop() {
   if (size == 0)
      throw new Underflow();
   return elements[--size];
}


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

Упражнение


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

Оформить предзаказ бумажной книги можно на нашем сайте
Теги:
Хабы:
+2
Комментарии4

Публикации

Информация

Сайт
piter.com
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия