関心の分離

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

最適化の第二法則(要約版)

これはThe Second Law of Optimization (Abridged) – the Voidの日本語訳です。原文と同じく、Creative Commons 表示 - 非営利 - 継承 3.0ライセンスの下で自由に利用できます。


僕は同じ題名の元の記事(英文)がみんなが読みたいと思うよりも長いと気付いたので、要約したものを用意した。

今や誰もがこの「最適化のマントラ」を聞いたことがあるだろう。「やめとけ」

これは一般的に三つのルールに内包されている。最初の二つはこのマントラの複製で、三つ目のルールは「まだ」という抜け目のない言葉を唯一無二のルールに付け加えて「エキスパート」に宛てている。

時期尚早な最適化――僕らの大部分は身に覚えがある。この言葉がとても有名な理由だ。言うなれば格言だ。アルゴリズムを肉付けせず、結果をよく確かめもしない内に、ただ出力に驚嘆してスマートなトリックを仕込んだ経験は誰にでもあるだろう。「わかったぞ!」そう宣言する……そして半日後、もうあの馬鹿な関数を書いてたまるかと思う。まっぴらごめんだよ、なあ。

このルールは正論だ。多分ね。もう一つのルールは、その時が来たらプロファイラを使い、決して、何があっても思い込みで見積もらないことだ。そして、思い込みはきっと高くつく。これもまた正論だ。これらは格言だ、多分ね。でもこれを額面通りに受け取ると災いの元だ。

よっぽど小さいプロジェクトでもない限り、変更の前にはプロファイラを使い、他の人、特にモジュールオーナーとベテラン開発者とアーキテクトに意見を求めなければならない。変更は計画され、設計され、よく管理されたものでなければならない。プロジェクトが巨大なほど、この過程はますます重要だ。ズルはしないでくれ、頼むから。

効率的なコード != 時期尚早な最適化

先人の知恵は時期尚早な最適化を避け、確実に必要な時はまずプロファイラを使うように告げている。だけどこのように受け取ることもできる。「非効率的で膨れ上がったコードを書いていいよ、必要な時はプロファイラが見つけてくれるさ」

パフォーマンスを後付けするのはとても高くつく。本当に。だけどその代替は時期尚早な最適化じゃない。プロフェッショナルに要求される熟慮と設計を経たコードと、学生のおもちゃプロジェクトスタイルのコードの間にはわずかな境界線がある。後者は目の前の問題を解くことに注力していて、エラー処理やパフォーマンスやメンテナンスへの配慮に欠けている。

参照が頻繁に起こる場合にリストや配列の代わりに辞書やマップ*1を使うのは時期尚早な最適化じゃない。使ってもあまり複雑にならない場合に、O(n2)のアルゴリズムの代わりにO(n)のアルゴリズム(O(log2 n)のアルゴリズムがない場合)を使うのは時期尚早な最適化じゃない。同様に、変化しないデータをループから追い出すのは時期尚早な最適化じゃない。

僕はチームに自惚れた自慢屋がいるのと同じくらい、プロファイラを走らせたり同僚に話したりすることなく、乱暴な推測とでたらめな変更でコードを「最適化」しようとする奴が大嫌いだ。チームの時間が次から次へとクリーンアップに使われるのはもっと大嫌いだ。事前に二度以上考えることなくコードを書くのは簡単だ。コードを打ち込んで、走らせて、(正しくデバッグする代わりに)でたらめなトレースログを突っ込んで、コードを増やして、それを正しい出力が見られるまで繰り返すのは簡単だ。馬鹿でひどく退屈なこった。*2

僕が説明した極端にひどいケースが標準だと言ってるわけじゃない(それがどんなにありふれているかを知って驚くかもしれないけど)。僕の論点は「時期尚早な最適化」と「糞コーディング」の中庸(golden mean)が存在するってことだ。

変更のコスト

プロジェクトが開発サイクルの後期になるほど、変更のコストが指数関数的に増加することはよく書かれている(例として『Code Complete(英文)*3』を見てほしい)。「最適化のルール」のおかげで、このコストは見落とされている。パフォーマンスについて考えるべき時に考えること、少なくとも設計時に正しい判断を下すことをそのルールは著しく妨害する。

最適化指向開発を勧めてるわけじゃない。それどころか、パフォーマンスの影響に対する自覚を持つことで将来のつらい変更を避けられるんだ。繰り返したように、効率的なコードを設計して書くことは時期尚早な最適化を意味しない。僕らは賢明であり、早めに投資して将来の高いコストを避けることで精算しているんだ。現実の例として、Robert O'Callahanの投稿(英文)を見てほしい。

結論

