(4/23 追記:はてブのコメントで指摘をいただいた箇所を直しました。ありがとうございます!)
最近またElmを触り始めているので小ネタを書きます。
このエントリで主張したいこと。
オブジェクト指向とは?
オブジェクト指向と言うと色んな意味を含んでいて、解釈の違いで論争になるのでまずは整理します。だいたい特徴として挙げられるのは以下でしょう。
- カプセル化
- 継承
- メッセージング
どのオブジェクト指向に馴染んでいるかは開発者のバックグラウンドによって異なると思いますが、私はオブジェクト指向と言ったら圧倒的に「カプセル化」であって「役割分担」です。というわけで、以降はカプセル化の話です。 関数型言語とオブジェクト指向の代表としては、ElmとJavaScriptを例として取り上げます。Elmに馴染みのない方でも雰囲気は掴めると思います。
関数型言語は構造体に状態を持たないのでは?
カプセル化と言えば、オブジェクトが所持している「状態」を管理するためのもので、関数型言語とは相容れないような気がします。しかし変数(メモリ)の管理方法が違うだけでやっていることは同じです。
例えばUser
という構造体の中のname
という値を変更したい場合、JavaScriptでは構造体の中身を書き換えますが、Elmではname以外はそのままに、nameだけ変更した新しい構造体を返します。
// JavaScript user.name = 'John';// userの中身を書き換える
-- Elm user' = { user | name = 'John' } -- 新しいuserを返す
新しい構造体を生成していますが、意味的にはそのUser
の状態と考えても問題ないはずです。
カプセル化は何が嬉しいんだっけ?
状態へのアクセス方法・変更方法の制限です。構造がむき出しになっているとどんな操作でも可能になってしまいますが、適切なAPIを提供することでそれ以外の操作が起こる可能性をなくしてくれます。
以下は、ゲームで敵にダメージを与える例です。
// JavaScript enemy.hit(9999); var dead = enemy.isDead();
-- Elm enemy' = Enemy.hit 9999 enemy dead = Enemy.isDead enemy'
このとき、enemy
が内部でどんな変数を持っているかを利用者は知らずに済みます。例えばenemy
が内部変数hp
を持っていたとしても、hp
がゼロかマイナスかを気にする必要はありません。isDead
メソッド(関数)を呼び出せば真偽値が返ってきます。また、もしhp
がおかしな値になっていたとしても今のところhp
を変更できるのはhit
メソッド(関数)だけなので、原因をすぐに追及することができます。
重要なことは、カプセル化によってこのような設計を可能にする必要性に関しては、MutableでもImmutableでも関係ないという事です。Immutableにすれば参照を共有することによる予期せぬ副作用を防いでくれますが、どの道このようなカプセル化のアプローチは必要になってきます。
Elmでオブジェクトを作る
先ほどの例をElmでさくっと実装してみます。
module Enemy(Enemy, init, hit, isDead) where type alias Enemy = { hp : Int, mp : Int } init : Enemy init = { hp = 100, mp = 30 } hit : Int -> Enemy -> Enemy hit amount enemy = { enemy | hp = enemy.hp - amount } isDead : Enemy -> Bool isDead enemy = enemy.hp <= 0
ここではなんとなく見慣れたオブジェクト指向と似たコードだ、ということだけで十分です。関数型言語だからと言って肩肘張る必要はありません。同じようにやれば良いのです。
ところで、実はこのEnemy型のhp
やmp
という値には普通にアクセス出来てしまいます。ここからはElmに限ったテクニックですが、次のように書き換えると解決します。
module Enemy(Enemy, init, hit, isDead) where type Enemy = Enemy { hp : Int, mp : Int } init : Enemy init = Enemy { hp = 100, mp = 30 } hit : Int -> Enemy -> Enemy hit amount (Enemy enemy) = Enemy { enemy | hp = enemy.hp - amount } isDead : Enemy -> Bool isDead (Enemy enemy) = enemy.hp <= 0
type Enemy = Enemy { hp : Int, mp : Int }
の右辺のEnemy
はUnion Type*1のタグです。Maybe
型でいうとJust
やNothing
に相当します。そして重要なことは、このEnemy
タグはモジュールの外に対して意図的に公開していません。つまり、Maybe
型でいうとJust
やNothing
が公開されていないのと同じです。つまりモジュールの外側ではパターンマッチが出来ず、中の値を取り出すことができません。{ hp : Int, mp : Int }
はプライベートな値なのです。
少しだけコードが煩雑になるので、あまり厳しくなくてよい時は上の方法でやりますが、きちんと管理したい場合は下の方法でより厳しいカプセル化が可能です。
The Elm Architectureにおけるカプセル化
ElmではThe Elm Architecture(以降TEA)と呼ばれる設計でアプリを作るのが一般的になっています。
以下のリンクは、TEAに沿って作られたCounter
コンポーネントのコードです。
elm-architecture-tutorial/Counter.elm at master · evancz/elm-architecture-tutorial · GitHub
TEAではコンポーネントから発火したイベント(Action)は必ず一度ツリーの頂点に到達し、アプリケーションの状態を更新します。しかし、コンポーネントから発火したイベントによって影響を受けるのはコンポーネント自身であることが多く、外部にActionの詳細を公開する必要がありません。Counter
コンポーネントのAction
は次のように定義されています。
type Action = Increment | Decrement
このIncrement
とDecrement
という2つのアクションは外部に公開されていません。それぞれCounter
コンポーネントのview
関数で発火し、Counter
コンポーネントのupdate
関数で使われます。
どうモジュール分割するか
TEAでは、アプリケーションの全ての状態を一か所で管理するため、放っておくとどんどんその部分のコードが膨らんでいきます。 そこで、ざっくり方針として
- 似たフィールドが3つ以上続いたらまとめる
ことにします。例えば、次のようなコードがあったとします。
-- Main.elm type Action = Select Id | ChangeName String | Ctrl Bool | Shift Bool | Alt Bool type alias Model = { selected : Id, name : String, ctrl : Bool, shift : Bool, alt : Bool } update : Action -> Model -> Model update action model = case action of Select id -> ... ChangeName name -> ... Ctrl isDown -> { model | ctrl = isDown } Shift isDown -> { model | shift = isDown } Alt isDown -> { model | alt = isDown }
この時点で既にActionが5個、Modelのフィールド数も5個で、このまま機能追加していくとパンクすること必至です。しかし、よく見るとなんとなく、ctrl
, shift
, alt
という3つのフィールドは一か所にまとめられそうな気がします。そこで、この3つのフィールドと付随するActionをKeys.elmに切り出します。
-- Main.elm type Action = Select Id | ChangeName String | KeysAction Keys.Action type alias Model = { selected : Id, name : String, keys : Keys.Model } update : Action -> Model -> Model update action model = case action of Select id -> ... ChangeName name -> ... KeysAction action -> { model | keys = Keys.update model.keys }
-- Keys.elm type Action = Ctrl Bool | Shift Bool | Alt Bool type alias Model = { ctrl : Bool, shift : Bool, alt : Bool } update : Action -> Model -> Model update action model = case action of Ctrl isDown -> { model | ctrl = isDown } Shift isDown -> { model | shift = isDown } Alt isDown -> { model | alt = isDown }
すっきりしました。キーに関する情報はすべてKeys.elmに押し込めることが出来ています。
機能追加していると、本当にあっという間にコードが膨らんでいってしまうので、モジュール分割は気付いた段階でしておくべきでしょう。Elmは強力な静的型付け言語なので、大規模にリファクタリングしてもほぼバグりません。気付いた時に気軽にリファクタリング出来るのは大きな強みです。
適切な粒度
オブジェクト指向において適切に役割分担するには、経験上、デメテルの法則を意識すると大体上手く行きます。具体的には
- 2つドットが続くと危ない
を意識します。例えば、ある会社が自社で運用するサービスを開発するとします(コードはJavaScript)。
company.getDev().work();
この例では、会社から開発者を引っ張り出して働かせています。これでは開発物はできるかもしれませんが、サービスが運用されるかはわかりません。すぐに次のコードが必要になります。
company.getDev().work(); company.getOps().work();
このように一連の手続きをセットにする場合、利用者に毎回同じ手続きを踏むように強制するのは困難です。次のようにすべきでしょう。
company.run();
Elmでも同じことができます。もしMain
モジュールがあらゆる処理で埋め尽くされていたら、まず切り離すべきは詳細に踏み込みすぎたコードです。
まとめ
ここまで見てきた通り、「データと処理を一緒にする」という発想は関数型言語においても自然と導かれます。また、カプセル化もMutableなオブジェクトの特権ではなく、Immutableなオブジェクトでもモジュールが処理を制約できれば普通にできます。
このエントリの狙いは、オブジェクト指向言語から関数型言語に移行する際のある種の懸念を払拭することです。お役に立てれば幸いです。
*1:またはtagged union, ADT