Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
Help us understand the problem. What is going on with this article?

関数型言語Elmでテスト駆動開発(第12~13章)

More than 3 years have passed since last update.

前回の記事の続きになります。前回はtimesが一般化されスッキリしたコードになりました。今回から遂に為替に関する実装を開始していきます。

12章

ここで一旦TODOリストを綺麗に整理します。そして為替の第一段階としてUSドル同士の足し算を新たなタスクとして追加します。

  • \$ + 10 CHF = \$10 (レートが2:1の場合)
  • \$5 + \$5 = \$10

以下が追加される対象のテストです。5ドル同士をplus関数に渡すことで足し算をおこないます。そのままそれが答えになるわけではなく、足し算の計算結果は、為替を考慮してBank.reduceの関数に通貨を渡すことで最終的な結果に変化します。

tests/Tests.elm

++ describe "Simple Addition"
++            [ "addition1"
++                => let
++                   five =
++                        dollar 5
++
++                    sum =
++                        plus five five
++
++                    reduced =
++                        Bank.reduce sum USD
++                  in
++                    dollar 10
++                       === reduced
++           ]

plusとreduceの詳細を見ていきましょう。本当であればplus関数の戻り値の型はMoneyではなく、計算を示すExpressionと言う型であるべきです。しかし、一旦テストを通したいので仮実装となります。2つのMoney型の値を受け取って、Money型の値を返します。パターンマッチをしてamount同士を足し合わせます。もう皆さん慣れましたね?

src/Money.elm

++ plus : Money -> Money -> Money
++ plus (Money amount1 currency) (Money ++ amount2 _) =
++     Money (amount1 + amount2) currency

reduceの実装は本当に仮のものです。単に10ドルの値を返します。

[src/Bank.elm]

++ module Bank exposing (..)

++ import Money exposing (..)


++ reduce : Money -> Currency -> Money
++ reduce source to =
++    Money.dollar 10

13章

13章は非常に難しい章でした。Javaの内容をそのまま移植したのですが、しっくりせずElm使いのフォロワーさん達のお力添えもあり何とか納得できる形になりました。この場を借りてお礼を言いたいと思います。実際後で確認したところ、後の章の抽象化まで進んでしまいましたが、とりあえずそれも含めて成果とします。初期のダメダメな設計も含めて包み隠さず順を追って説明していきたいと思います(これぞTDD・・・?)。

TODOの項目は以下のように増えます。(正直あまり項目について理解していません・・・。)

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

Javaでは、Expressionと言うインターフェースが定義してありました。このインターフェースは、reduceというメソッドを持ち、通貨を与えられると、Moneyを返すというメソッドを持ちます。reduceは計算の簡約を意味します。Moneyと足し算を表すSumと言うクラスがこのインターフェースを実装します。まずはじめに、Expressionについて考えました。Elmは言語をなるべくシンプルに保つために関数型におけるインターフェースの役割をする型クラスを持ちません。とりあえず愚直にreduceメソッドを表すために、Expressionの定義をCurrency -> Moneyとしました。

src/Expression.elm

++ module Expression exposing (Expression)

++ import Money.Model exposing (Currency, Money)


++ type alias Expression =
++     Currency -> Money

続いて足し算を表す型Sumを考えました。Union Typesを使い、2つのMoneyを受け取り足し算を表す型を表現します。SumはExpressionを実装しなければならないので、reduce関数を定義します。Sumを受け取り、Expressionを返します。これでSum型のreduce関数を表せますね。ちょうど第二引数がCurrencyを受け取るので、最終的に足し算を終えた新しいMoneyを返す関数、Expression型が完成します。SumはExpressionとして表現されなければならないので、expressionというリフト(持ち上げ)する関数も用意しました。しかし、ここではreduce関数を呼び出さなければならず、SumをExpressionとして持ち上げると簡約が行われてしまうという問題が発生しました。

src/Sum.elm

module Sum exposing (..)

import Money.Model exposing (Money(..), Currency)
import Expression exposing (..)


type Sum
    = Sum Money Money


reduce : Sum -> Expression
reduce (Sum (Money augend _) (Money addend _)) to =
    let
        amount =
            augend + addend
    in
        Money amount to


expression : Sum -> Expression
expression sum =
    reduce sum

