33
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Elmで副作用を扱う仕組みCmdがとっても良い理由

Last updated at Posted at 2018-12-04

乱数を扱うケース

副作用の例として乱数を考えてみましょう。題材はテーブルトークRPGの100面体ダイスを振ったときのストーリーを考えてみます。

  • 0(100の代わり)~99の数字を乱数として出し、出目とする
  • 1~5の出目の場合は、criticalの文字列
  • 0, 96~99の出目の場合は、fumbleの文字列
  • それ以外の数字はそのまま

TypeScriptの場合

TypeScriptで先程の仕様についてコードを書いた場合は、2-4番目の仕様を満たしているかどうかをテストをするには、1番の乱数に処理が依存しているため、実際にアプリケーションを起動してめっちゃ振りまくるしかありません。また、何の目がcritical, fumbleとして出たのかわからないため、console.logを仕込んで消すなどの涙ぐましい努力が別途必要になり開発の雲行きが怪しくなります。

function getRandomInt(max): number {
  return Math.floor(Math.random() * Math.floor(max));
}

function roll1d100(): string {
    const face = getRandomInt(100);

    if (1 <= face && face <= 5) {
        return "critical"
    } else if (face == 0 || 96 <= face && face <= 99) {
        return "fumble"
    } else {
        return String(face);
    }
}

この問題の解決策は、非常に簡単です。roll1d100と言う関数の最初に呼び出していた乱数生成処理を分離し引数で渡すように変更します。そうすることで、テストしたい対象のダミーの値を流し込んで、欲しい結果が得られるかどうかを普通にテストすれば良いことになります。

function getRandomInt(max): number {
  return Math.floor(Math.random() * Math.floor(max));
}

function roll1d100(face: number): string {
    if (1 <= face && face <= 5) {
        return "critical"
    } else if (face == 0 || 96 <= face && face <= 99) {
        return "fumble"
    } else {
        return String(face);
    }
}

// 以下のようなダミーの値を渡して、テストをすれば良い。
roll1d100(6) === "6";
roll1d100(95) === "95";
roll1d100(1) === "critical";
roll1d100(5) === "critical";
roll1d100(96) === "fumble";
roll1d100(99) === "fumble";
roll1d100(0) === "fumble";

Elmの場合

Elmの場合でも基本的にやることは TypeScriptとやることは変わりませんが、大きく違う点が、どう逆立ちしても副作用を起こすコードは関数に含めることができないことです。大事なポイントは2つです。

1つは、副作用をElmのコード上で起こす代わりにMsg(カスタム型)と言う決めごとで、Elmランタイムが副作用を含む処理を行ないMsgに結果を込めて返してあげます。今回は乱数の結果をNewFace Intと言うMsgで受け取りますよ。0~99の範囲で乱数を生成しますよ。と言うお願い事をするというのが乱数生成の合図です。

type Msg
    = Roll
    | NewFace Int

Random.generate NewFace (Random.int 0 99)

もう一つは、update関数で先ほどお願いした、Msgを待ち構える準備(パターンマッチ)NewFace faceをするだけです。あとはroll1d100というIntを受け取ってStringを返す関数を定義してあげるだけです。TypeScriptとノリは同じですね。

-- updateから抜粋
NewFace face ->
            ( { rollText = roll1d100 face }
            , Cmd.none
            )

roll1d100 : Int -> String
roll1d100 face =
    if 1 <= face && face <= 5 then
        "critical"

    else if face == 0 || 96 <= face && face <= 99 then
        "fumble"

    else
        String.fromInt face

今までの詳しい解説は以下の公式ドキュメントの説明にお任せします。

The Elm Architecture
コマンドとサブスクリプション

コードの全体感(完全ではありません)です。

type alias Model =
    { rollText : String
    }

type Msg
    = Roll
    | NewFace Int


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Roll ->
            ( model
            , Random.generate NewFace (Random.int 0 99)
            )

        NewFace face ->
            ( { rollText = roll1d100 face }
            , Cmd.none
            )


