( ) 【Common Lisp】
defunとdefmethod、defstructと
defclassの使い分けを考える
(CLOS)

  • JavaやRubyではクラス設計から始めますが、Common Lispではデータを作る関数と操作する関数が揃えば成立する、という異なる設計思想があります。
  • defstructdefunを組み合わせると、クラスに近いデータ構造と操作を軽く実現できます。
  • defclassdefgenericdefmethodが必要になるのは、同じ操作名を保ちながら対象の種類だけを増やしたいときで、型ごとの分岐を関数の外に出す仕組みとして機能します。
  • CLOSのディスパッチは呼び出し時に引数の型を見てメソッドを選ぶため、タイトなループではdefunより遅くなりますが、設計の柔軟性を得るためのトレードオフとして捉えると使いどころが見えてきます。

関連記事

1. オブジェクト指向の設計とdefclass

JavaやRubyなどオブジェクト指向言語でプログラムを書くときは、多くの場合はクラス設計から始めます。

Lispの設計思想 Java / Ruby クラス設計から開始 メソッドをクラスに集約 object.method() vs Common Lisp データを作る関数 + 操作する関数 (function data) → これで十分

まずは、プログラムの中心となるデータ構造を設計し、必要な操作をクラスにまとめる、これがオブジェクト指向の設計の出発点です。
Common Lispにも、クラス定義に使う defclassはあります。

(defclass linked-queue ()
  ((head :accessor queue-head :initform nil)
   (tail :accessor queue-tail :initform nil)))

(defmethod enqueue ((q linked-queue) x)
  (let ((cell (list x)))
    (if (queue-head q)
        (setf (cdr (queue-tail q)) cell
              (queue-tail q) cell)
        (setf (queue-head q) cell
              (queue-tail q) cell)))
  q)

(defmethod dequeue ((q linked-queue))
  (let ((cell (queue-head q)))
    (when cell
      (prog1 (car cell)
        (setf (queue-head q) (cdr cell))
        (unless (queue-head q)
          (setf (queue-tail q) nil))))))

(enqueue q x)
(dequeue q)Code language: Lisp (lisp)

しかし、Lispで自前でデータ構造を作るときには、defclassよりdefstructdefunの組み合わせの方がよく見かけます。

(defstruct queue
  head
  tail)

(defun enqueue (q x)
  (let ((cell (list x)))
    (if (queue-head q)
        (setf (cdr (queue-tail q)) cell
              (queue-tail q) cell)
        (setf (queue-head q) cell
              (queue-tail q) cell)))
  q)

(defun dequeue (q)
  (let ((cell (queue-head q)))
    (when cell
      (prog1 (car cell)
        (setf (queue-head q) (cdr cell))
        (unless (queue-head q)
          (setf (queue-tail q) nil))))))

(enqueue q x)
(dequeue q)Code language: Lisp (lisp)

1.1. 生成と操作の関数は分かれている

Lispで、あまりデータ構造を「クラス」として定義しないのは、データを作る関数と操作する関数が揃っていれば、オブジェクトとして扱うことができるからです。

Lispの標準ライブラリ自体には、データを作る関数、操作する関数がそれぞれ用意されていて、それを組み合わせて使います。

;; 配列を作って操作する
(defparameter *buf* (make-array 10 :initial-element 0))
(setf (aref *buf* 0) 42)
(aref *buf* 0)  ; => 42

;; ハッシュテーブルを作って操作する
(defparameter *config* (make-hash-table))
(setf (gethash :host *config*) "localhost")
(gethash :host *config*)  ; => "localhost"Code language: Lisp (lisp)

make-arrayでデータを作り、arefで操作する。
make-hash-tableでデータを作り、gethashで操作する。

JavaならHashMapクラスのインスタンスを生成し、.get()メソッドを呼ぶところですが、Lispでは「データを作る関数」と「そのデータを受け取る関数」が分かれて存在しています。

このような設計思想が根底にあるため、Lispでは「まずクラスを作る」という発想にならないのです。

1.2. オブジェクトはCLOS以前からあった

hash-tablearrayは、CLOS以前から存在していました。

配列や文字列は、1970年代のMacLispで追加され、「作る関数と操作する関数」というスタイルが慣習として定着しました。
defstructdefunは、その慣習の延長にあります。