Sumと同様にMoneyもExpressionとして実装しなおしましょう。expressionは、通貨を渡したらMoneyを返す関数とします。MoneyをExpressionとして扱うだけなのでレートは考慮せず、そのままMoneyを返します。ちょっと怪しい実装な気がしますね?plus関数は、Moneyを返していましたが、足し算を表す式(Sum)として返すようにしています。足し算はSumに委譲します。Money単体のときは為替をしなければなりませんが、とりあえずExpressionにリフトしてそのまま返す実装にしています。

src/Money/Money.elm

++ expression : Money -> Expression
++ expression money =
++    (\_ -> money)

-- plus : Money -> Money -> Money
++ plus : Money -> Money -> Sum
-- plus (Money amount1 currency) (Money  amount2 _) =
--     Money (amount1 + amount2) currency
++ plus money1 money2 =
++    Sum money1 money2

++ reduce : Money -> Currency -> Expression
++ reduce money to =
++    expression money

BankはExpressionと変換先の通貨を受け取り最終結果のMoneyを返します。Expressionの型がCurrency -> Moneyという型だったことを思い出してください。

module Bank exposing (..)

import Money.Model exposing (Money, Currency)
import Expression exposing (Expression)


reduce : Expression -> Currency -> Money
reduce source to =
    source to

テストがいくつか増えて長くなったので変更や増えた部分のテストを列挙しておきます。

  • Simple Addition
    • 足し算結果をSumとBankでreduceし最終結果のMoneyを比較
  • Plus Returns Sum
    • plusだけを行い、足し算を表すSum型であることを確認
  • Reduce Sum
    • USドルからではなく、Sumを直接reduceに渡して想定した結果になるか確認
  • Reduce Money
    • MoneyをExpressionとしreduceし、そのままMoneyとなるか確認
        describe "Simple Addition"
            [ "addition1"
                => let
                    five =
                        dollar 5

                    sum =
                        Sum.reduce <| plus five five

                    reduced =
                        Bank.reduce sum USD
                   in
                    dollar 10
                        === reduced
            ]
        , describe "Plus Returns Sum"
            [ "addition1"
                => let
                    five =
                        dollar 5

                    result =
                        plus five five
                   in
                    result
                        === Sum five five
            ]
        , describe "Reduce Sum"
            [ "addition1"
                => let
                    sum =
                        Sum.reduce <| Sum (dollar 3) (dollar 4)

                    result =
                        Bank.reduce sum USD
                   in
                    dollar 7
                        === result
            ]
        , describe "Reduce Money"
            [ "reduce1"
                => let
                    money =
                        Money.expression <| dollar 1
                   in
                    dollar 1
                        === Bank.reduce money USD
            ]

いろいろツッコミどころは多そうですが、とりあえずの問題点としては、MoneyとSumがExpression型と言うのは結構無理がありそうです。また、SumとBankの二重のreduceが結構不自然ですね。

抽象レベルを引き上げる

Javaスタイルに合わせるのをやめて、Elmらしくリファクタリングしていきます。

まず、Expressionの型としてMoneyとSumを含めてしまいます。

src/Expression.elm

- type alias Expression =
-    Currency -> Money
+ type Expression
+    = Expression (Currency -> Money)
+    | ExpressionMoney Money
+    | ExpressionSum Sum

plus関数は、SumをExpressionにリフトして返します。

src/Money/Money.elm

- plus : Money -> Money -> Sum
+ plus : Money -> Money -> Expression
  plus money1 money2 =
-    Sum money1 money2
+    ExpressionSum <| Sum money1 money2

Bankの関数はExpression型の分岐が増えたので、パターンマッチをおこない、それぞれの計算を行います。

src/Bank.elm

  reduce : Expression -> Currency -> Money
     reduce source to =
-    source to
+    case source of
+        Expression expression ->
+            expression to
+
+        ExpressionMoney money ->
+            money
+
+        ExpressionSum sum ->
+            Sum.reduce sum to

だいぶ素直な実装になってきましたが、もう少し何とかなりそうです。例えば、Expression (Currency -> Money)は、どこでも使われていません。reduceが既にその役割をになっています。Expresion Sumは、Sum型を持ちますが、素直にSumをそっくり持ってきたほうがしっくり来そうな気がします。最後にもうひと頑張りしてみましょう。

最終段階

フォロワーさんに先ほどの実装を見せたところ、Expressionを非常にしっくり来る型として修正していただきました。早速見ていきましょう。

