LoginSignup
5
1

More than 5 years have passed since last update.

関数型言語Elmでテスト駆動開発(15~16章)

Last updated at Posted at 2017-11-11

前回の記事の続きになります。前回レート計算の実装が課題でしたが辞書型のお陰で思いの外、簡単に実装がおこなえました。今回でさらなる抽象化を進め、複雑なテストを増やすことで最終回となります。ここまで見てくださった方(いれば)、本当にありがとうございました!

15章

遂に、本来やりたかった他貨幣間での足し算の実装をおこないます。それにともない、各関数をMoney型からExpressionに抽象度を引き上げていきます。

  • \$5 + 10 CHF = \$10 (レートが2:1の場合)
  • \$5 + \$5 = \$10
  • \$5 + \$5 がMoneyを返す
  • Bank.reduce(Money)
  • Moneyを変換して換算を行う
  • Reduce(Bank, Currency)

追加される他貨幣間の足し算のテストは以下のものになります。

tests/Tests.elm

+ 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の定義では、dollarfranc関数がMoney型を返したいたものをSingleに噛ませてExpression型に引き上げます。そして、今までMoney型で書いていた関数を全てExpression型に引き上げます(結構大工事です)。その場合すべての関数でパターンマッチをする必要があり面倒ですが、あとから解消するための手立てが出てくるのでお待ち下さい。ただcurrency関数は、
Sum(足し算)の場合には、後ろの項の貨幣が取得されるという実装詳細が生じてしまいます。

src/Money/Money.elm

- 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から移行をしました。

src/Bank.elm

- 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も$*という特殊な演算子に変えてしまいましょう。

src/Expression.elm

+ ($+) : 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を返す関数と、currencyamountのように別な型に収束するタイプの関数です。

($*) : 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型に戻してあげることで、多くの問題は解決されたと思います。例えば通貨currencyMoney型しか取得できないのが当たり前で、抽象化されたExpression型では、他通貨同士の足し算のケースもあるため一意に定まらないと言う問題が解消されました(amountも同様)。さらに、PRをいただいた後、私の実装と見比べいくつかリファクタリングをおこない、本当に最後の実装としました。ご指摘本当にありがとうございました。せっかくなので実装をすべて載せて終わりたいと思います。

src/Money/Model.elm

module Money.Model exposing (Money(..), Amount, Currency(..))


type alias Amount =
    Int


type Currency
    = USD
    | CHF


type Money
    = Money Amount Currency

src/Money/Money.elm

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

src/Bank.elm

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

src/Expression.elm

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 $*

tests/Tests.elm

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
            ]
        ]
5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1