その後、1988年にCLOSがCommon Lispに統合され、その慣習をクラス階層とgeneric functionとして体系化した、と言えます1

たとえば、sequence型がlistvectorの共通の親クラスとして整理されているのも、既存の設計をCLOSで後付けに形式化した例のひとつです2

2. defstructとdefunでクラスっぽいものは作れる

defstruct + defun で十分 defstruct 自動生成されるもの • make-queue(コンストラクタ) • queue-p(述語) defun 操作を追加 • enqueue(追加) • dequeue(取り出し) 向いている場面 実装が1種類に決まっている 速さ・シンプルさが優先

defstructは、C言語のstructに近い、軽いデータ記録型です。

(defstruct queue
  head
  tail)Code language: Lisp (lisp)

データ構造にフィールドを定義すると、コンストラクタ、述語、アクセサが自動生成されます3
これだけでmake-queueで生成し、queue-pで判定し、queue-headqueue-tail でフィールドにアクセスできます。

2.1. 操作の関数をdefunで作る

それ以外の操作はdefunで定義します。

(defun queue-empty-p (q)
  (null (queue-head q)))

(defun enqueue (q x)
  (let ((cell (list x)))
    (if (queue-head q)
        (setf (cdr (queue-tail q)) cell
              (queue-tail q) cell)
        (setf (queue-head q) cell
              (queue-tail q) cell)))
  q)

(defun dequeue (q)
  (let ((cell (queue-head q)))
    (when cell
      (prog1 (car cell)
        (setf (queue-head q) (cdr cell))
        (unless (queue-head q)
          (setf (queue-tail q) nil))))))Code language: Lisp (lisp)

C言語でいえば、structとそれを引数に取る関数群に相当し、JavaやRubyのクラスとは違いますが、「データ構造とその操作」という役割は果たしているのです。

これで、queue として使える仕組みです。

