【Common Lisp】
defgenericは関数呼び出しの
インターフェースであって、
クラスのインターフェースではない

  • defgenericは複数クラスで使う操作を宣言できる点でJavaのinterfaceに似ているが、クラスが「この操作を実装している」という規約にはならない。
  • 実装が足りなくても定義時点では止まらず、実行時にNo applicable methodになる。
    これはデメリットに見えるが、メソッドがそろっていない状態でもREPLで動作確認できるというメリットでもある。
  • 実装の保証はテストコードが担う。
    クラスの構文が強制するのではなく、必要に応じて明示的に確認する。
    この違いは、Common Lispと静的型付き言語の設計思想の違いに根ざしている。

関連記事

1. defgenericは必要なの?

CLOSには、defgenericという総称関数の定義構文があります。

defgenericは必要なの? defmethodだけ (defmethod enqueue ((q linked-queue) v)) 暗黙的に 総称関数を生成 → これだけでも動く defgenericで明示 (defgeneric enqueue (q v)) (defgeneric dequeue (q)) (defgeneric queue-empty-p (q)) 設計上の入口を 明示的に宣言 → 意図が伝わる

しかし、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は、関数のインターフェースは示しても、クラスのインターフェースを規定してはいません。

実行時エラー vs コンパイルエラー Java interface implements Queue → 全メソッド必須 コンパイルエラー 実装漏れを コード作成時に検出 全骨格が必要 vs CLOS defgeneric defgenericは規約なし → 未実装でも定義OK 実行時エラー No applicable method 呼び出し時に検出 部品ずつ試せる

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でLinkedQueueQueueを実装させるとき、enqueueだけ先に試したくても、dequeueisEmptyのスタブを書かない限りコンパイルが通りません。
動作確認の前に、全メソッドの骨格を用意する必要があります。

一方、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. では実装の保証はどうするか

それでは、実装漏れを確実にチェックするにはどうすればよいのでしょう。

実装の保証はテストコードが担う 振る舞いテスト test-queue-behavior 各クラスで実行 linked-queue array-queue … 未実装なら No applicable method で検出 型チェック(Java) メソッドの存在を確認 コンパイル時 振る舞いテスト(CLOS) 期待通りに動くか確認 実行時

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 「プログラマは  必要なことを知っている」 判断を書く側に委ねる ・自由度が高い ・REPLで探索的に設計 ・テストで保証 defgeneric = 設計上の合意 vs Java / TypeScript 「プログラマは  間違える」 言語が逸脱を事前に止める ・型・interface で強制 ・コンパイル時に検出 ・組織的開発に強い interface = 法律

Common Lispには、「プログラマーは必要なことを知っている」という前提があるように思います。
どこをテストし、どこを型宣言し、どこを実行時チェックするかは、書く側が判断します。
言語が先回りして全部を禁止するのではなく、強い道具を渡して、判断を委ねる設計です。

一方、JavaやTypeScriptは「プログラマーは間違える」という前提に寄っています。
とくに組織的な開発では、各人が勝手な判断で実装すると整合性が崩れます。
インターフェース、型、アクセス修飾子、静的解析といった仕組みで、逸脱を事前に止めます。

これは、何を優先するかの違いです。

4.1. プログラミングを哲学する

その意味で、Lisp は「プログラミングの哲学」に近いです。

与えられた言語仕様を規則として従うだけでなく、規則はなぜあるのか、別の規則ではどうなるのか、というところまで試せるのが Lisp の面白さです。
Common LispのCLOSは、オブジェクト指向で必要な最小限を突き詰めた、「控えめな仕様」になっています。

  1. CLHSのSection 7.6.1によると、defmethodを書いたとき対応する総称関数が存在しない場合、ensure-generic-functionを通じて暗黙に総称関数が作られます。つまりdefgenericは必須ではなく、明示的な宣言として機能します。 – CLHS Section 7.6.1 Introduction to Generic Functions
  2. CLOSは1986年から設計が始まり、FlavorsとCommonLoopsの両方から着想を得ています。Flavorsはメッセージパッシングモデルでしたが、New Flavorsが総称関数を導入しました。CLOSはこの総称関数モデルを採用し、defgenericを中心に置く設計になりました。 – Common Lisp Object System – Wikipedia
  3. Javaではimplements宣言のあるクラスがインターフェースのすべての抽象メソッドを実装していない場合、コンパイルエラーになります。これは名前ベースの型システム(nominal typing)の特性で、クラスが明示的に「このインターフェースを実装する」と宣言することで規約が成立します。TypeScriptのinterfaceは構造的部分型(structural typing)を採用しており、明示的なimplementsなしで形が一致すれば型互換とみなされる点でJavaとは異なります。 – TypeScript: Type Compatibility
  4. CLOSのディスパッチは実行時に行われます。総称関数が呼ばれると、引数の型に基づいて適用可能なメソッドのリストが決定され、最も特定的なものが選ばれます。該当するメソッドが一つもない場合、no-applicable-methodという総称関数が呼ばれ、デフォルト実装がエラーを発生させます。この動作はCLHSのSection 7.6.6で定義されています。 – CLHS Section 7.6.1 Introduction to Generic Functions
  5. ポール・グレアムは2001年のエッセイ「Beating the Averages」で、スタートアップのように何が正解かわからない状態で速く動く場面でLispが強いと述べています。Viaweb(後にYahoo! Storeとなる)の開発でCommon Lispを使い、競合他社より速く機能を実装できたことを報告しています。この強みの一つが、REPLで部分的に動かしながら探索的に設計できる点にあります。 – Beating the Averages – Paul Graham
  6. CLOSのディスパッチは実行時に行われるため、既存クラスのソースコードを変更せずに外からメソッドを追加できます。これはオブジェクト指向設計原則のOpen/Closed原則(拡張に対して開いており、変更に対して閉じている)に近い性質ですが、JavaやC++とは逆の方向から実現しています。Javaはインターフェースと継承で変更を制限し、CLOSは総称関数への後付けで拡張を開きます。 – Common Lisp Object System – Wikipedia
  7. ここで使っているassertはCommon Lisp標準のマクロで、式がnilを返した場合にsimple-errorを発生させます。CLHSではassertを「条件が真でなければエラーを発生させる」と定義しています。FiveAM、Parachute、ProveなどのサードパーティのテストフレームワークもCommon Lispでよく使われます。 – Common Lisp Cookbook – Testing