時期尚早な最適化は有名な罠だ。コミュニティの知恵は製品コードで実験するのを避け、できるだけ最適化を延期するように告げている。コードが十分できあがって必要な時にだけ、プロファイラの助けを借りてホットスポットを判断し、それから細心の注意を払って最適化するんだ。

この戦略は非効率的で考えなしで、――時には――あからさまに醜いコードを作るように開発者をそそのかす。すべては「時期尚早な最適化の回避」の名の下に。さらに、誤ってプロファイリングがパフォーマンスを改善する魔法の解決法だとみなす。

代替が小さなコストないしコストなしに使えるのに非効率的なコードを書く口実はない。打ち込む前にアルゴリズムを考えない口実はない。あとで必要になるかもしれないとか、最適化する時にクリーンアップするつもりだからという理由で古い実験用の部品を放置する口実はない。お粗末な設計、遅いコードのコストは非常に高い。

最適化は後回しにしよう。でも効率的な、最適ではなく単に効率的なコードを最初から書こう。


*1:一般的にハッシュ関数を内部で使うことから、「ハッシュ」と呼ばれることもあるデータ構造。

*2:原文は"As dull and dead-boring as that is."だが、うまく訳せなかった。対案募集中。

*3:日本語版の書籍についてはamazon:Code Completeから見つかる。

NullPointerExceptionに遭うコード

最近悩んでる問題。

(import '[javax.media.opengl GLCapabilities GLProfile]
        '[com.jogamp.newt.event WindowAdapter]
        '[com.jogamp.newt.opengl GLWindow]
        '[com.jogamp.opengl.util FPSAnimator])
(def window (-> (GLProfile/getDefault) (GLCapabilities.) (GLWindow/create)))
(def animator (FPSAnimator. window 1))
(doto window
  (.addWindowListener
    (proxy [WindowAdapter] []
      (windowDestroyNotify
        [e]
        (.start (proxy [Thread] []
          (run [] (.stop animator) (System/exit 0)))))))
  (.setVisible true))

(.start animator)

これをclj-env-dirの類で起動して、ウィンドウを閉じようとするとFPSAnimator.pauseからNullPointerExceptionが飛んでくる。

addWindowListener部分はあってもなくてもいい。

等価なJavaコードでも同じ例外が起こるので、Clojureの問題ではない。

Ubuntu(X11)とWindowsで確認。

「ここが悪い」「自分の環境では例外が起きない」など、なにか情報があればぜひ教えてください。
よろしくお願いします。

rand-strを添削してみる

オリジナル:https://bitbucket.org/fgtrjhyu/misc/src/5d4fa9455ed0/randomstring/clj

(use '[clojure.string :only (join)])

(defn- char-range
  [& more]
  (letfn [(crange
            [[^Character start ^Character end]]
            (join (map char (range (int start) (inc (int end))))))]
    (join (map crange more))))

(let [characters (char-range [\0 \9] [\A \Z] [\a \z] [\_ \_])]
  (defn rand-str
    [n]
    (join (take n (repeatedly #(rand-nth characters))))))

(println (rand-str 256))

char-rangeについていくつか補足。

  • 今回は必要ない機能を省いた。
  • applyを使う代わりに分配束縛を使った。
  • letfnを使った形に変形した。
  • 今回はLazinessがあまり役に立たないので、文字列を返すようにした。

また、charactersはdefして外部から書き換えられるようにするのが一般的だが、オリジナルが外部からの書き換えを禁じていたのでletでdefnを包む形を取った。

これはレキシカルクロージャや(Doug Hoyteが著書のタイトルに使ったように)Let Over Lambdaと呼ばれるテクニックで、char-rangeは一度だけ呼ばれる。

オリジナルはメモ化しているが、(source memoize)すればわかるように、メモ化しても関数が毎回呼ばれ、atomをderefし、マップを探す。

もちろんメモ化は時々役に立つものの、今回は適切ではないと思う。

読んだ方の参考になれば嬉しいです。

char-range

(defn- char-range*
  [start end]
  (map char (range (int start) (inc (int end)))))

(defn char-range
  [& more]
  (if (and (= (count more) 2)
           (every? char? more))
    (apply char-range* more)
    (apply concat (map #(apply char-range* %) more))))

(comment
(char-range \a \z)
(char-range [\0 \9] [\a \z])
)

ClojureでBase64

http://gist.github.com/301008Clojureで書かれたBase64ライブラリを置きました。

使い方は読めばわかるはず。(おい)

*encode-table*を書き換えることでURLセーフな変種にも対応できるようになっています。

文字列操作と正規表現を湯水のように使い捨てているので、遅いです。ビット操作を使えば速くなるかも。

byte配列を要求するというClojureっぽくない実装なので、後で最も多い用途であろう文字列用のAPIを追加します。

あと、内部用関数は後でdefn-を使って隠蔽します。