roll1d100 : Int -> String
roll1d100 face =
    if 1 <= face && face <= 5 then
        "critical"

    else if face == 0 || 96 <= face && face <= 99 then
        "fumble"

    else
        String.fromInt face

テストは非常に簡単に書けます。今回はたくさんのケースをテストしたいので、roll1d100Testのように一つのテストをする関数を生やして、値を差し替えるだけでデータテーブルのような構造を簡単に構築することができます。

type alias TestCase =
    String


roll1d100Test : TestCase -> Int -> String -> Test
roll1d100Test testCase face res =
    test testCase <|
        \_ ->
            let
                actual =
                    roll1d100 face

                expected =
                    res
            in
            Expect.equal expected actual


suite : Test
suite =
    describe "The Main module"
        [ describe "roll1d100"
            [ roll1d100Test
                "1はcritical"
                1
                "critical"
            , roll1d100Test
                "5はcritical"
                5
                "critical"
            , roll1d100Test
                "96はfumble"
                96
                "fumble"
            , roll1d100Test
                "99はfumble"
                99
                "fumble"
            , roll1d100Test
                "0はfumble"
                0
                "fumble"
            , roll1d100Test
                "6は6"
                6
                "6"
            , roll1d100Test
                "95は95"
                95
                "95"
            ]
        ]

以下が、動くコードです。
1d100ふりふり君

非同期処理を扱う方法

乱数よりもおそらく、WEB API等で非同期処理を利用するケースのほうが一般的だと思われます。

TypeScriptの場合

TypeScriptではWEB APIのレスポンスを扱う一番手軽は、async/awaitを使う方法でしょうか。

function fetchApi(): Promise<number> {
    return Promise.resolve(1);
}

async function foo(): Promise<string> {
    const response = await fetchApi();
    
    return '0' + response;
}

Jasmineを利用してテストする場合には少し特殊な書き方をして、テストを書いてあげる必要があります。使用しているフレームワークがPromiseを素直に返さないケースなどは変換処理等が入り、テストの敷居が高くなってしまうことが弱点でしょうか。

it ('Promise Test 01', (done: DoneFn) => {
   foo().then(data => {
       // verify
       expect(data).toBe('01');

       done();
   });
});

Elmの場合

乱数のときとやることは何も変わりません。副作用が起こりうる値はCmdMsgのお願いをする!簡単ですね? 今回は副作用を簡単に作り出せるTaskというモジュールの関数を利用しました。Task.succeedとPromise.resolve(1)が等価と捉えて良いでしょう。Task.performが、TaskからCmd Msgを作り出すための変換メソッドになります。

type Msg
= GotResponse Int

fetchApi : Cmd Msg
fetchApi =
  Task.perform GotResponse (Task.succeed 1)

GotResponse num ->
   ( { model | txt = foo num } , Cmd.none )

foo : Int -> String
foo num =
    "0" ++ String.fromInt num

まとめ

TypeScriptやJavaScriptなど手続き型の思想が入った言語では、副作用をカジュアルにコードに組み込むことができ、一見すると柔軟なコードが記述可能で素晴らしい利点に見えます(実際にその方が向いている案件は多く存在します)。しかし、副作用を起こすコードは一般的にテストがしづらく返ってくる値が(0~99の乱数のように)不定の場合はアプリケーションを実行し手動テストなどをすることが非常に困難になります。

解決策として、副作用が無い部分とある部分をきれいに分離することでダミーの値を流し込むことでロジックの担保をすることを可能にしますが、人間は甘い欲に流されがちなので、大抵の場合はロジック分離が不可能なほど複雑なコードになりがちです(個人が制御しても複数人での開発や外部ライブラリなどを利用すると困難になります)。

Elmは、Cmd・Msg・updateなどの構成要素を利用したThe Elm Architectureのおかげで、副作用がある部分とない部分を完全に分離してコードを記述することができます。面倒なようで実は素晴らしい仕組みであるCmdを理解して、高速で安全なコードを量産していきませんか?

33
17
1

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
33
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?