実に美しい型です。Moneyだけの場合はSingle Moneyとなり、Sumの場合には、2つの式(Expression)を受け取る再帰的データ構造として表すのが、かなりしっくりきました。ここの型は後の章で表されていました。現段階では、まだテストでMoneyを直接渡したいので、Sumの形で返してくれるヘルパー関数sumを用意しておきました。単にmoneyをSingle Moneyにリフトするだけですね。

src/Expression.elm

type Expression
    = Single Money
    | Sum Expression Expression

sum : Money -> Money -> Expression
sum money1 money2 =
    Sum (Single money1) (Single money2)

plus関数は計算として、より自然に見せるために$+という演算子として定義しました。記号を使った関数で中置演算の形で書けるのはElmの利点と言えますね。ScalaはJVM言語ですが、同様に記号でメソッドを定義することが可能なのでオススメです。$+関数は先ほどのヘルパー関数を呼び出してあげるだけでOKです。

src/Money/Money.elm

($+) : Money -> Money -> Expression
($+) money1 money2 =
    Expression.sum money1 money2

Money同士の足し算が自然にExpressionとして、表すことができたのでBank.reduceに移りましょう。ポイントはSumが再帰的データ構造で表されていた点です。つまり、reduceも再帰的に解決されます。Singleの場合は、単にMoneyに変換してあげるだけで済みます。Sumの場合は、sum_というprivateなヘルパー関数に、exp1, exp2という名前で式を渡してあげます。式は再帰的にreduce関数に渡すことで最終的にはMoney型で返ることが保証されています。あとは、Money.amount関数でamountを取得します。あとは式から抽出したamount同士を返してあげれば足し算されたMoneyの完成です。少し複雑ですが、再帰を利用したきれいな形で書けたので非常に嬉しくなりました。

src/Bank.elm

reduce : Expression -> Currency -> Money
reduce source to =
    case source of
        Single (Money amount _) ->
            Money amount to

        Sum exp1 exp2 ->
            Money (sum_ exp1 exp2 to) to


sum_ : Expression -> Expression -> Currency -> Int
sum_ exp1 exp2 to =
    let
        getAmount =
            (\expression -> Money.amount <| reduce expression to)
    in
        (getAmount exp1) + (getAmount exp2)

最後にテスト全体を張ります。最初より格段に見やすいテストになったと思います。

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"
                => (dollar 5 |> times 2)
                === dollar 10
            , "Multiplication2"
                => (dollar 5 |> times 3)
                === dollar 15
            , "Currency"
                => (currency <| dollar 5)
                === USD
            ]
        , describe "Franc"
            [ "Multiplication1"
                => (franc 5 |> times 2)
                === franc 10
            , "Multiplication2"
                => (franc 5 |> times 3)
                === 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 =
                        dollar 5

                    sum =
                        five $+ five

                    reduced =
                        Bank.reduce sum USD
                   in
                    dollar 10
                        === reduced
            ]
        , describe "Plus Returns Sum"
            [ "addition1"
                => let
                    five =
                        dollar 5

                    result =
                        five $+ five
                   in
                    result
                        === Expression.sum five five
            ]
        , describe "Reduce Sum"
            [ "addition1"
                => let
                    sum =
                        Expression.sum (dollar 3) (dollar 4)

                    result =
                        Bank.reduce sum USD
                   in
                    dollar 7
                        === result
            ]
        , describe "Reduce Money"
            [ "reduce1"
                => let
                    money =
                        Money.expression <| dollar 1
                   in
                    dollar 1
                        === Bank.reduce money USD
            ]
        ]

コード全体

まとめ

前回の最後では、JavaとElmのコードが同じような形で表現できたので今回も同じような形でいけるかと移植を試みましたが、結果として汚い形になってしまいました。個人的な結論としては、中途半端な抽象化コードをElmで表すと、かなり厳しいコードになってしまうのではないかと思いました。思い返してみれば今までのElmコードも具象型が混じってしまうと表現が厳しいので抽象化された型になっている箇所がいくつか見受けられました。そこが関数型の融通の効かない部分と見ることもできますし、しっかり抽象化されて型が保証されていると見ることもできると思います。ただ、今回のElmの抽象化もまだ中途半端で、$+関数がExpression型ではなくMoney型で受け取っていたり改善の余地はまだ多く残されています。TDDシリーズも佳境に近づいてきましたが、これからどのように表せるか私自身もかなり楽しみです(上手くコード化できるか不安でもありますが)。

elm-jp
主に日本で活動する Elm 利用者のコミュニティです。
https://elm-lang.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away