関心の分離

これはMeikel Brandmeyer([twitter:@kotarak])の文章、"Separation of Concerns"の日本語訳です。


最近の知恵」曰わく、プロトコル*1関数は低レベルのインターフェースにするべし。無論、僕は無知ゆえにこの言葉に従わなかった。幸いにもChristophe*2はいつだって僕を教え導いてくれる。

実際のところ、この知識は「最近」のものではない。これはとても古くからあり、あなたは多くのオブジェクト指向言語のライブラリにこれを見いだすだろう。そして追加の間接参照がすべての問題を解決する*3ように、この知識の要点はとある悪い設計の原因に集約される。「関心の分離」の欠如だ。

Clojureにおける「関心の分離」……

例として参照型を見てみよう。これは基本的にあなたが物を入れる箱だ。参照型はどうやってあなたが物を入れるのかにだけ関心がある。実際に入れるのが何なのかには関心がない。ベクタを入れられるのか、それとも文字列を入れられるのかをrefは単に気にしない。だけど入れるときはdosyncの内側で頼むよ!

一方でベクタはいくつかの要素を挿入順に連続して取っておくことにだけ関心がある。アトムに入れられるのか、それともエージェントに入れられるのかを……ベクタは気にしない。

これは参照型とそれに入るデータの直交性を明示するというClojureの時間モデルに帰着する。鍵はもちろん不変性だ。もしベクタがその場で変更できたら、あなたは異なる平行性のシナリオに応じてTransactionalVector(トランザクション的なベクタ)やAtomicVector(アトミックなベクタ)のような異なる型の変種を必要としただろう。

……そしてAPIの設計における「関心の分離」

となると、これが「プロトコル関数は実装の詳細であるべきかどうか」という元の問題にどう適用されるんだろう?

第一に、プロトコル関数はユーザーにとって完全に透過的だ。だから、プロトコル関数の呼び出しを普通の関数の呼び出しと比較して違いを感じることはない。

だから僕らはとある架空のコレクション型のためにこんなAPIを定義することだってあり得た。

(ns our.collection)

(defprotocol OurCollection
  (count [this])
  (get [this k not-found]))

(defn empty?
  [this]
  (zero? (count this)))

これで僕らは公開APIに三つの関数を持つ。empty?はcountを使って実装した普通の関数だ。そのcountとgetはプロトコル関数だ。

三者はOurCollectionプロトコルを実装に使われる彼ら固有の型へ拡張する*4ことで僕らのコレクション抽象を実装するだろう。するとempty?はなんと自動的に動くようになるだろう。

よって何もかもがうまくいっている。どこに問題があるんだろう?

「ユーザー」の分離

問題は僕らのAPIのユーザーだ。彼女はgetの第三引数にいつもnilを渡さなきゃいけないことにイラついている。しばしば二引数あれば十分で追加のパラメーターは無駄にコードを散らかすだけだった。

「お客様第一」な僕らはその希望を注文として受け入れ、getの短縮呼び出しもできるようにAPIを変更する。

(ns our.collection)

(defprotocol OurCollection
  (count [this])
  (get [this k] [this k not-found]))

(defn empty?
  [this]
  (zero? (count this)))

そして古い形式の呼び出しも廃止されないので古いコードが壊れることもないだろう。だから全部OK、だよね?違うんだ!僕らはプロトコル関数を一つ変えただけだ。しかし今や実装も同様に変更する必要がある!前のAPIに合わせた実装を使っているユーザーがgetの短縮形を呼び出したら例外が発生するだろう。

一種類より多くのユーザーがいる。それは消費者と実装者だ。前者は幸せだ、彼女のコードはより明確になったから。しかし後者は僕らにカチンと来ている!

彼女はgetの新しい引数の個数を追加するだけでなく、彼女が気にかけていないものも含めてあらゆる実装にデフォルト値を定義する必要があったんだ。

僕らは一種類のユーザーを満足させた。もう一種類のユーザーを犠牲にして。

注釈:以下に述べるような、単純にプロトコル関数を残して普通の関数でgetを置き換える方法も助けてはくれない。古い実装は僕らの新しいバージョンと組み合わせるとまったく動かなくなるだろう。したがってどの道僕らは後方互換性を壊す。やられた。*5

分離する

問題は二種類のユーザーの関心を混同したことだ。そのため、片方の変更が必然的にもう片方に影響する。僕らは当初から二つを分離し続けるべきだった。僕は分離を明確にするためにプロトコルを専用の名前空間に収めてしまった。

(ns our.collection.protocols)

(defprotocol OurCollection
  (count [this])
  (get [this k not-found]))

(ns our.collection
  (:require [our.collection.protocols :as impl]))

(defn count
  [this]
  (impl/count this))

(defn get
  [this k not-found]
  (impl/get this k not-found))

(defn empty?
  [this]
  (zero? (count this)))

すると、もし僕らが前にやったのと同じ変更を導入しようとしたら、何が起きるんだろう?やるべきことはgetの変更だけだ。

(defn get
  ([this k]
   (get this k nil))
  ([this k not-found]
   (impl/get this k not-found)))

僕らは何を得たんだろう?

getの古い引数の個数は依然として機能するので、消費者のコードは依然として新しいAPIのバージョンと互換性がある。

プロトコルは変わっていないので、実装者が何かを変える必要はない。すべての実装は依然として新しいバージョンと組み合わせても動き、なんと新しいgetの恩恵も受ける。

なので両方のユーザー……消費者と実装者……は今や完全に分かれている。彼らの関心は別個に扱われるんだ。

結論

「関心の分離」についてとても注意深く考えること。一つの関数の実際の機能について考える時だけではなく、あなたの製品の異なる利害関係者たちの関心といったメタ情報を検討する時にも。

そして僕の場合は、考え始めること。Christophe、またもや僕を教え導いてくれたことに感謝する。

あとがき

実際のところこの知識はそんなに最近のものではない、と僕は書いた。実際、あなたはオブジェクト指向の世界でこれが使われているのをとても頻繁に見かけるだろう。

インターフェースを実装した抽象基底クラスがある。そのクラスから派生させて、要求されているいくつかの抽象メソッドを実装する。するとインターフェースの残りはタダで手に入る。

*1:訳注:この文章において、「プロトコル」は一貫してdefprotocolマクロを使って定義するものを指す。HTTPなどのことではない点に注意すること。

*2:訳注:Christophe Grand([twitter:@cgrand])。

*3:訳注:Butler Lampsonの言葉として有名な格言、「計算機科学のあらゆる問題は追加の間接参照を導入することで解決できる」の引用。Clojureユーザーに身近な例を一つ挙げると、ref/アトム/エージェント/varは並行プログラミングの問題を解決するために導入された間接参照。アクターもデータを直接共有する代わりにそれを所有するアクターにメッセージを送る形を取るので、同様の問題を解決するための別の間接参照と言える。

*4:訳注:プロトコルそのものが第三者に拡張されるのではなく、プロトコルの適用範囲が第三者の型に拡張されるという意味。

*5:訳注:プロトコル関数は後方互換性を壊さずにシグネチャを拡張できないという意味。初めから以下の方法を取れば問題ない。