Любопытная задачка – смоделировать валюту, т.е. конвертабельные друг в друга денежные средства. Чтобы уметь делать утверждения о валюте, хранить и конвертировать её, нам нужно определить тип для представления денег в программе.

В первом приближении можно рассуждать так:

data Symbol = USD | RUB | BTC | LTC | ETH | DASH | ZEC

type Money = (Symbol, Double)

Но при таком подходе типы выражений (USD, 100) (купюра в сто баксов) и (RUB, 1) будут одинаковы, а значит любые комбинаторы над типом Money каким-то образом должны будут учитывать разные денежные знаки (разные конструкторы типа Symbol). На деле это оказывается неудобно, поскольку в сложной программе запросто могут перепутаться сто долларов и сто биткоинов (вот бы мне так!)…

Мы бы хотели использовать строгую статическую типизацию языка Haskell для гарантии корректности нашей программы и любых утверждений о деньгах, присутствущих в ней.

Типы, конструкторы

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

  1. Различать и хранить доллары отдельно от рублей, гарантированно не смешивая их.
  2. Конвертировать одну валюту в другую, используя данную таблицу котировок (если такая пара в таблице присутствует).
  3. Базовая арифметика над валютами: сложение/вычитание валют с одинаковым денежным знаком, умножение/деление валюты на число (скаляр)

Для начала, определим полиморфический тип для представления денег:

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, заключается в следующем:

  1. Делается попытка найти в словаре котировок прямой курс по ключу (RUB, USD).
  2. Если в словаре отсутствует такая пара, делается попытка найти обратный курс - выполняется поиск по ключу (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. Спасибо за внимание.