Моделирование валюты в Haskell
Любопытная задачка – смоделировать валюту, т.е. конвертабельные друг в друга денежные средства. Чтобы уметь делать утверждения о валюте, хранить и конвертировать её, нам нужно определить тип для представления денег в программе.
В первом приближении можно рассуждать так:
data Symbol = USD | RUB | BTC | LTC | ETH | DASH | ZEC
type Money = (Symbol, Double)
Но при таком подходе типы выражений (USD, 100)
(купюра в сто баксов) и (RUB, 1)
будут одинаковы, а значит любые комбинаторы над типом Money
каким-то образом должны
будут учитывать разные денежные знаки (разные конструкторы типа Symbol
). На деле это оказывается неудобно, поскольку в сложной программе запросто могут перепутаться
сто долларов и сто биткоинов (вот бы мне так!)…
Мы бы хотели использовать строгую статическую типизацию языка Haskell для гарантии корректности нашей программы и любых утверждений о деньгах, присутствущих в ней.
Типы, конструкторы⌗
Итак, нам хотелось бы иметь следующие возможности для работы с валютой:
- Различать и хранить доллары отдельно от рублей, гарантированно не смешивая их.
- Конвертировать одну валюту в другую, используя данную таблицу котировок (если такая пара в таблице присутствует).
- Базовая арифметика над валютами: сложение/вычитание валют с одинаковым денежным знаком, умножение/деление валюты на число (скаляр)
Для начала, определим полиморфический тип для представления денег:
data Money a = Money
{ amount :: Double
, symbol :: a
} deriving (Show, Eq)
Строго говоря, символом может быть любой тип, поэтому мы вводим понятие денежного знака: таких типов, которые реализуют тайпкласс CurrencySymbol
:
class (Typeable a, Eq a) => CurrencySymbol a
data USD = USD deriving (Show, Typeable, Eq)
data RUB = RUB deriving (Show, Typeable, Eq)
data EUR = EUR deriving (Show, Typeable, Eq)
data BTC = BTC deriving (Show, Typeable, Eq)
data LTC = LTC deriving (Show, Typeable, Eq)
instance CurrencySymbol USD
instance CurrencySymbol RUB
instance CurrencySymbol EUR
instance CurrencySymbol BTC
instance CurrencySymbol LTC
Деньги можно создать только с помощью экспортируемого из библиотеки умного конструктора makeMoney
, параметризующего тип Money
денежными символами:
makeMoney :: (CurrencySymbol a) => Double -> a -> Money a
makeMoney = Money
Как видно, тип, претендующий на то, чтобы быть денежным знаком, должен реализовывать интерфейс тайпкласса Typeable
из Data.Typeable
. Это крутой тайпкласс, все типы в
котором имеют репрезентацию: функцию typeOf
из (Typeable a) => a
в специальный тип TypeRep
, то есть мы можем сравнивать репрезентации (“названия”) типов. Этот факт
поможет нам в поиске котировок по словарю.
Словарь представляется типом Map
из Data.Map
: ключом выступает пара (TypeRep, TypeRep)
, сомволизирующая базовую валюту
(base currency) и котируемую валюту (foreign currency). Значения в словаре представляют собой прямые курсы между соответствующими парами. Вот тип нашего словаря и его
умный конструктор из ассоциативного списка вида [((typeOf USD, typeOf RUB), 59.24), ((typeOf EUR, typeOf USD), 1.064), ...]
:
data ExchangeRates = ExchangeRates (M.Map (TypeRep, TypeRep) Double)
deriving (Show)
makeExchangeRates :: [((TypeRep, TypeRep), Double)] -> ExchangeRates
makeExchangeRates rs = ExchangeRates $ M.fromList rs
Поиск по словарю котировок и конвертация⌗
Поиск курса, например RUB/USD, заключается в следующем:
- Делается попытка найти в словаре котировок прямой курс по ключу
(RUB, USD)
. - Если в словаре отсутствует такая пара, делается попытка найти обратный курс - выполняется поиск по ключу
(USD, RUB)
(числитель и знаменатель курса при этом переворачиваются)
Из библиотеки юзеру экспортируется только функция findRate
. Ниже я воспользовался тем фактом, что Maybe
– это
аппликативный функтор:
findDirectRate :: (CurrencySymbol a, CurrencySymbol b) => ExchangeRates -> a -> b -> Maybe Double
findDirectRate (ExchangeRates ex) fc tc
| typeOf fc == typeOf tc = Just 1
| otherwise = (M.lookup (typeOf fc, typeOf tc) ex)
findRate :: (CurrencySymbol a, CurrencySymbol b) => ExchangeRates -> a -> b -> Maybe Double
findRate ex fc tc = case findDirectRate ex fc tc of
Nothing -> (/) <$> Just 1 <*> findDirectRate ex tc fc
dr -> dr
Функция конвертации валюты в валюту другого денежного знака тривиальна:
exchangeTo :: (CurrencySymbol a, CurrencySymbol b) => ExchangeRates -> Money a -> b -> Maybe (Money b)
exchangeTo ex m sy = do
r <- findRate ex (symbol m) sy
let a = (amount m) * r
return $ Money a sy
Мы даём словарь, валюту, денежный знак и может быть получаем новую валюту.
Арифметика над валютами⌗
Чтобы складывать/вычитать и умножать/делить валютные суммы на число я создал абстрактный тайпкласс для штук, которые можно “растягивать” или “соединять” вместе. Например, это может быть не только валюта, но также купленный на некоторой майнинг-ферме хэшрейт (за хэшрейтом закреплён алгоритм, поэтому нельзя смешивать, например, SHA256 и Scrypt хэшрейты, как нельзя смешивать доллары и евро).
class ScalableAdditive a where
-- Minimal instance: (^*), (^+^), (^-^)
(^*) :: a -> Double -> a
(^+^) :: a -> a -> a
(^-^) :: a -> a -> a
(^/) :: a -> Double -> a
-- default implementation
i ^/ c = i ^* (1/c)
Деньги реализуют интерфейс ScalableAdditive
проще простого: компилятором Haskell гарантировано, что данные операции будут работать везде в точности так, как ожидается, и
ничего не смешается:
instance ScalableAdditive (Money a) where
(^*) (Money a sy) c = Money (c * a) sy
(^+^) (Money a1 sy) (Money a2 _) = Money (a1 + a2) sy
(^-^) (Money a1 sy) (Money a2 _) = Money (a1 - a2) sy
Пример в ghci:
> exchangeTo sampleRates (makeUSD 1000) BTC
Just (Money {amount = 1.1538164208845387, symbol = BTC})
> makeBTC 1.2 ^* 4
Money {amount = 4.8, symbol = BTC}
> makeRUB 12500 ^+^ makeRUB 12500
Money {amount = 25000.0, symbol = RUB}
Данная простенькая библиотечка доступна на Hackage. Спасибо за внимание.