(let ((q (make-queue)))
  (enqueue q 'a)
  (enqueue q 'b)
  (enqueue q 'c)
  (list (dequeue q) (dequeue q)))
;; => (A B)Code language: Lisp (lisp)

実際、競技プログラミングや小さなスクリプトでは、この形で十分で、実装が一種類に決まっていて速さと単純さが求められる場面では、むしろこちらが向いています。

3. defclassとdefgenericが必要になる瞬間

このようにCommon Lispでは、シンプルなクラスであれば、defstructで十分です。

型が増えたとき 型ごとに分岐 enqueue の中に linked-queue-p → array-queue-p → priority-queue-p → 追加のたびに書き換え 解決 defgeneric + defmethod 型ごとにメソッドを追加 linked-queue 用 array-queue 用 priority-queue 用 既存コードを変えずに型を追加できる 呼び出し側は常に (enqueue q x) のまま

では、defclassを使うのは、どんなときでしょう。
それは、クラスの継承や多相性を利用したいときです。
同じ操作名(メソッド)を保ちながら対象の種類(タイプ)によって具体的な内部動作を変えたいからです。

3.1. シーケンス関数の多相性

たとえば、標準のシーケンス関数がその例です。
lengtheltは、リストにも文字列にもベクタにも同じ名前で使えます。

(length '(1 2 3))      ; => 3
(length "hello")       ; => 5
(length #(1 2 3))      ; => 3

(elt '(a b c) 1)       ; => B
(elt "hello" 1)        ; => #\e
(elt #(10 20 30) 1)    ; => 20Code language: Lisp (lisp)

呼び出し側は型を気にしなくても、渡したオブジェクトの種類に応じて適切な実装が選ばれます。
これがCLOSのgeneric functionとして実現されている設計です。

3.2. 似たようなデータ構造が増えると……

自分のコードでも同じことをしたいとき、defclassdefgenericを使います。

たとえば、最初はlinked-queueだけでよかったのに、あとからarray-queuepriority-queueも必要になったとします。

型が増えるたびに、それぞれの操作関数を作るか、あるいはenqueueの中で分岐させる必要があります。

(defun enqueue (q x)
  (cond
    ((linked-queue-p q)
     ;; linked-queue 用の処理
     ...)
    ((array-queue-p q)
     ;; array-queue 用の処理
     ...)
    ((priority-queue-p q)
     ;; priority-queue 用の処理
     ...)
    (t
     (error "Unknown queue type: ~S" q))))Code language: Lisp (lisp)

これなら、呼び出し側は変えずに済みますが、enqueueという一つの関数に全員の処理を詰め込み続けることになります。
この分岐を外に出すのが、defgenericdefmethodです。

3.3. defgenericの宣言とdefmethodの定義

まずdefgenericで操作名を宣言します。

(defgeneric enqueue (queue value))
(defgeneric dequeue (queue))
(defgeneric queue-empty-p (queue))Code language: Lisp (lisp)

型ごとの実装はdefmethodで定義します。

(defclass linked-queue ()
  ((head :accessor queue-head :initform nil)
   (tail :accessor queue-tail :initform nil)))

(defmethod enqueue ((q linked-queue) x)
  (let ((cell (list x)))
    (if (queue-head q)
        (setf (cdr (queue-tail q)) cell
              (queue-tail q) cell)
        (setf (queue-head q) cell
              (queue-tail q) cell)))
  q)Code language: Lisp (lisp)

メソッドでは、引数のクラスを指定できます。

array-queueを追加するときは、既存のenqueueを書き換えません。
新しい型と、その型用のメソッドを足すだけです。

(defclass array-queue ()
  ((data  :accessor queue-data  :initform (make-array 256))
   (head  :accessor queue-head  :initform 0)
   (tail  :accessor queue-tail  :initform 0)))

(defmethod enqueue ((q array-queue) x)
  ;; array-queue 用の処理
  ...)Code language: Lisp (lisp)

この方法なら、呼び出し側はずっと名前を使うことができます。

(enqueue q x)
(dequeue q)Code language: Lisp (lisp)

defunでは型ごとの処理を関数内部にまとめて書いていましたが、defgenericdefmethodはその処理を関数の外側に分散させます。

3.4. defmethodは強制されない

Javaのインターフェースに似て見えますが、仕組みが違います。
Javaのインターフェースはクラスが「実装する」ことを宣言するもので、クラス定義の側にimplements Queueと書きます。

一方、Common Lispのdefgenericは、クラスとは独立していて、あとからどんな型に対してでもdefmethodを足せます。
既存のクラスを変更せずに、新しい操作を外側から追加できるのです4

4. CLOSのディスパッチは何をしているか

CLOSのメソッドは、見た目は関数呼び出しと同じです。

(enqueue q x)
;; (q enqueue x) のような順番ではない。Code language: Lisp (lisp)
CLOSディスパッチの仕組み (enqueue q x) 総称関数が引数の型を判定 linked-queue array-queue priority-queue 多重ディスパッチ 複数引数の型で選択 メソッド結合 :before / :after / :around

JavaやRubyでは、メソッド呼び出しq.enqueue(x)は、まずレシーバ q のクラスを見て、そこから定義されたメソッドが呼ばれます5
一方、Common Lispでは、まずenqueueというシンボルの関数セルを見ます。
そこには、generic function(総称関数)が入っていて、総称関数が呼び出し時に引数の型を調べ、適切なメソッドを選んで実行します。

実際にはこれより一般化された仕組みで動いていますが、イメージとしては、「型を見て処理を選ぶ」関数が自動で作られている感覚に近いです。

(defun enqueue (queue value)
  (cond
    ((typep queue 'linked-queue)
     ;; linked-queue 用のメソッド本体
     ...)
    ((typep queue 'array-queue)
     ;; array-queue 用のメソッド本体
     ...)
    (t
     (error "No applicable method"))))Code language: Lisp (lisp)

4.1. 多重ディスパッチ

CLOSのもう一つの特徴は、multiple dispatch(多重ディスパッチ)です。

JavaやRubyは第一引数であるレシーバの型が特別扱いされています。
一方、CLOSでは複数の引数の型の組み合わせで選べます。

たとえば、衝突判定など a, b 2つのオブジェクトの組み合わせで処理が決まるときにスッキリします。

(defgeneric collide (a b))

(defmethod collide ((a player) (b enemy))
  (format t "プレイヤーが敵に当たった~%"))

(defmethod collide ((a bullet) (b enemy))
  (format t "弾が敵に当たった~%"))Code language: Lisp (lisp)

衝突判定のように「どちらのオブジェクトのメソッドにするか」で迷う処理に向いています。

4.2. メソッド結合

CLOSには:before:after:aroundを使ったmethod combination(メソッド結合)があります。

(defmethod enqueue :before ((q linked-queue) value)
  (when *debug-mode*
    (format t "enqueue: ~A~%" value)))Code language: Lisp (lisp)

既存の本体を直接書き換えずに、前後に処理を差し込める仕組みです6

ログ、検証、計測などを後から追加するとき、本体のメソッドに手を入れずに済みます。
この「既存のコードを変えずに機能を足す」という方向は、JavaやRubyのオブジェクト指向と共鳴する部分があります。
ただしLispでは、クラス階層ではなくgeneric functionを中心に考える点が違います。

4.3. ディスパッチの判定コスト

ただし、このメソッド呼び出しの柔軟性にはコストが伴います。

通常のdefun呼び出しは関数本体を直接実行しますが、総称関数は呼び出しのたびに引数の型を調べてメソッドを選ぶ処理が入ります。
多くの処理系ではメソッド選択の結果をキャッシュするため、同じ型の組み合わせなら2回目以降は速くなります7
それでも、BFSの内部キューのように何十万回も呼ぶ場面では、defstructdefunの方が無難です。

CLOSは実行時の柔軟性を得るために少し実行時コストを払う仕組みなのです。

5. 使い分けの判断基準

Common Lispでは、実装が一種類に決まっていて速さと単純さが求められる場面では、defstructdefunの組み合わせで十分です。

Lispではdefstructdefclassの差は、ポリモーフィズムが必要かどうかです。
たとえば、同じリスト内に異なるタイプのオブジェクトをまとめて処理したいようなときには、defclassdefmethodを使います。
ただ、Common Lispのオブジェクト指向の設計の中心は、クラス階層ではなく総称関数になります。

つまり、CLOSでの「オブジェクト」はデータをまとめるためだけ仕組みでなく、同じ操作名を対象ごとに変えるために使うと強みが出ます。

  1. CLOSは1986年から始まったANSI X3J13委員会の標準化作業の中で設計されました。MIT FlavorsとXerox PARCのCommonLoopsという二つのオブジェクトシステムの設計を統合したもので、1988年6月にX3J13がDocument 88-002Rとして採択し、1994年のANSI Common Lisp標準に組み込まれました。 – Common Lisp Object System – Wikipedia
  2. ANSI Common Lisp仕様のTable 28-1では、sequenceのクラス優先順位リストが定義されており、list(list sequence t)vector(vector array sequence t)と規定されています。sequenceはどちらの親クラスにも当たります。 – 28.1.4. Integrating Types and Classes
  3. ANSI Common Lisp仕様によると、defstructはスロットのアクセサ関数に加えて、述語(name-p)、コンストラクタ(make-name)、コピー関数(copy-name)を自動で定義します。アクセサ関数はsetfと組み合わせて書き込みにも使えます。 – 19.2. How to Use Defstruct
  4. なお、defgenericを事前に書かずにdefmethodだけを書いた場合、Common Lispは自動的に総称関数を生成します。ただし、すでに同名のdefunが存在する場合は衝突が起きます。設計の意図を明示し、引数リストや:documentationをまとめて定義したいなら、defgenericを先に置く方が読みやすくなります。 – The Common Lisp Cookbook – Fundamentals of CLOS
  5. JavaやRubyのこの仕組みはsingle dispatch(単一ディスパッチ)と呼ばれます。メソッドの選択に使われるのは第一引数(レシーバ)の型だけです。CLOSのmultiple dispatch(多重ディスパッチ)は、複数の引数の型の組み合わせでメソッドを選ぶ点が根本的に異なります。 – Common Lisp Object System – Wikipedia
  6. ANSI Common Lisp仕様によると、standard method combinationの実行順序は次のとおりです。まず:aroundメソッドが最も特定的なものから実行され、次に:beforeメソッドが最も特定的なものから順に実行されます。その後、最も特定的なprimaryメソッドが実行され、最後に:afterメソッドが最も特定的でないものから順に実行されます。:before:afterの戻り値は無視されます。 – CLHS: Section 7.6.6.2
  7. CLOSの処理系は、直近の呼び出しで使った引数のクラスとメソッドの対応をfast-lookupキャッシュとして保持します。同じ型の組み合わせで呼ばれた場合はキャッシュから直接メソッドを取得するため、ディスパッチのオーバーヘッドを抑えられます。新しいメソッドが追加されるとキャッシュは破棄されます。 – Fundamentals of CLOS