Книга: Экстремальное программирование. Разработка через тестирование
Назад: 9. Потребность в валюте
Дальше: 11. Корень всего зла

10. Избавление от двух разных версий times()

$5 + 1 °CHF = $10, если курс обмена 2:1
$5 * 2 = $10
Сделать переменную amount закрытым (private) членом
Побочные эффекты в классе Dollar?
Округление денежных величин?
equals()
hashCode()
Равенство значению null
Равенство объектов
5 CHF * 2 = 1 °CHF
Дублирование Dollar/Franc
Общие операции equals()
Общие операции times()
Сравнение франков (Franc) и долларов (Dollar)
Валюта?
Нужен ли тест testFrancMultiplication()?

 

В конце данной главы мы должны получить единый класс Money, соответствующий понятию «деньги». Две реализации метода times() близки друг к другу, однако они не идентичны:

 

Franc
Money times(int multiplier) {
return Money.franc(amount * multiplier);
}

 

Dollar
Money times(int multiplier) {
return Money.dollar(amount * multiplier);
}

 

Увы, я не вижу простого способа добиться идентичности этих методов, однако в некоторых ситуациях, для того чтобы продвинуться дальше, требуется вернуться немного назад, – это напоминает кубик Рубика. Что будет, если мы заменим вызовы фабричных методов операторами new? (Я отлично понимаю, что совсем недавно мы выполнили обратную процедуру – заменили new вызовами фабричных методов. Но что я могу поделать – сейчас мы решаем несколько иную задачу. Понимаю, что это может показаться обескураживающим, однако потерпите немного.)

 

Franc
Money times(int multiplier) {
return new Franc(amount * multiplier, "CHF");
}

 

Dollar
Money times(int multiplier) {
return new Dollar(amount * multiplier, "USD");
}

 

Мы абсолютно уверены, что в экземплярах класса Franc значение поля currency всегда будет равно «CHF», поэтому можем написать:

 

Franc
Money times(int multiplier) {
return new Franc(amount * multiplier, currency);
}

 

Сработало! Теперь тот же трюк можно проделать и в отношении класса Dollar:

 

Dollar
Money times(int multiplier) {
return new Dollar(amount * multiplier,currency);
}

 

Мы почти закончили. Имеет ли значение, что мы используем в данном случае – Franc или Money? Об этом можно рассуждать в течение некоторого времени исходя из имеющихся знаний о внутреннем устройстве нашей системы, однако у нас есть чистый код и тесты, которые дают нам уверенность в том, что код работает так, как надо. Вместо того чтобы тратить несколько минут на рассуждения, мы можем спросить об этом компьютер. Для этого достаточно внести интересующие нас изменения в код и запустить тесты. Обучая методике TDD, я наблюдаю подобную ситуацию постоянно – опытные умные программисты тратят от 5 до 10 минут на обсуждение вопроса, на который компьютер может дать ответ в течение 15 секунд. Если у вас нет тестов, вам остается только размышлять и предполагать. Если же у вас есть тесты, вместо того, чтобы напрасно тратить время, вы можете провести быстрый эксперимент. Как правило, если у вас есть тесты, быстрее спросить компьютер.
Чтобы провести интересующий нас эксперимент, модифицируем код так, чтобы метод Franc.times() возвращал значение типа Money:

 

Franc
Money times(int multiplier) {
return new Money (amount * multiplier, currency);
}

 

В ответ компилятор сообщил, что Money должен быть конкретным (не абстрактным) классом:

 

Money
class Money
Money times(int amount) {
return null;
}

 

Получаем красную полоску и сообщение об ошибке: «expected:<Money.Franc@31aebf> but was:<Money.Money@478a43>». Не очень-то информативно. Не так информативно, как нам хотелось бы. Чтобы получить более осмысленное сообщение об ошибке, добавим метод toString():

 

Money
public String toString() {
return amount + " " + currency;
}

 

О, ужас! Код без тестов?! Допустимо ли такое? Конечно же, прежде чем писать код метода toString, мы должны были написать соответствующий тест, однако
• мы увидим результаты работы этого метода на экране;
• метод toString() используется только для отладки, поэтому риск, связанный с потенциальными ошибками, невелик;
• перед нами красная полоса, а мы предпочитаем не писать новых тестов, пока не избавимся от красной полосы.
Обстоятельства приняты к сведению.
Теперь сообщение об ошибке изменилось: "expected:<1 °CHF> but was:<1 °CHF>". Выглядит осмысленней, однако сбивает с толку. В двух объектах хранятся одни и те же данные, однако при этом объекты не считаются равными. Проблема кроется в реализации метода equals():

 

Money
public boolean equals(Object object) {
Money money = (Money) object;
return amount == money.amount
&& getClass(). equals(money.getClass());
}

 

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

 

Franc
Money times(int multiplier) {
return new Franc (amount * multiplier, currency);
}

 

Перед нами снова зеленая полоса. Мы попали в ситуацию, когда объект Franc(10,"CHF") не равен объекту Money(10,"CHF"), хотя нам хотелось бы, чтобы эти объекты были равны. Превращаем наше желание в тест:

 

public void testDifferentClassEquality() {
assertTrue(new Money(10, "CHF"). equals(new Franc(10, "CHF")));
}

 

Как и ожидалось, тест потерпел неудачу. Код метода equal() должен сравнивать идентификаторы валют, а не имена классов:

 

Money
public boolean equals(Object object) {
Money money = (Money) object;
return amount == money.amount
&& currency(). equals(money.currency());
}

 

Теперь метод Franc.times() может возвращать значение Money, и все тесты будут по-прежнему успешно выполняться:

 

Franc
Money times(int multiplier) {
return new Money(amount * multiplier, currency);
}

 

Сработает ли этот трюк для метода Dollar.times()?

 

Dollar
Money times(int multiplier) {
return new Money (amount * multiplier, currency);
}

 

Да! Теперь две реализации абсолютно идентичны, и мы можем переместить их в базовый класс.

 

Money
Money times(int multiplier) {
return new Money(amount * multiplier, currency);
}
$5 + 1 °CHF = $10, если курс обмена 2:1
$5 * 2 = $10
Сделать переменную amount закрытым (private) членом
Побочные эффекты в классе Dollar?
Округление денежных величин?
equals()
hashCode()
Равенство значению null
Равенство объектов
5 CHF * 2 = 1 °CHF
Дублирование Dollar/Franc
Общие операции equals()
Общие операции times()
Сравнение франков (Franc) и долларов (Dollar)
Валюта?
Нужен ли тест testFrancMultiplication()?

 

Метод умножения там, где ему следует быть, теперь мы готовы удалить ненужные нам производные классы.
В данной главе мы
• сделали идентичными две реализации метода times(), для этого мы избавились от вызовов фабричных методов в них, и заменили константы переменными;
• добавили в класс отладочный метод toString() без теста;
• попробовали модифицировать код (заменили тип Franc возвращаемого значения на Money) и обратились к тестам, чтобы узнать, сработает ли это;
• отменили изменения и написали еще один тест, добились успешного выполнения теста и вновь применили изменения.
Назад: 9. Потребность в валюте
Дальше: 11. Корень всего зла