ジンジャー研究室

長めのつぶやき。難しいことは書きません。

関数型言語Elmでオブジェクト指向する

f:id:jinjor:20160420163043p:plain

(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型のhpmpという値には普通にアクセス出来てしまいます。ここからは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型でいうとJustNothingに相当します。そして重要なことは、このEnemyタグはモジュールの外に対して意図的に公開していません。つまり、Maybe型でいうとJustNothingが公開されていないのと同じです。つまりモジュールの外側ではパターンマッチが出来ず、中の値を取り出すことができません{ 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

このIncrementDecrementという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