前回の記事の続きになります。前回レート計算の実装が課題でしたが辞書型のお陰で思いの外、簡単に実装がおこなえました。今回でさらなる抽象化を進め、複雑なテストを増やすことで最終回となります。ここまで見てくださった方(いれば)、本当にありがとうございました!
15章
遂に、本来やりたかった他貨幣間での足し算の実装をおこないます。それにともない、各関数をMoney
型からExpression
に抽象度を引き上げていきます。
- $5 + 10 CHF = $10 (レートが2:1の場合)
-
$5 + $5 = $10 - $5 + $5 がMoneyを返す
-
Bank.reduce(Money) -
Moneyを変換して換算を行う -
Reduce(Bank, Currency)
追加される他貨幣間の足し算のテストは以下のものになります。
+ describe "Mixed Addition"
+ [ "CHF ~> USD 2"
+ => let
+ fiveBucks =
+ dollar 5
+
+ tenFrancs =
+ franc 10
+
+ bank =
+ Bank.bank |> Bank.addRate (CHF ~> USD) 2
+
+ result =
+ Bank.reduce (fiveBucks $+ tenFrancs) USD bank
+ in
+ dollar 10 === result
+ ]
Moneyの定義では、dollar
とfranc
関数がMoney
型を返したいたものをSingle
に噛ませてExpression
型に引き上げます。そして、今までMoney型で書いていた関数を全てExpression型に引き上げます(結構大工事です)。その場合すべての関数でパターンマッチをする必要があり面倒ですが、あとから解消するための手立てが出てくるのでお待ち下さい。ただcurrency
関数は、
Sum(足し算)の場合には、後ろの項の貨幣が取得されるという実装詳細が生じてしまいます。
- dollar : Amount -> Money
+ dollar : Amount -> Expression
dollar amount =
- Money amount USD
+ Single <| Money amount USD
- franc : Amount -> Money
+ franc : Amount -> Expression
franc amount =
- Money amount CHF
+ Single <| Money amount CHF
- times : Int -> Money -> Money
- times multiplier (Money amount currency) =
- Money (multiplier * amount) currency
+ times : Expression -> Int -> Expression
+ times exp multiplier =
+ case exp of
+ Single (Money amnt crncy) ->
+ Single <| Money (amnt * multiplier) crncy
+
+ Sum exp1 exp2 ->
+ let
+ mlp_ e =
+ times e multiplier
+ in
+ Sum (mlp_ exp1) (mlp_ exp2)
- ($+) : Money -> Money -> Expression
+ ($+) : Expression -> Expression -> Expression
- ($+) money1 money2 =
- Expression.sum money1 money2
+ ($+) exp1 exp2 =
+ Sum exp1 exp2
- amount : Money -> Amount
- amount (Money amount _) =
- amount
- currency : Money -> Currency
+ currency : Expression -> Currency
- currency (Money _ currency) =
- currency
+ currency expression =
+ case expression of
+ Single (Money _ currency) ->
+ currency
+ Sum _ exp2 ->
+ currency exp2
Bankのreduce
関数もMoney
型を返却していたものをExpression
型のまま返すようにしています。amount
関数は、Bankのみ(レート計算が行われた前提)で使われるためMoneyから移行をしました。
- reduce : Expression -> Currency -> Bank -> Money
+ reduce : Expression -> Currency -> Bank -> Expression
reduce source to bank =
case source of
- Single (Money amount currency) ->
+ Single (Money amnt currency) ->
let
r =
rate (currency ~> to) bank
in
- Money (amount // r) to
+ Single <| Money (amnt // r) to
Sum exp1 exp2 ->
- Money (sum_ exp1 exp2 to bank) to
+ Single <| Money (sum_ exp1 exp2 to bank) to
+
+
+ amount : Expression -> Int
+ amount expression =
+ case expression of
+ Single (Money amnt _) ->
+ amnt
+
+ Sum exp1 exp2 ->
+ (amount exp1) + (amount exp2)
書籍の方ではここまで完全な抽象化は行っていないのですが、Elmでは完全な形で移行しないと上手く行かない部分が生じてしまったのでこのような形になりました。また、この抽象化で、16章の内容も終わっています。しかし、コードが冗長な部分をリファクタリングをしたかったため、もう少し15章の内容を進めたいと思います。
これまでの内容でMoney
からExpression
に引き上げたので、Expression.elmに内容を移したほうがモジュールとしてスッキリする気がしてきました。また、$+
に合わせて、timesも$*
という特殊な演算子に変えてしまいましょう。
+ ($+) : Expression -> Expression -> Expression
+ ($+) exp1 exp2 =
+ Sum exp1 exp2
+
+
+ ($*) : Expression -> Int -> Expression
+ ($*) exp multiplier =
+ case exp of
+ Single (Money amnt crncy) ->
+ Single <| Money (amnt * multiplier) crncy
+
+ Sum exp1 exp2 ->
+ let
+ mlp_ e =
+ e $* multiplier
+ in
+ Sum (mlp_ exp1) (mlp_ exp2)
+
+
+ currency : Expression -> Currency
+ currency expression =
+ case expression of
+ Single (Money _ currency) ->
+ currency
+
+ Sum _ exp2 ->
+ currency exp2
+
+
+ amount : Expression -> Int
+ amount expression =
+ case expression of
+ Single (Money amnt _) ->
+ amnt
+
+ Sum exp1 exp2 ->
+ (amount exp1) + (amount exp2)
テストも演算子で書き換えてみましょう。自然な計算のように見えます!
- => (dollar 5 |> times 2)
+ => (dollar 5 $* 2)
- => (dollar 5 |> times 3)
+ => (dollar 5 $* 3)
ここでExpressionの関数に注目してみましょう。2つのタイプの関数があることがわかります。($*)
のように、Expressionが新たなExpressionを返す関数と、currency
やamount
のように別な型に収束するタイプの関数です。
($*) : Expression -> Int -> Expression
currency : Expression -> Currency
amount : Expression -> Int
更に内部構造に注目すると以下の2つの関数のように括り出すことができます。この2つの関数は関数型由来のものですが、本来の性質と若干違う部分があるので詳細の説明は省きます。
map : (Money -> Money) -> Expression -> Expression
map f exp =
case exp of
Single money ->
Single <| f money
Sum exp1 exp2 ->
Sum (map f exp1) (map f exp2)
fold : (Money -> a -> a) -> a -> Expression -> a
fold f init exp =
case exp of
Single money ->
f money init
Sum exp1 exp2 ->
(amount exp1) + (amount exp2)
fold f (fold f init exp1) exp2
この2つの関数を利用すると、なんとなんと1行で実装が終わってしまいます!2つの関数に当てはめながらどんな風に展開されるか試していただけると楽しめると思います。
($*) : Expression -> Int -> Expression
($*) exp multiplier =
map (\(Money amnt c) -> Money (amnt * multiplier) c) exp
fold関数は初期値が必要とされるのですが、貨幣(Currency)の初期値というものは存在しないので、USDと仮でさせていただきました。
currency : Expression -> Currency
currency exp =
fold (\(Money _ c) _ -> c) USD exp
amount exp =
fold (\(Money amnt _) sum -> sum + amnt) 0 exp
amount関数のみ、どのように展開されるかやってみましょう。非常にシンプルなパターンですが、以下のように展開されます。
amount Single(Money 10 USD)
= fold (\(Money amnt _) sum -> sum + amnt) 0 Single (Money 10 USD)
= (\(Money amnt _) sum -> sum + amnt) (Money 10 USD) 0
= (\(Money 10 _) 0 -> 0 + 10)
= 0 + 10
= 10
これでリファクタリングは終わりとなります。この先、このプロジェクトを拡張するときも、同じパターンに適用できる場合は、関数を渡すだけで処理を記述することができます。
16章
15章の説明で述べた通りです。実装はすでに終えているので、テストを追加するのみです。演算子のお陰で貨幣の計算が自然に記述できるのが嬉しいですね。
+ , describe "Sum Plus Money"
+ [ "($5 + 10 CHF) + $5"
+ => let
+ fiveBucks =
+ dollar 5
+
+ tenFrancs =
+ franc 10
+
+ bank =
+ Bank.bank |> Bank.addRate (CHF ~> USD) 2
+
+ sum =
+ Bank.reduce ((fiveBucks $+ tenFrancs) $+ fiveBucks) USD bank
+
+ result =
+ Bank.reduce sum USD bank
+ in
+ dollar 15 === result
+ ]
+ , describe "Sum Times"
+ [ "($5 + 10 CHF) * 2"
+ => let
+ fiveBucks =
+ dollar 5
+
+ tenFrancs =
+ franc 10
+
+ bank =
+ Bank.bank |> Bank.addRate (CHF ~> USD) 2
+
+ sum =
+ Bank.reduce ((fiveBucks $+ tenFrancs) $* 2) USD bank
+
+ result =
+ Bank.reduce sum USD bank
+ in
+ dollar 20 === result
+ ]
お疲れ様です。最終実装になります。
まとめ
MoneyからExpression型に引き上げ、最後の実装にまで一気に解決しました。また処理の性質に注目し、mapとfoldという関数を定義しExpressionの関数を短くすることができました。また今回のTdd Elm連載を通した、全体的な総括とオブジェクト指向のコードをElmコードに落とすテクニックをElm Advent Calendar 2017にて、掲載したいと思っています。もしよろしければ、そちらでまたお会いしましょう。今まで記事を見てくださって本当にありがとうございました!
追記
投稿後 @miyamo_madoka さんよりPRをいただきました。実装の怪しげな部分が一切なくなり、とてもスッキリした実装にしていただきました。私の実装で大きく間違っている部分が抽象度を気にするあまりBankのreduce : Bank -> Currency -> Expression -> Expression
の最後の型がExpression
のままであることでした。reduce : Bank -> Currency -> Expression -> Money
のようにMoney型に戻してあげることで、多くの問題は解決されたと思います。例えば通貨currency
はMoney
型しか取得できないのが当たり前で、抽象化されたExpression
型では、他通貨同士の足し算のケースもあるため一意に定まらないと言う問題が解消されました(amount
も同様)。さらに、PRをいただいた後、私の実装と見比べいくつかリファクタリングをおこない、本当に最後の実装としました。ご指摘本当にありがとうございました。せっかくなので実装をすべて載せて終わりたいと思います。
module Money.Model exposing (Money(..), Amount, Currency(..))
type alias Amount =
Int
type Currency
= USD
| CHF
type Money
= Money Amount Currency
module Money.Money exposing (dollar, franc, currency, amount)
import Money.Model exposing (Money(..), Amount, Currency(..))
dollar : Amount -> Money
dollar amount =
Money amount USD
franc : Amount -> Money
franc amount =
Money amount CHF
currency : Money -> Currency
currency (Money _ c) =
c
amount : Money -> Amount
amount (Money a _) =
a
module Bank exposing (bank, rate, Bank, addRate, (~>), reduce)
import Money.Model exposing (Currency, Money(..))
import Money.Money as Money
import EveryDict exposing (EveryDict)
import Expression exposing (Expression(..))
type alias Bank =
EveryDict ( Currency, Currency ) Int
bank : Bank
bank =
EveryDict.empty
(~>) : a -> b -> ( a, b )
(~>) a b =
( a, b )
addRate : ( Currency, Currency ) -> Int -> Bank -> Bank
addRate fromTo rate =
EveryDict.insert fromTo rate
rate : ( Currency, Currency ) -> Bank -> Int
rate (( from, to ) as fromto) bank =
if from == to then
1
else
case EveryDict.get fromto bank of
Just r ->
r
Nothing ->
Debug.crash <| (toString from) ++ " ~> " ++ (toString to) ++ " is not found."
reduce : Bank -> Currency -> Expression -> Money
reduce bank to exp =
case exp of
Single (Money amnt source) ->
let
r =
rate (source ~> to) bank
in
Money (amnt // r) to
Sum exp1 exp2 ->
let
amnt_ e =
Money.amount <| reduce bank to e
a1 =
amnt_ exp1
a2 =
amnt_ exp2
in
Money (a1 + a2) to
module Expression exposing (Expression(..), single, ($+), ($*))
import Money.Model exposing (Money(..), Currency)
type Expression
= Single Money
| Sum Expression Expression
single : Money -> Expression
single =
Single
($+) : Expression -> Expression -> Expression
($+) =
Sum
($*) : Expression -> Int -> Expression
($*) exp multiplier =
map (\(Money amnt c) -> Money (amnt * multiplier) c) exp
map : (Money -> Money) -> Expression -> Expression
map f exp =
case exp of
Single money ->
Single <| f money
Sum exp1 exp2 ->
Sum (map f exp1) (map f exp2)
infixl 6 $+
infixl 7 $*
module Tests exposing (..)
import Test exposing (..)
import TestExp exposing (..)
-- Test target modules
import Money.Money as Money exposing (..)
import Money.Model exposing (..)
import Bank exposing (..)
import Expression exposing (..)
all : Test
all =
describe "Money Test"
[ describe "Dollar"
[ "Multiplication1"
=> ((single <| dollar 5) $* 2)
=== (single <| dollar 10)
, "Multiplication2"
=> ((single <| dollar 5) $* 3)
=== (single <| dollar 15)
, "Currency"
=> (currency <| dollar 5)
=== USD
]
, describe "Franc"
[ "Multiplication1"
=> ((single <| franc 5) $* 2)
=== (single <| franc 10)
, "Multiplication2"
=> ((single <| franc 5) $* 3)
=== (single <| franc 15)
, "Currency"
=> (currency <| franc 5)
=== CHF
]
, describe "Equality"
[ "Equality1"
=> dollar 10
=== dollar 10
, "Equality2"
=> franc 10
=== franc 10
, "Equality3"
=> dollar 1
/== franc 1
, "Equality4"
=> dollar 1
/== dollar 2
, "Equality5"
=> franc 1
/== franc 2
]
, describe "Simple Addition"
[ "addition1"
=> let
five =
single <| dollar 5
sum =
five $+ five
reduced =
Bank.reduce bank USD sum
in
dollar 10
=== reduced
]
, describe "Reduce Sum"
[ "addition1"
=> let
sum =
(single <| dollar 3) $+ (single <| dollar 4)
bank =
Bank.bank |> Bank.addRate (CHF ~> USD) 2
result =
Bank.reduce bank USD sum
in
dollar 7
=== result
]
, describe "Reduce Money"
[ "reduce1"
=> let
single_ =
single <| dollar 1
bank =
Bank.bank |> Bank.addRate (CHF ~> USD) 2
in
Bank.reduce bank USD single_ === dollar 1
]
, describe "Reduce Bank with Different Currency"
[ "CHF ~> USD 2"
=> let
twoCHF =
single <| franc 2
bank =
Bank.bank |> Bank.addRate (CHF ~> USD) 2
result =
Bank.reduce bank USD twoCHF
in
result === dollar 1
]
, describe "Identity rate"
[ "USD ~> USD 1"
=> let
bank =
Bank.bank |> Bank.addRate (CHF ~> USD) 2
in
Bank.rate (USD ~> USD) bank
=== 1
]
, describe "Mixed Addition"
[ "CHF ~> USD 2"
=> let
fiveBucks =
single <| dollar 5
tenFrancs =
single <| franc 10
bank =
Bank.bank |> Bank.addRate (CHF ~> USD) 2
result =
(fiveBucks $+ tenFrancs)
|> Bank.reduce bank USD
in
dollar 10 === result
]
, describe "Sum Plus Money"
[ "($5 + 10 CHF) + $5"
=> let
fiveBucks =
single <| dollar 5
tenFrancs =
single <| franc 10
bank =
Bank.bank |> Bank.addRate (CHF ~> USD) 2
result =
((fiveBucks $+ tenFrancs) $+ fiveBucks)
|> Bank.reduce bank USD
in
dollar 15 === result
]
, describe "Sum Times"
[ "($5 + 10 CHF) * 2"
=> let
fiveBucks =
single <| dollar 5
tenFrancs =
single <| franc 10
bank =
Bank.bank |> Bank.addRate (CHF ~> USD) 2
result =
((fiveBucks $+ tenFrancs) $* 2)
|> Bank.reduce bank USD
in
dollar 20 === result
]
]