12. Сложение, наконец-то
$5 + 1 °CHF = $10, если курс обмена 2:1
Наступил новый день, и я заметил, что список задач переполнен вычеркнутыми пунктами. Лучше всего переписать оставшиеся не зачеркнутыми пункты в новый свежий список. (Я люблю физически копировать пункты из старого списка в новый список. Если в старом списке много мелких недоделанных задач, вместо того, чтобы копировать их в новый список, я просто добавляю в программу соответствующий код. В результате из-за моей лени куча мелочей, которая могла бы расти со временем, просто исчезает. Используйте свои слабости.)
$5 + 1 °CHF = $10, если курс обмена 2:1
$5 + $5 = $10
Пока что я не представляю себе, как можно реализовать смешанное сложение долларов и франков, поэтому предлагаю начать с более простой задачи: $5 + $5 = $10.
public void testSimpleAddition() {
Money sum = Money.dollar(5). plus(Money.dollar(5));
assertEquals(Money.dollar(10), sum);
}
Мы могли бы подделать реализацию, просто вернув значение Money.dollar(10), однако в данном случае реализация кажется очевидной. Давайте попробуем:
Money
Money plus(Money addend) {
return new Money(amount + addend.amount, currency);
}
(Далее я буду ускорять процесс разработки, чтобы сэкономить бумагу и сохранить ваш интерес. Там, где дизайн не очевиден, я буду подделывать реализацию и выполнять рефакторинг. Я надеюсь, что благодаря этому вы увидите, каким образом в TDD выполняется контроль над величиной шагов.)
Сказав, что планирую увеличить скорость, я немедленно замедляю процесс разработки. Однако я не планирую замедлять процесс написания кода, который обеспечивает успешное тестирование. Я планирую замедлить процесс написания самих тестов. Некоторые ситуации и некоторые тесты требуют тщательного обдумывания. Каким образом мы планируем представить арифметику со смешанными валютами? Это как раз тот случай, когда требуется тщательное обдумывание.
Наиболее важное и сложное ограничение, с которым нам приходится иметь дело, заключается в том, что мы не хотим, чтобы код нашей системы знал о существовании каких-либо валют. Нам хотелось бы, чтобы система имела дело с деньгами и не зависела от того, в какой валюте они представлены. Возможная стратегия состоит в том, чтобы немедленно преобразовывать любые денежные значения в некоторую единую валюту (попробуйте угадать, какая валюта является самой любимой у американских программистов). Однако подобное решение не позволит нам с легкостью варьировать соотношения (курсы обмена) между различными валютами.
Вместо этого мы хотели бы найти решение, которое позволило бы нам в удобной форме реализовать механизм обменных курсов и при этом обеспечить запись арифметических выражений в форме, близкой к стандартной арифметической записи.
Решение основано на объектах. Если имеющийся объект ведет себя не так, как нам хотелось бы, мы создаем еще один объект, обладающий точно таким же внешним протоколом, но отличающейся внутренней реализацией. Этот шаблон называется «Самозванец» (Imposter).
Возможно, многим это покажется хиромантией. Каким образом в данной ситуации можно использовать шаблон «Самозванец»? Однако я не собираюсь шутить над вами – не существует формулы, позволяющей генерировать гениальные дизайнерские решения. Решение проблемы было придумано Уордом Каннигемом десятилетие назад. Я еще не встречал человека, который независимо от Уорда придумал бы нечто подобное. К сожалению, методика TDD не гарантирует генерацию гениальных идей. Вместе с тем благодаря TDD вы имеете тесты, формирующие вашу уверенность в коде, а также тщательно вылизанный код, – все это является хорошей почвой для возникновения идеи и ее воплощения в реальность.
Итак, что же является решением в нашем случае? Предлагается создать объект, который ведет себя как объект Money, однако соответствует сумме двух объектов Money. Чтобы объяснить эту идею, я пробовал использовать несколько разных метафор. Например, можно рассматривать сумму различных денежных величин как бумажник. В один бумажник можно положить несколько банкнот разных валют и разных достоинств.
Еще одна метафора: выражение. Имеется в виду математическое выражение, например: (2 + 3) * 5. В нашем случае мы имеем дело с денежными величинами, поэтому выражение может быть таким: ($2 + 3 CHF) * 5. Класс Money – это атомарная форма выражения. В результате выполнения любых операций над денежными величинами получается объект класса Expression. Одним из таких объектов может быть объект Sum. После того как операция (например, сложение нескольких значений в разных валютах) выполнена, полученный объект Expression можно привести к некоторой заданной валюте. Преобразование к некоторой валюте осуществляется на основании набора курсов обмена.
Как выразить эту метафору в виде набора тестов? Прежде всего, мы знаем, к чему мы должны прийти:
public void testSimpleAddition() {
…
assertEquals(Money.dollar(10), reduced);
}
Переменная reduced – это объект класса Expression, который создан путем применения обменных курсов в отношении объекта Expression, полученного в результате выполнения математической операции. Кто в реальном мире отвечает за применение обменных курсов? Банк. Стало быть, было бы неплохо, если бы мы могли написать
public void testSimpleAddition() {
…
Money reduced = bank.reduce(sum, "USD");
assertEquals(Money.dollar(10), reduced);
}
(Плохо, когда в одной программе смешиваются две разные метафоры: банк и математическое выражение. Однако сейчас предлагаю не заострять на этом внимания. Сначала воплотим в жизнь то, что запланировали, а затем посмотрим, можно ли улучшить нашу систему с литературно-художественной точки зрения.)
Обратите внимание на важное дизайнерское решение: метод reduce() принадлежит объекту bank. С такой же легкостью мы могли бы написать
…educed = sum.reduce(«USD», bank).
Почему ответственным за выполнение операции reduce() сделан именно объект bank? На самом деле ответ следующий: «Это первое, что пришло мне в голову», однако такой ответ нельзя считать удовлетворительным. Почему мне в голову пришло сделать ответственным за выполнение операции reduce() именно объект класса Bank, а не объект класса Expression? Вот что мне известно на текущий момент:
• Объекты класса Expression, по всей видимости, лежат в самом сердце того, что мы делаем. Я стараюсь делать объекты, являющиеся сердцем системы, как можно менее зависимыми от всего остального мира. Благодаря этому они остаются гибкими в течение длительного времени («гибкие» в данном случае означает «простые для понимания, тестирования и повторного использования»).
• Я могу предположить, что класс Expression будет нести ответственность за множество операций. Значит, мы должны по возможности освободить этот класс от лишней ответственности и переложить часть ответственности на другие классы там, где это допустимо. В противном случае класс Expression разрастется до неконтролируемых размеров.
Конечно, это всего лишь догадки – этого не достаточно, чтобы принимать какие-либо окончательные решения, однако этого вполне достаточно, чтобы я начал двигаться в избранном направлении. Безусловно, если выяснится, что наша система вполне может обойтись без класса Bank, я переложу ответственность за выполнение метода reduce() на класс Expression. Если мы используем объект bank, значит, его необходимо создать:
public void testSimpleAddition() {
…
Bank bank = new Bank();
Money reduced = bank.reduce(sum, "USD");
assertEquals(Money.dollar(10), reduced);
}
Сумма двух объектов Money – это объект класса Expression:
public void testSimpleAddition() {
…
Expression sum = five.plus(five);
Bank bank = new Bank();
Money reduced = bank.reduce(sum, "USD");
assertEquals(Money.dollar(10), reduced);
}
Наконец, операция, в которой мы абсолютно уверены, – создание пяти долларов:
public void testSimpleAddition() {
Money five = Money.dollar(5);
Expression sum = five.plus(five);
Bank bank = new Bank();
Money reduced = bank.reduce(sum, "USD");
assertEquals(Money.dollar(10), reduced);
}
Что надо сделать, чтобы данный код откомпилировался? Для начала создадим интерфейс Expression (мы могли бы создать класс, однако интерфейс обладает существенно меньшим весом):
Expression
interface Expression
Метод Money.plus() должен возвращать значение типа Expression:
Money
Expression plus(Money addend) {
return new Money(amount + addend.amount, currency):
}
Это означает, что класс Money должен реализовать интерфейс Expression (это очень просто, так как в этом интерфейсе пока что нет ни одной операции):
Money
class Money implements Expression
Кроме того, нам потребуется пустой класс Bank:
Bank
class Bank
Добавим в этот класс заглушку для метода reduce():
Bank
Money reduce(Expression source, String to) {
return null;
}
Теперь код компилируется и выдает нам красную полоску. Ура! У нас прогресс! Теперь можем легко подделать реализацию:
Bank
Money reduce(Expression source, String to) {
return Money.dollar(10);
}
Зеленая полоса! Теперь мы готовы выполнить рефакторинг. Но сначала подведем итоги главы. В этой главе мы
• вместо большого теста реализовали меньший тест, чтобы добиться быстрого прогресса (вместо операции $5 + 1 °CHF ограничились более простой операцией $5 + $5);
• основательно обдумали возможные метафоры для нашего предполагаемого дизайна;
• переписали первоначальный тест в свете новой метафоры;
• как можно быстрее добились компиляции теста;
• добились успешного выполнения теста;
• с трепетом посмотрели вперед, оценив объем рефакторинга, который необходим, чтобы сделать реализацию реальной.
7 В переводе на русский язык sum – это сумма. – Примеч. пер.