[Из песочницы] Money-моноид
Mark Seeman рассказывает о функциональном программировании просто и быстро. Для этого он начал писать цикл статей, посвященных связи между паттернами проектирования и теорией категорий. Любой ООПшник, у которого есть 15 минут свободного времени, сможет заполучить в свои руки принципиально новый набор идей и инсайтов, касающихся не только функциональщины, но и правильного объектно-ориентированного дизайна. Решающим фактором является то, что все примеры — это реальный код на C#, F# и Haskell.
Этот хабрапост — вторая статья из цикла статей о моноидах:
- Моноиды, полугруппы и все-все-все
- Money-моноид
- Convex hull monoid
- Tuple monoids
- Function monoids
- Endomorphism monoid
- Monoids accumulate
Прежде чем начать, хотелось бы сделать небольшое отступление относительно названия статьи. В 2003-м году вышла, ставшая уже бестселлером, книга Кента Бека: «Экстремальное программирование: разработка через тестирование», которая в оригинале называется «Test-Driven Development by example». Одним из таких «example» стал «Money example» — пример написания и рефакторинга приложения, которое умеет выполнять мультивалютные операции, например сложение 10 долларов и 10 франков. Название данной статьи является отсылкой к данной книге, и я настоятельно рекомендую ознакомиться с ее первой частью, чтобы лучше понимать, о чем идет речь в статье.
«Money example» Кента Бека имеет некоторые любопытные свойства.
Если коротко, моноид — это ассоциативная бинарная операция, имеющая нейтральный элемент (иногда также называемый единицей).
В первой части своей книги Кент Бек исследует возможность создания простого и гибкого «денежного API», используя принцип «разработка через тестирование». В итоге у него получается решение, дизайн которого требует дальнейшей проработки.
API Кента Бека
В этой статье используется код из книги Кента Бека, переведенный Яваром Амином на язык С# (оригинальный код был написан на Java), который я форкнул и дополнил.
Кент Бек в своей книге занимался разработкой объектно-ориентированного API, способного обрабатывать деньги в нескольких валютах, с возможностью работы с выражениями (expressions), такими как »5 USD + 10 CHF». К концу первой части он создает интерфейс, который (переведенный на язык C#), выглядит так:
public interface IExpression
{
Money Reduce(Bank bank, string to);
IExpression Plus(IExpression addend);
IExpression Times(int multiplier);
}
Метод Reduce
преобразует объект IExpression
в некую валюту (параметр to
), представленную, как объект Money
. Это полезно, если у вас есть выражение, которое имеет несколько валют.
Метод Plus
прибавляет объект IExpression
к текущему объекту IExpression
и возвращает новый IExpression
. Это могут быть деньги как в одной валюте, так и в нескольких.
Метод Times
умножает IExpression
на определенный множитель. Вы, наверное, заметили, что во всех примерах мы используем целые числа для множителя и суммы. Я думаю, Кент Бек сделал это, чтобы не усложнять код, но в реальной жизни при работе с деньгами мы бы использовали дробные числа (например, decimal
).
Метафора «expression» заключается в том, что мы можем моделировать работу с деньгами, как работу с математическими выражениями. Простое выражение будет выглядеть как 5 USD, но также могут быть и 5 USD + 10 CHF или 5 USD + 10 CHF + 10 USD. Несмотря на то, что вы легко можете вычислить (reduce) некоторые простые выражения, например, 5 CHF + 7 CHF, вы не можете вычислить выражение 5 USD + 10 CHF, если у вас нет обменного курса. Вместо того, чтобы пытаться сразу вычислить денежные операции, в данном проекте мы создаем дерево выражений, а уже затем производим его преобразование. Звучит знакомо, не так ли?
Кент Бек в своих примерах реализует интерфейс IExpression
двумя классами:
Money
представляет собой некоторое количество в денег конкретной валюте. Он содержит свойства «Amount» (количество) и «Currency» (название валюты). Это — ключевой момент:Money
является объектом-значением (value object).Sum
является суммой двух других объектовIExpression
. Он содержит два слагаемых, именуемых Augend (первое слагаемое) и Addend (второе слагаемое).
Если мы захотим описать выражение 5 USD + 10 CHF, оно будет выглядеть примерно так:
IExpression sum = new Sum(Money.Dollar(5), Money.Franc(10));
где Money.Dollar
и Money.Franc
— это два статических фабричных метода, которые возвращают объекты Money
.
Ассоциативность
Вы заметили, что Plus
— это бинарная операция? Можем ли мы считать ее моноидом?
Чтобы быть моноидом, она должна удовлетворять законам моноида, первый из которых гласит, что операция должна быть ассоциативной. Это означает, что для трех объектов IExpression
, x
, y
и z
, выражение x.Plus(y).Plus(z)
должно быть равно x.Plus(y.Plus(z))
. Как мы должны понимать здесь равенство? Возвращаемое значение метода Plus
— это интерфейс IExpression
, а интерфейсы не имеют такого понятия, как «равенство». Значит, либо, равенство зависит от конкретных реализаций (Money
и Sum
), где мы можем определить соответствующие методы, либо мы можем использовать тестовое соответствие (паттерн тестирования, test-specific equality, — прим. пер.).
Библиотека для тестирования xUnit.net поддерживает тестовое соответствие через реализацию пользовательских компараторов (для детального изучения возможностей юнит-тестирования автор предлагает пройти его курс Advanced Unit Testing на Pluralsight.com). Однако, в оригинальном Money API уже имеется возможность сравнения объектов типа IExpression
!
Метод Reduce
может преобразовать любой IExpression
в объект типа Money
(то есть к единой валюте), а поскольку Money
— объект-значение, он имеет структурное равенство (подробнее о value objects и их особенностях можно почитать, например, тут). И мы можем использовать это свойство для сравнения объектов IExpression
. Все, что нам нужно, это обменный курс.
В своей книге Кент Бек использует обменный курс 2:1 между CHF и USD. На момент написания этой статьи обменный курс составлял 0,96 швейцарского франка к доллару, но поскольку код примера везде использует целые числа для операций с деньгами, мне придется округлить курс до 1:1. Это, однако, довольно дурацкий пример, поэтому вместо этого я буду придерживаться первоначального курса обмена 2:1.
Теперь давайте напишем адаптер между Reduce
и xUnit.net в виде класса IEqualityComparer
:
public class ExpressionEqualityComparer : IEqualityComparer
{
private readonly Bank bank;
public ExpressionEqualityComparer()
{
bank = new Bank();
bank.AddRate("CHF", "USD", 2);
}
public bool Equals(IExpression x, IExpression y)
{
var xm = bank.Reduce(x, "USD");
var ym = bank.Reduce(y, "USD");
return object.Equals(xm, ym);
}
public int GetHashCode(IExpression obj)
{
return bank.Reduce(obj, "USD").GetHashCode();
}
}
Вы заметили, что компаратор использует объект Bank
с обменным курсом 2:1. Класс Bank
— еще один объект из кода Кента Бека. Сам он не реализует какой-либо интерфейс, но используется как аргумент метода Reduce
.
Чтобы сделать код нашего теста более читаемым, добавим вспомогательный статический класс:
public static class Compare
{
public static ExpressionEqualityComparer UsingBank =
new ExpressionEqualityComparer();
}
Это позволит нам написать ассерт, который проверяет равенство для операции ассоциативности:
Assert.Equal(
x.Plus(y).Plus(z),
x.Plus(y.Plus(z)),
Compare.UsingBank);
В моем форке кода Явара Амина я добавил этот ассерт к тесту FsCheck, и он используется для всех объектов Sum
и Money
, которые генерирует FsCheck.
В текущей реализации IExpression.Plus
ассоциативен, но стоит отметить, что данное поведение не гарантируется, и вот почему: IExpression
является интерфейсом, поэтому кто-нибудь может легко добавить третью реализацию, которая будет нарушать ассоциативность. Условно мы будем считать, что операция Plus
ассоциативна, но ситуация деликатная.
Нейтральный элемент
Если мы согласимся с тем, что IExpression.Plus
ассоциативен, то это кандидат в моноиды. Если существует нейтральный элемент, то это точно моноид.
Кент Бек не добавлял в свои примеры нейтральный элемент, поэтому добавим его сами:
public static class Plus
{
public readonly static IExpression Identity = new PlusIdentity();
private class PlusIdentity : IExpression
{
public IExpression Plus(IExpression addend)
{
return addend;
}
public Money Reduce(Bank bank, string to)
{
return new Money(0, to);
}
public IExpression Times(int multiplier)
{
return this;
}
}
}
Поскольку может существовать только один нейтральный элемент, имеет смысл сделать его синглтоном. Приватный класс PlusIdentity
является новой реализацией IExpression
, которая ничего не делает.
Метод Plus
просто возвращает входное значение. Это такое же поведение, как и для сложения чисел. При сложении, ноль — нейтральный элемент, и то же самое имеет место и здесь. Это более явно видно в методе Reduce
, где вычисление «нейтральной» валюты просто сводится к нулю в запрошенной валюте. Наконец, если вы умножаете нейтральный элемент на что-нибудь, вы получаете нейтральный элемент. Здесь, что интересно, PlusIdentity
ведет себя аналогично нейтральному элементу для операции умножения (1).
Теперь мы напишем тесты для любого IExpression
x
:
Assert.Equal(x, x.Plus(Plus.Identity), Compare.UsingBank);
Assert.Equal(x, Plus.Identity.Plus(x), Compare.UsingBank);
Это property-тест, и он выполняется для всех x
, сгенерированных FsCheck. Осторожность, применимая к ассоциативности, также применима и здесь: IExpression
является интерфейсом, поэтому вы не можете быть уверены, что Plus.Identity
будет нейтральным элементом для всех реализаций IExpression
которые кто-то может создать, но для трех существующих реализаций моноидные законы сохраняются.
Теперь мы можем утверждать, что операция IExpression.Plus
— моноид.
Умножение
В арифметике оператор умножения называется «раз» (в англ. «times» — прим. пер.). Когда вы пишете 3×5, это буквально означает, что у вас есть 3
пять раз (или 5
три раза?). Другими словами: 3 * 5 = 3 + 3 + 3 + 3 + 3
Существует ли аналогичная операция для IExpression
?
Возможно, мы cможем найти подсказку в языке Haskell, где моноиды и полугруппы являются частью основной библиотеки. Позднее вы узнаете о полугруппах, но на данный момент просто отметим, что класс Semigroup
определяет функцию stimes
, которая имеет тип Integral b => b -> a -> a
. Это означает, что для любого целочисленного типа (16-разрядное целое, 32-разрядное целое и т.д.) функция stimes
принимает целое число и значение a
и «умножает» значение на число. Здесь a
— тип, для которого существует бинарная операция.
В языке C# функция stimes
будет выглядеть как метод класса Foo
:
public Foo Times(int multiplier)
Я назвал метод Times
, а не STimes
, так как сильно подозреваю, что буква s
в названии stimes
означает Semigroup
. И обратите внимание, что этот метод имеет такую же сигнатуру, что и метод IExpression.Times
.
Если можно определить универсальную реализацию такой функции в Haskell, можно ли сделать то же самое в C#? В классе Money
мы можем реализовать Times
, используя метод Plus
:
public IExpression Times(int multiplier)
{
return Enumerable
.Repeat((IExpression)this, multiplier)
.Aggregate((x, y) => x.Plus(y));
}
Статический метод Repeat
библиотеки LINQ возвращает this
столько раз, сколько указано в multiplier
. Возвращаемое значение представляет собой Enumerable
, но в соответствии с интерфейсом IExpression
Times
должен возвращать одно значение IExpression
. Воспользуемся методом Aggregate
для многократного объединения двух значений IExpression
(x
и y
) в одно, используя метод Plus
.
Эта реализация вряд ли будет так же эффективна, как предыдущая, конкретная реализация, но здесь речь идет не об эффективности, а об общей, переиспользуемой абстракции. Точно такую же реализацию можно использовать для метода Sum.Times
:
public IExpression Times(int multiplier)
{
return Enumerable
.Repeat((IExpression)this, multiplier)
.Aggregate((x, y) => x.Plus(y));
}
Это точно такой же код, что и для Money.Times
. Вы также можете скопировать и вставить этот код в PlusIdentity.Times
, но я не буду повторять его здесь, потому что это тот же код, что и выше.
Это значит, что вы можете удалить метод Times
из IExpression
:
public interface IExpression
{
Money Reduce(Bank bank, string to);
IExpression Plus(IExpression addend);
}
вместо этого реализовав его как метод-расширение (extension method):
public static class Expression
{
public static IExpression Times(this IExpression exp, int multiplier)
{
return Enumerable
.Repeat(exp, multiplier)
.Aggregate((x, y) => x.Plus(y));
}
}
Это сработает, потому что любой объект IExpression
имеет метод Plus
.
Как я уже сказал, это, вероятно, будет менее эффективно, чем специализированные реализации Times
. В Haskell это устраняется путем включения stimes
в класс типов (typeclass), так что разработчики могут реализовать более эффективный алгоритм, чем реализация по умолчанию. В C# такой же эффект может быть достигнут путем реорганизации IExpression
в абстрактный базовый класс с использованием Times
как публичного виртуального (overridable) метода.
Проверка корректности
Поскольку язык Haskell имеет более формальное определение моноида, мы можем попытаться переписать API Кента Бека на Haskell, просто как доказательство самой идеи (proof of concept). В своей последней модификации мой форк на C# имеет три реализации IExpression
:
Money
Sum
PlusIdentity
Поскольку интерфейсы расширяемы, мы должны позаботиться об этом, поэтому в Haskell мне кажется более безопасным реализовать эти три подтипа как тип sum
:
data Expression = Money { amount :: Int, currency :: String }
| Sum { augend :: Expression, addend :: Expression }
| MoneyIdentity
deriving (Show)
Более формально мы можем сделать это, используя Monoid
instance Monoid Expression where
mempty = MoneyIdentity
mappend MoneyIdentity y = y
mappend x MoneyIdentity = x
mappend x y = Sum x y
Метод Plus
из нашего примера на C# здесь представлен функцией mappend
. Единственным оставшимся членом класса IExpression
является метод Reduce
, который можно реализовать следующим образом:
import Data.Map.Strict (Map, (!))
reduce :: Ord a => Map (String, a) Int -> a -> Expression -> Int
reduce bank to (Money amt cur) = amt `div` rate
where rate = bank ! (cur, to)
reduce bank to (Sum x y) = reduce bank to x + reduce bank to y
reduce _ _ MoneyIdentity = 0
Обо всем остальном позаботится механизм тайпклассов, так что теперь мы можем воспроизвести один из тестов Кента Бека следующим образом:
λ> let bank = fromList [(("CHF","USD"),2), (("USD", "USD"),1)]
λ> let sum = stimesMonoid 2 $ MoneyPort.Sum (Money 5 "USD") (Money 10 "CHF")
λ> reduce bank "USD" sum
20
Так же, как stimes
работает для любой Semigroup
, stimesMonoid
определен для любого Monoid
, и поэтому мы также можем использовать его с Expression
.
С историческим обменным курсом 2:1,»5 долларов + 10 швейцарских франков умножить на 2» как раз и будет 20 долларов.
Резюме
В 17-й главе своей книги Кент Бек описывает, как он неоднократно пытался придумать различные варианты Money API, прежде чем попробовал сделать его «на выражениях», который он в итоге и использовал в книге. Другими словами, у него был большой опыт, как с конкретно этой проблемой, так и с программированием в целом. Очевидно, что эту работу проделывал высококвалифицированный программист.
И мне показалось любопытным, что он, кажется, интуитивно приходит к «моноидному дизайну». Возможно, он специально это сделал (он не говорит об этом в книге), поэтому я скорее предположу, что он пришел к такому дизайну просто потому, что осознал его превосходство. Именно по этой причине мне кажется интересным рассматривать конкретно этот пример, как моноид, потому что это дает представление о том, что есть что-то в высшей степени понятное в отношении API на основе моноида. Концептуально — это просто «небольшое дополнение».
В данной статье мы вернулись к коду девятилетней (на самом деле, 15-летней, — прим. пер.) давности, чтобы идентифицировать его как моноид. В следующей статье я собираюсь пересмотреть код 2015-го года.
Заключение
На этом мы завершаем эту статью. Впереди еще очень много информации, которая будет публиковаться так же, как в оригинале — в виде последовательных постов на Хабре, связанных обратными ссылками. Здесь и далее: оригиналы статей — Mark Seemann 2017, переводы делаются силами java-сообщества, переводчик — Евгений Федоров.