追記: この記事からちょうど一年。設計に関する考えが180度変わりましたので、差分をお楽しみください。
この記事はElm2 Advent Calendar 14日目の記事です。(フライング)
Elmの入門は果たしたけど開発に着手すると、どう進めてよいかわからない。Elmにはアプリケーションを構築するためのElmアーキテクチャがありますが、アーキテクチャに対してどう向き合っていけば良いか。私が開発するときには、どういう思考で進めているかを解説した記事になります。
環境構築や文法の基礎はそれなりにマスターしたという前提で進めます。もし、ちょっと自信が無いよという方は、環境に関しては以下の記事や環境を好みで選んでください。文法はチュートリアルを読んでいれば問題ない内容になっています。
Elm開発における思考(開発)フロー
今回は簡単なじゃんけんゲームを題材に思考フローを解説しています。以下が開発・思考フローのステップになります。アプリケーション制作では、もっと広い視点で考えるとデザインを考える・UI/UXを考えるなどの多くのステップがあると思いますが、ここではあくまでElm開発の本質だけにフォーカスした項目のみを挙げています。
- 単体テストを使いビジネスロジックを考える
- 状態遷移について考える
- 状態(Model)についてコードを書く
- 状態を遷移するための入力(Message)についてコードを書く
- 状態遷移(update)についてコードを書く
- 状態を元に画面の描画をするコードを書く
単体テストを使いビジネスロジックを考える
このステップは個人的にはElmの大きな強みなので厳守していただきたい最初のステップになります。Elmのような純粋関数型言語は関数から副作用をキッチリ分離します。また値の等価性に優れているため、テスト駆動開発に向いています(細かな説明は記事を参照)。以下が標準的なじゃんけんロジックとテストになります。アプリケーションのロジックは開発中の仕様変更などが生じなければ原則、他のモジュールに影響することなく組み上げることができるはずです。逆にモジュールはアプリケーションのロジックに依存するはずです。そのため最初の思考ステップになります。じゃんけんロジックはとても単純なため説明は省略させていただきます。
Tests.elm
module Tests exposing (..)
import Test exposing (..)
import TestExp exposing (..)
-- Test target modules
import Rps exposing (..)
all : Test
all =
describe "Rsp Test"
[ "Win"
=> fight Rock Scissors
=== Win
, "Lose"
=> fight Scissors Rock
=== Lose
, "Draw"
=> fight Paper Paper
=== Draw
]
Rps.elm
module Rps exposing (Result(..), Hand(..), fight, toHand)
type Result
= Win
| Lose
| Draw
type Hand
= Rock
| Paper
| Scissors
fight : Hand -> Hand -> Result
fight you enemy =
let
r =
((fromHand you - fromHand enemy) + 3) % 3
in
result r
fromHand : Hand -> Int
fromHand hand =
case hand of
Rock ->
0
Scissors ->
1
Paper ->
2
toHand : Int -> Hand
toHand num =
case num of
0 ->
Rock
1 ->
Scissors
2 ->
Paper
_ ->
Debug.crash "Illegal hand"
result : Int -> Result
result num =
case num of
2 ->
Win
1 ->
Lose
0 ->
Draw
_ ->
Debug.crash "Illegal number"
状態遷移について考える
それではじゃんけんゲームのアプリケーションの状態について考えていきましょう。今回のアプリケーションの場合には、画面遷移を状態として考えるのが一番イメージしやすいでしょう。画面遷移が存在せず内部状態のみで1画面を変化する場合には、もっとシンプルに考えられると思います。シンプルなじゃんけんゲームは以下の3つで表せると思います。
- Start - ゲーム開始画面
- NowPlaying - じゃんけんの手を考える画面
- Over - ゲームの結果を表示する画面
また、状態が遷移するための入力について考えてみると、StartやOverは次のじゃんけんゲームに移るための催促が入力と考えられます。NextGameという名前にしておきましょう。また、NowPlayingでじゃんけんゲームの判定をおこなうには、自分の手を選ぶ必要があります。SelectYourHandという名前にしておきましょう。この状態遷移を簡単に図におこしておきました。
この思考プロセスはとても重要です。アプリケーション開発者は、このように丁寧に図をおこしたことは無いかもしれませんが、頭のなかで同じプロセスを通っているはずです。慣れないうちは必ず図に起こすことを推奨します。何故かと言うとElmのアーキテクチャはステートマシンと相性がとても良いからです。これからのステップでそれをお見せしていきます。
状態(Model)についてコードを書く
まず、じゃんけんゲームの画面の状態について考えていきます。Elmでは状態をModelと呼んでいます。Modelでは、どのようなものをModelにするか定義をします。ここで言う定義はunion typesで新しく型を生成するか、既存の型が状態だとわかりやすいようにtype aliasを張ることを指します。Model自身は多くのケースでレコードや配列になり、そのtype aliasに過ぎません。先ほどの図から画面の状態を型として定義します。union typesでScene
と名付け、そのまんまの状態名を採用します。また、じゃんけんの結果を判定する際には、自分と相手の手が必要だと推測されるので、you
とenemy
をレコードの要素として追加しておきます。型は一番最初に作ったRpsモジュールからHand型を持ってきています。定義を終えたら、アプリケーションの最初の状態を決める必要があります。scene
は当然Start
、youとenemyは決め打ちでRock
としておきます(気持ち悪い方は、Nothing : Maybe Hand
とすることもできますが、その場合、エラー処理も追加する必要が出てきます)。
Models.elm
module Models exposing (..)
import Rps exposing (..)
type Scene
= Start
| NowPlaying
| Over
type alias Model =
{ you : Hand, enemy : Hand, scene : Scene }
initialModel : Model
initialModel =
{ you = Rock, enemy = Rock, scene = Start }
状態を遷移するための入力(Message)についてコードを書く
今度は状態を遷移するための入力を考えていきます。入力はMessageと呼ばれます。これまた、union typesで列挙するだけになります。NextGameのような単に次の状態を遷移するためのメッセージなら単体で書きますが、SelectYourHandのように手の値を受け取りたい場合には続けて型を記述します。ステートマシンの考えが反映しやすい理由がわかりかけてきたでしょうか。
Message.elm
module Messages exposing (..)
import Rps exposing (Hand)
type Msg
= NextGame
| SelectYourHand Hand
状態遷移(update)についてコードを書く
ModelとMessageについて定義をしましたが、このままでは、ただの定義だけに過ぎません。状態遷移について考えていきましょう。状態遷移とは、現在の状態(Model)から入力(Message)を元に次の状態に変化させることです。それを取り仕切るのがupdate : Msg -> Model -> ( Model, Cmd Msg )
になります。最終的な戻り値が、ModelとCmd Msgというものの組になっていますが今は新しいModelを返すのだなという理解に留めておいてください。これまた上に戻ってステートマシン図を見てください。扱うべき入力は2つです。NextGameとSelectYourHandです。入力をパターンマッチで分岐し、その入力が来た時にどのような状態に変化するかを式であらわします。初期の画面(scene)はStartです。また、OverにいるときもNextGameの入力が来たらNowPlayingの状態に移行します。NowPlayingにいるときは、SelectYourHandで手(h)を受け取り、youとsceneをOverに移行します。ステートマシンが、そのまま宣言的にコードに現れており、とても美しくありませんか?
Update.elm
module Update exposing (..)
import Models exposing (Model, Scene(..))
import Messages exposing (..)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
NextGame ->
( { model | scene = NowPlaying }, Cmd.none )
SelectYourHand h ->
( { model | you = h, scene = Over }, Cmd.none )
状態を元に画面の描画をするコードを書く
ここで初めて描画する画面のコードについて触れていきます。画面と言うのは現在の状態を元にHtml(もしくはSvg等)を組み上げていくだけになります。画面毎に完成のスクリーンショットを列挙していきます。思考フローを本質にしているため、極力コードを省き見るも無残な画面となっています(見た目は後からいくらでも変えることができます!)。
- Start
- NowPlaying
- Over
これまた細かい説明は省きますが、画面の状態毎に見た目は変えたいのでパターンマッチをおこなって返すHtml Msg
を分けています。もしコードが見づらいぐらい膨れ上がった場合には、別の関数に小分けにしていくことで解決します(お好みでどうぞ)。重要な箇所は、ボタン(inputタグ)が押された場合のonClick
イベントです。onClickイベントにMessageを渡すことで、次の状態変化(update
)を促します。ただし明示的にupdate関数を呼び出す必要は無く、そこはElmランタイムがよしなに取り計らってくれます(ここがElmアーキテクチャの最大の強みです!)。
View.elm
module View exposing (view)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onClick)
import Messages exposing (..)
import Models exposing (Model, Scene(..))
import Rps exposing (Hand(..), Result(..), fight)
view : Model -> Html Msg
view { you, enemy, scene } =
case scene of
Start ->
input
[ type_ "button"
, onClick NextGame
, value "Game Start"
]
[]
NowPlaying ->
div [] hands
Over ->
let
ye =
(toString you) ++ "(YOU) VS " ++ (toString enemy) ++ "(ENEMY)"
r =
result you enemy
in
div []
[ h1 [] [ text ye ]
, r
, input
[ type_ "button"
, onClick NextGame
, value "Next Game"
]
[]
]
hands : List (Html Msg)
hands =
[ Rock, Paper, Scissors ]
|> List.map
(\hand ->
input
[ type_ "button"
, onClick <| SelectYourHand hand
, value <| toString hand
]
[]
)
result : Hand -> Hand -> Html Msg
result you enemy =
let
r =
Rps.fight you enemy
in
case r of
Win ->
h1 [ style [ ( "color", "red" ) ] ] [ text <| toString r ]
Lose ->
h1 [ style [ ( "color", "blue" ) ] ] [ text <| toString r ]
Draw ->
h1 [ style [ ( "color", "gray" ) ] ] [ text <| toString r ]
ここまで、出来ればMain関数を用意してあげるだけです。今まで定義してきたものをprogramと言う関数の引数にレコードの形で渡してあげるだけで終わります。
Main.elm
module Main exposing (..)
import Html exposing (program)
import Models exposing (initialModel, Model)
import Messages exposing (..)
import View exposing (view)
import Update exposing (update)
init : ( Model, Cmd Msg )
init =
( initialModel, Cmd.none )
main : Program Never Model Msg
main =
program
{ init = init
, view = view
, update = update
, subscriptions = (\_ -> Sub.none)
}
いやー無事完成ですね!・・・ちょっと待った!敵の手がRockから変わっていないじゃないか!!その通りです。Modelのenemyについては、初期状態(Rock)から変化していませんでした。これではゲームになりませんね?最後に相手の手を考えるロジックについて考えてみます。一旦この時点でのコード全体を共有しておきます。
副作用について考える
Elmは純粋関数型言語です。関数は一切の副作用が含まれるコードが書けません。フロントエンドプログラミングにおける副作用が必要とされるコードは乱数, WebAPI, WebSocketなどです。例えば、乱数は関数が実行される度に値が変動する可能性があります。入力によってのみ出力の値が変動しなければ純粋な関数とは呼べず、参照透過性が
崩れ去ってしまいます。しかし実際問題として、これらの機能が使えなければ実用的なプログラムが書けません。安心してください。副作用が含まれるコードは書けませんが、(トンチのようですが、)副作用を発生させるようなコードは書くことができます。コードの説明の前に、思考フローに従って再び最初からサイクルしていきましょう。じゃんけんロジックについては完成しているので、副作用を含む状態遷移について考えてみましょう。相手の手が決まるタイミングは、NowPlayingの状態のときです。副作用を起こす処理と副作用を起こさない処理を完全に分離する必要があるので、内部状態として考えます。NowPlayingになった瞬間の状態を仮にInitialとし、先ほどと同じようにSelectYourHandメッセージを発行します。そうすると自分の手が決まっているSelectedYourHandと言う状態に変動します。その後、乱数を用いてSelectEnemyHandメッセージを発行することで、初めてOverの状態に移行することができます。
それではコードに起こしていきましょう。実はNowPlayingの内部状態はModelに追加されません。相手の手が決まっていない状態と言うのは画面に描画する必要が無いからです(そもそも、NowPlayingの内部状態が遷移している間は、view関数が呼び出されません)。Messageは発行する必要があるので定義をします。乱数を発生させるため、SelectEnemyHandの引数はHandではなく、Intなのに注意してください。
Messages.elm
module Messages exposing (..)
import Rps exposing (Hand)
type Msg
= NextGame
| SelectYourHand Hand
+ | SelectEnemyHand Int
それでは乱数(副作用)を発生させるには、どうするのでしょうか?答えは途中説明をしなかったCmd Msg
になります。Cmdは副作用を発生させつつ、内部状態を遷移させるためのMessageを発行させる型だったのです。乱数を発生させるには、Random.generate Message 乱数の範囲
という形で式を実行します。この時点では乱数は取得することができないので、updateの再帰呼出しとなります。SelectEnemyHandのパターンマッチの
分岐を追加しましょう。Cmdは副作用を起こしたい数だけ連鎖して、終端処理にCmd.noneを渡してあげます。あとは数字からHandに変換して無事最終コードとなります。Elmアーキテクチャが優れている点として、副作用を発生させる箇所をCmd(実は説明を省いているSubscriptionも該当します)、つまりupdate関数だけに限定しているため、他のモジュールは一切の影響を受けません。綺麗な設計を保つことができるようになります。
Update.elm
module Update exposing (..)
import Models exposing (Model, Scene(..))
import Messages exposing (..)
import Random
import Rps
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
NextGame ->
( { model | scene = NowPlaying }, Cmd.none )
SelectYourHand h ->
+ ( { model | you = h }, Random.generate SelectEnemyHand (Random.int 0 2) )
+ SelectEnemyHand num ->
+ ( { model
+ | enemy = Rps.toHand num
+ , scene = Over
+ }
+ , Cmd.none
+ )
まとめ
Elmに入門してアーキテクチャを学ぶことが最初の関門になりますが、その後の学習コストはグンと低くなります。アーキテクチャを攻略するカギは、状態遷移の考えなので是非マスターして楽しくアプリケーションを量産していきましょう!