defgenericは複数クラスで使う操作を宣言できる点でJavaのinterfaceに似ているが、クラスが「この操作を実装している」という規約にはならない。- 実装が足りなくても定義時点では止まらず、実行時に
No applicable methodになる。
これはデメリットに見えるが、メソッドがそろっていない状態でもREPLで動作確認できるというメリットでもある。 - 実装の保証はテストコードが担う。
クラスの構文が強制するのではなく、必要に応じて明示的に確認する。
この違いは、Common Lispと静的型付き言語の設計思想の違いに根ざしている。
1. defgenericは必要なの?
CLOSには、defgenericという総称関数の定義構文があります。
しかし、defmethodだけでもオブジェクト指向の設計はできます1。
(defmethod enqueue ((q linked-queue) value)
...)Code language: Lisp (lisp)
defmethodは、暗黙的に総称関数を作るからです。
1.1. defgenericはインターフェースを明示している
それなら、defgenericはなんのためにあるかというと、それは「意図を伝える」ためです。
たとえば、複数のクラスにまたがる操作を使う場合、defgenericを先に宣言しておくと、その名前が総称関数だとあらかじめわかります。
(defgeneric enqueue (queue value))
(defgeneric dequeue (queue))
(defgeneric queue-empty-p (queue))Code language: Lisp (lisp)
これは「queueとして使うなら、この3つの操作に応答できるべき」という設計上の入口を示しています2。
2. Javaのようなクラスのinterfaceではない
ただ、defgenericは、関数のインターフェースは示しても、クラスのインターフェースを規定してはいません。
Javaでキューのインターフェースを書くなら、こうなります。
interface Queue<T> {
void enqueue(T value);
T dequeue();
boolean isEmpty();
}Code language: Java (java)
並べると、やっていることはよく似ています。
どちらも「この操作を使う」という宣言です。
しかし、Javaのinterfaceは、クラスが「この操作を実装している」という規約になります。
class LinkedQueue<T> implements Queue<T> {
public void enqueue(T value) { ... }
public T dequeue() { ... }
// isEmptyを実装し忘れると、コンパイルエラー
}Code language: Java (java)
implements Queueと書いた時点で、リストにある全メソッドを実装しなければコンパイルが通りません3。
実装漏れはコードを書いている段階で検出されます。
2.1. 実行時にメソッドが見つからないエラー
CLOSのdefgenericは、そうではありません。
たとえば、dequeueが未実装でも、定義時点ではエラーになりません。
(defclass linked-queue () ...)
(defgeneric enqueue (queue value))
(defgeneric dequeue (queue))
(defgeneric queue-empty-p (queue))
;; enqueueだけ実装して、残りは後回し
(defmethod enqueue ((q linked-queue) value)
...)Code language: Lisp (lisp)
これは、実行して初めてわかります。
(defparameter *q* (make-instance 'linked-queue))
(dequeue *q*)
;=> There is no applicable method for the generic function
; #<STANDARD-GENERIC-FUNCTION DEQUEUE> when called with arguments
; (#<LINKED-QUEUE ...>)Code language: Lisp (lisp)
「No applicable method」は、「この型に対応するメソッドが見つからない」というエラーです4。
2.2. 動的チェックはメリットでもある(探索的設計)
実装漏れが実行時にしかわからないのは、一見デメリットに見えます。
ただ、これにはもう一面あります。
JavaでLinkedQueueにQueueを実装させるとき、enqueueだけ先に試したくても、dequeueとisEmptyのスタブを書かない限りコンパイルが通りません。
動作確認の前に、全メソッドの骨格を用意する必要があります。
一方、CLOSでは、enqueue一つだけを実装した状態でもREPLに渡せます。
(defmethod enqueue ((q linked-queue) value)
(push value (slot-value q 'data)))
(let ((q (make-instance 'linked-queue)))
(enqueue q 'a)
(enqueue q 'b)
(slot-value q 'data))
;=> (B A)Code language: Lisp (lisp)
dequeueがまだなくても、enqueueの動きを確認できます。
次にdequeueを書いて試す。
その次にqueue-empty-pを足す。
部品を一つずつ動かしながら設計を進められます。
「全体が完成しないと動かせない」か「未完成の部品から試せる」かは、探索的に設計する場面では大きな差です5。
2.3. 後からメソッドを追加できる
CLOSのdefgenericは、規約を強制しない代わりに、後からメソッドを足して操作体系に参加させる自由を残しています6。
;; 既存のクラスを変更せずに、外側からメソッドを追加できる
(defmethod enqueue ((q my-queue) value)
...)Code language: Lisp (lisp)
Javaでimplementsを後から追加するには元のクラスを書き直す必要がありますが、CLOSではその必要がありません。
3. では実装の保証はどうするか
それでは、実装漏れを確実にチェックするにはどうすればよいのでしょう。
CLOSでは言語の仕組みが実装を強制しない分、テストを用意する必要があります。
たとえば、queueとして期待される振る舞いを、一つの関数にまとめます。
(defun test-queue-behavior (make-q)
(let ((q (funcall make-q)))
(assert (queue-empty-p q))
(enqueue q 'a)
(enqueue q 'b)
(assert (not (queue-empty-p q)))
(assert (eql (dequeue q) 'a))
(assert (eql (dequeue q) 'b))
(assert (queue-empty-p q))))Code language: Lisp (lisp)
これを、実装ごとに流します。
(test-queue-behavior
(lambda () (make-instance 'linked-queue)))
(test-queue-behavior
(lambda () (make-instance 'array-queue)))Code language: Lisp (lisp)
dequeueが未実装なら、ここで「No applicable method」になります。
3.1. 型チェックと振る舞いテスト
Javaのinterfaceがコンパイル時に止めるのと結果は同じですが、確認のタイミングが違います。
Javaは「型」を強制します。
そのクラスがdequeueというメソッドを持つかどうかを、コンパイラが検査します。
一方、CLOSのテストは「振る舞い」を確認します。dequeueが存在するだけでなく、期待通りに動くかどうかを見ます7。
未実装をより明示的にしたい場合、基底クラスにエラーを置く方法もあります。
(defclass queue () ())
(defmethod dequeue ((q queue))
(error "dequeue is not implemented for ~S" q))Code language: Lisp (lisp)
具体クラスで上書きしなければ、呼び出したときにこのエラーが出ます。
Javaの抽象メソッドに近い振る舞いですが、やはりこれもコンパイル時ではなく実行時の検出です。
4. プログラマは必要なことを知っているのか?
defgenericがinterfaceにならないのは、Common Lispの設計思想に根ざしているのかもしれません。
Common Lispには、「プログラマーは必要なことを知っている」という前提があるように思います。
どこをテストし、どこを型宣言し、どこを実行時チェックするかは、書く側が判断します。
言語が先回りして全部を禁止するのではなく、強い道具を渡して、判断を委ねる設計です。
一方、JavaやTypeScriptは「プログラマーは間違える」という前提に寄っています。
とくに組織的な開発では、各人が勝手な判断で実装すると整合性が崩れます。
インターフェース、型、アクセス修飾子、静的解析といった仕組みで、逸脱を事前に止めます。
これは、何を優先するかの違いです。
4.1. プログラミングを哲学する
その意味で、Lisp は「プログラミングの哲学」に近いです。
与えられた言語仕様を規則として従うだけでなく、規則はなぜあるのか、別の規則ではどうなるのか、というところまで試せるのが Lisp の面白さです。
Common LispのCLOSは、オブジェクト指向で必要な最小限を突き詰めた、「控えめな仕様」になっています。
- CLHSのSection 7.6.1によると、
defmethodを書いたとき対応する総称関数が存在しない場合、ensure-generic-functionを通じて暗黙に総称関数が作られます。つまりdefgenericは必須ではなく、明示的な宣言として機能します。 – CLHS Section 7.6.1 Introduction to Generic Functions - CLOSは1986年から設計が始まり、FlavorsとCommonLoopsの両方から着想を得ています。Flavorsはメッセージパッシングモデルでしたが、New Flavorsが総称関数を導入しました。CLOSはこの総称関数モデルを採用し、
defgenericを中心に置く設計になりました。 – Common Lisp Object System – Wikipedia - Javaでは
implements宣言のあるクラスがインターフェースのすべての抽象メソッドを実装していない場合、コンパイルエラーになります。これは名前ベースの型システム(nominal typing)の特性で、クラスが明示的に「このインターフェースを実装する」と宣言することで規約が成立します。TypeScriptのinterfaceは構造的部分型(structural typing)を採用しており、明示的なimplementsなしで形が一致すれば型互換とみなされる点でJavaとは異なります。 – TypeScript: Type Compatibility - CLOSのディスパッチは実行時に行われます。総称関数が呼ばれると、引数の型に基づいて適用可能なメソッドのリストが決定され、最も特定的なものが選ばれます。該当するメソッドが一つもない場合、
no-applicable-methodという総称関数が呼ばれ、デフォルト実装がエラーを発生させます。この動作はCLHSのSection 7.6.6で定義されています。 – CLHS Section 7.6.1 Introduction to Generic Functions - ポール・グレアムは2001年のエッセイ「Beating the Averages」で、スタートアップのように何が正解かわからない状態で速く動く場面でLispが強いと述べています。Viaweb(後にYahoo! Storeとなる)の開発でCommon Lispを使い、競合他社より速く機能を実装できたことを報告しています。この強みの一つが、REPLで部分的に動かしながら探索的に設計できる点にあります。 – Beating the Averages – Paul Graham
- CLOSのディスパッチは実行時に行われるため、既存クラスのソースコードを変更せずに外からメソッドを追加できます。これはオブジェクト指向設計原則のOpen/Closed原則(拡張に対して開いており、変更に対して閉じている)に近い性質ですが、JavaやC++とは逆の方向から実現しています。Javaはインターフェースと継承で変更を制限し、CLOSは総称関数への後付けで拡張を開きます。 – Common Lisp Object System – Wikipedia
- ここで使っている
assertはCommon Lisp標準のマクロで、式がnilを返した場合にsimple-errorを発生させます。CLHSではassertを「条件が真でなければエラーを発生させる」と定義しています。FiveAM、Parachute、ProveなどのサードパーティのテストフレームワークもCommon Lispでよく使われます。 – Common Lisp Cookbook – Testing