プログラミングに興味のある方ならば、「オブジェクト指向」という言葉は聞いたことがあると思います。よく使われているオブジェクト指向言語に C++ や Java があります。また、Lightweight Language と呼ばれているプログラミング言語、たとえば Perl, Python, Ruby, JavaScript などはオブジェクト指向をサポートしています。
多くの言語でサポートされている「オブジェクト指向」ですが、関数型言語では Common Lisp の CLOS (Common Lisp Object System) が有名でしょう。CLOS は Smalltalk, C++, Java などのポピュラーなオブジェクト指向とはちょっと違っていて、興味深い機能がたくさんあります。クラス、インスタンス、メソッド、継承などの一般的なオブジェクト指向機能のほかに、総称関数 (generic function) やメソッド結合 (method combination) などのユニークな機能があります。
CLOS は巨大なオブジェクト指向システムなので、CLtL2 (参考文献『COMMON LISP 第 2 版』) の第 28 章「Common Lisp Object System」を読むだけでも一苦労しますが、CLOS は自然な形で Common Lisp を拡張しているので、基本的な機能はとても簡単に使うことができると思います。やさしいところから少しずつ勉強していきましょう。
それでは、最初にオブジェクト指向について簡単に説明します。なお、この説明は拙作のページ「Lightweight Language: お気楽 Python プログラミング入門第 5 回」と同じです。既に読んだことがある方や、一般的なオブジェクト指向について理解されている方は、読み飛ばしてもらってもかまいません。
プログラムを作る場合、全体を小さな処理に分割して、ひとつひとつの処理を作成し、それらを組み合わせて全体のプログラムを完成させます。このとき、基本的な部品となるのが関数です。つまり、処理を関数単位で分割して、それらを組み合わせてプログラムを作るわけです。もともと関数の役割は、入力されたデータを処理してその結果を返すことです。つまり、関数は機能を表しているのです。このため、全体を小さな処理に分割するにしても、機能単位で行われることが普通です。
オブジェクト指向プログラミングでは、関数ではなく「オブジェクト (object)」を部品として扱います。たとえば、えんぴつを考えてみましょう。えんぴつには、色、長さ、固さ、などいろいろな性質があります。そして、えんぴつを使って文字を書いたり、絵を描いたりすることができます。プログラムでは、このような性質をデータで表し、機能を関数で表すことになります。そしてオブジェクトとは、このデータと関数を結び付けたものなのです。
いままでのプログラミング言語では、データと関数を別々に定義するため、それをひとつのオブジェクトとして表すことができません。えんぴつで文字を書くにも、えんぴつの種類をチェックして文字を書くようにプログラムしなければいけません。ところが、オブジェクトはデータと関数を結び付けたものなので、自分がなにをしたらよいかわかっています。えんぴつオブジェクトに文字を書けと命じれば、それが赤えんぴつのオブジェクトであれば文字は赤に、黒えんぴつのオブジェクトであれば黒い文字になるのです。
このように、オブジェクトはデータと関数をひとつにまとめたものです。従来のプログラミングが全体を機能単位で分割するのに対し、オブジェクト指向プログラミングでは全体をオブジェクト単位に分割して、それを組み合わせることでプログラムを作成します。
ところで、データと関数を結び付けることは、従来のプログラミング言語でも可能です。オブジェクト指向はプログラミングの考え方のひとつであり、C++ のようなオブジェクト指向言語を使わなくても、たとえばC言語でもその考え方にしたがってプログラムを作れば、オブジェクト指向プログラミングになります。
実際、オブジェクト指向には様々な考え方があり、いろいろなオブジェクト指向言語が存在します。ですが、データと関数をひとつにまとめたものをオブジェクトとして扱うという基本的な考え方は、オブジェクト指向言語の元祖と言われる Smalltalk でも、C++, Java, CLOS でも同じです。
次は、一般的なオブジェクト指向機能について簡単に説明します。
「クラス (class)」はオブジェクトの振る舞いを定義したものです。ここでデータを格納するための変数や、それを操作する関数が定義されます。この変数をメンバ変数とかインスタンス変数といい、関数を「メソッド (method)」といいます。メソッドはあとで説明します。
クラスはオブジェクトの設計図にあたるもので、オブジェクトの「雛形」と呼ぶこともあります。クラスはオブジェクトの振る舞いを定義するだけで、アクセスできる実体はなにも生み出していない、ということに注意してください。
このクラスから実体として作り出されるのが「インスタンス (instance)」です。このインスタンスを「オブジェクト」と考えてください。インスタンスを生成する方法は、当然ですがプログラミング言語によって違います。たとえば C++ や Java は new を使います。図 1 を見てください。
┌─ class Foo ─┐ ┌─ instance ─┐ │ │ │ │ │ 設計図 │─ インスタンスの生成 →│ 実体 │ │ │ │ │ └────────┘ └───────┘ │ │ │ ┌─ instance ─┐ │ │ │ └───── インスタンスの生成 →│ 実体 │ │ │ └───────┘ 図 1 : クラスとインスタンスの関係
クラスはオブジェクトの定義を表すものですから、Foo というクラスはひとつしかありません。これに対し、インスタンスはクラスから生み出されるオブジェクトです。たとえば、クラス Foo に new を適用することで、いくつでもインスタンスを生み出すことができるのです。クラスは設計図であり、それに従って作られるオブジェクトがインスタンスと考えるとわかりやすいでしょう。
メソッドはオブジェクトと結びついた関数です。オブジェクト指向プログラミングでは、ほかの関数から直接オブジェクトを操作することはせず、メソッドを呼び出すことで行います。メソッドは、クラスが異なっていれば同じ名前のメソッドを定義することができます。たとえば、クラス Foo1 にメソッド bar() が定義されていても、クラス Foo2 に同名のメソッド bar() を定義することができます。
そして、ここからが重要なのですが、あるオブジェクトに対してメソッド bar() を呼び出した場合、それが Foo1 から作られたオブジェクトであれば、Foo1 で定義された bar() が実行され、Foo2 から作られたオブジェクトであれば、Foo2 で定義された bar() が実行されるのです。
このように、オブジェクトが属するクラスによって、実行されるメソッドが異なるのです。この機能を「ポリモーフィズム(polymorphism)」と呼びます。これにより、オブジェクトは自分が行うべき適切な処理を実行できるわけです。
クラス、インスタンス、メソッドの関係は図 2 のようになります。
┌─ class Foo1 ─┐ ┌─ instance ─┐ │ │ │ │ │ 設計図 │─── 生成 ───→│ 実体 │ │ │ │ │ │ │ └───────┘ │┌─ method ─┐│ ↑ ││ ││ │ ││ bar()←─┼┼─── アクセス ─────┘ ││ ││ │└──────┘│ └────────┘ 図 2 : クラス、インスタンス、メソッドの関係
クラスという設計図が中心にあり、そこからインスタンスが生み出され、メソッドを使ってインスタンスを操作する、という関係になります。
さて、一般的な話はここまでにして、CLOS のオブジェクト指向機能を説明していきましょう。CLOS の場合、クラスはマクロ defclass を使って定義します。defclass は構造体 defstruct とよく似ています。構造体の説明は拙作のページ「お気楽 Common Lisp プログラミング入門: 構造体」をお読みください。defclass の構文を次に示します。
(defclass クラス名 (スーパークラス ...) ((スロット名 :accessor アクセスメソッド名 :initform 初期値フォーム :initarg 引数マークシンボル) ・・・・ (スロット名 :accessor アクセスメソッド名 :initform 初期値フォーム :initarg 引数マークシンボル)))
defclass の次にクラス名をシンボルで指定し、その次にスーパークラスをリストで指定します。CLOS は「多重継承」をサポートしているので、リスト内に複数のスーパークラスを指定することができます。継承はあとで詳しく説明します。
スロット (slot) とは、クラスで定義した変数のことです。スロット名の指定にはシンボルを使います。スロットは変数と同様に S 式であれば何でも格納することができます。これは構造体と同じですね。スロットのあとにスロットオプションを指定することができます。defclass でよく使われるスロットオプションには :accessor, :initform, :initarg などがあります。
定義されたクラス名は、あとで説明するインスタンス、メソッド、継承機能の中で、データ型を表す識別子として重要な役割を果たします。つまり、構造体と同様にクラスは Lisp システムの中で新しい「データ型」として機能するのです。したがって、データ型を検査する関数 typep やデータ型を求める関数 type-of でクラス名を利用することができます。
ここで、注意してもらいたいことがあります。クラスはデータ型を定義するだけで、アクセスできる実体は何も生み出していないのです。クラスというデータ型をアクセスできる実体として作り出したのが「インスタンス (instance)」です。CLOS の場合、このインスタンスを「オブジェクト」と考えてください。
インスタンスは関数 make-instance を使って生成します。
make-instance クラス名 [引数マークシンボル S式 ...]
スロットは make-instance でインスタンスを生成するときに初期化されます。初期値は引数マークシンボルの後ろの S 式になります。make-instance は関数なので、S 式は評価されることに注意してください。この初期値は引数マークシンボルに対応するスロットにセットされます。スロットの初期値が省略された場合は、defclass の :initform で設定した初期値フォームを評価して、その結果をスロットに格納します。
簡単な使用例を示しましょう。
* (defclass foo () ((a :accessor foo-a :initform 1 :initarg :a))) #<STANDARD-CLASS COMMON-LISP-USER::FOO> * (setq x (make-instance 'foo)) #<FOO {1001BD8B43}> * (foo-a x) 1 * (setf (foo-a x) 2) 2 * (foo-a x) 2 * (typep x 'foo) T * (type-of x) FOO * (setq y (make-instance 'foo :a 10)) #<FOO {1001C18BB3}> * (foo-a y) 10
defclass でクラス FOO を定義します。FOO にはスロット A が定義されています。そして、:accessor に foo-a を指定しているので、スロット A にアクセスするメソッド foo-a が生成されます。それから、:initform に初期値 1 を、:initarg にキーワード :a を指定します。引数マークシンボルは通常のシンボルでもかまいませんが、キーワードを使った方がわかりやすいでしょう。
次に、make-instance でクラス FOO のインスタンスを生成し、変数 X にセットします。スロット A は :accessor で指定したメソッド foo-a でアクセスすることができます。実際に (foo-a x) を評価すると値は 1 になります。スロット A は :initform の値 1 で初期化されていることがわかります。また、(setf (foo-a x) 2) とすれば、スロット A の値を 2 に書き換えることができます。
それから、クラス名 FOO はデータ型名として機能するので、クラス FOO のインスタンスのデータ型は FOO になります。したがって、(typep x 'foo) は真 (T) になります。また、関数 type-of で変数 X のデータ型を求めると FOO になります。
make-instance でスロットの初期値を指定するには、:initarg で指定した引数マークシンボル :a を使います。この場合 :initform で指定された初期値フォームは無効になります。実際に :a に 10 を指定してインスタンスを生成すると、スロット A は 10 に初期化されます。
くどいようですが、クラスはデータ型を定義するだけで、実際にアクセスするオブジェクトがインスタンスである、ということに注意してください。次の図を見てください。
変数 x ┌─ class foo ─┐ ┌─ instance ─┐ │ [データ型] │ │[オブジェクト]│ │ slot a : │─ make-instance ─→│ slot a : 1 │ └────────┘ └───────┘ │ │ 変数 y │ ┌─ instance ─┐ │ │[オブジェクト]│ └───── make-instance ─→│ slot a : 10 │ └───────┘ 図 3 : クラスとインスタンスの関係
クラスはデータ型を表すので、FOO というクラスは Lisp システムの中にはひとつしかありません。これに対し、インスタンスは make-instance によりクラスから生み出されるオブジェクトです。クラス FOO に make-instance を適用すれば、いくつでもインスタンスを生み出すことができるのです。クラスは設計図であり、それに従って作られるオブジェクトがインスタンス、それを作り出す工場が make-instance と考えるとわかりやすいでしょう。
メソッドは特定のクラス(データ型)と結びついた関数です。メソッドの特徴は、同じ名前のメソッドをいくつも定義することができ、引数のデータ型によって、その中から実際に呼び出すメソッドを自動的に選択することです。該当するメソッドが見つからない場合はエラーとなります。
CLOS では同一名のメソッドの集まりを「総称関数 (generic function)」と呼びます。この総称関数がC++や Java などのオブジェクト指向とはちょっと違う CLOS の特徴です。
メソッドの定義にはマクロ defmethod を使います。
defmethod メソッド名 ((仮引数名 データ型) or 引数 ... ) S式 ...
defmethod は defun と構造がよく似ていまが、仮引数リストが異なっています。仮引数名を表すシンボルとデータ型をリストに格納して表します。データ型 [*1] はクラス名か型指定子でなければいけません。データ型を省略した場合は、すべてのデータ型とマッチングすることになります。
CLOS の場合、クラス、インスタンス、メソッドの関係は次のようになります。
┌─class foo ──┐ ┌─ instance ─┐ │ slot a │── make-instnace →│ slot a : 100 │ └────────┘ └───────┘ │ ↑ │defmethod │ ↓ │Write ┌── 総称関数 bar ─┐ │ │┌─ bar: foo ──┐│ Read │ ││ ←┼┼─────────────┘ │└────────┘│ └──────────┘ 図 4 : クラス、インスタンス、メソッドの関係
CLOS のメソッドは、クラスの中に定義されているのではありません。個々のメソッドは総称関数に登録され、呼び出されるときに引数のデータ型によって適切なメソッドが選択されるのです。クラスとメソッドの結び付きはC++や Java よりも弱いですが、そのかわり柔軟なプログラミングが可能になります。
それでは簡単な使用例を示します。
* (defclass foo1 () ()) #<STANDARD-CLASS COMMON-LISP-USER::FOO1> * (defclass foo2 () ()) #<STANDARD-CLASS COMMON-LISP-USER::FOO2> * (defmethod bar ((x foo1)) (print "foo1 bar")) #<STANDARD-METHOD COMMON-LISP-USER::BAR (FOO1) {1001C970C3}> * (defmethod bar ((x foo2)) (print "foo2 bar")) #<STANDARD-METHOD COMMON-LISP-USER::BAR (FOO2) {1001CDB863}> * (setq a (make-instance 'foo1)) A * (setq b (make-instance 'foo2)) B * (bar a) "foo1 bar" "foo1 bar" * (bar b) "foo2 bar" "foo2 bar"
メソッド bar が 2 つ定義されています。どちらのメソッドも総称関数 bar に登録されます。そして、総称関数 bar を呼び出すとき、引数 X がクラス FOO1 のインスタンスであれば、最初に定義したメソッドが選択され "foo1 bar" が表示されます。引数 X がクラス FOO2 のインスタンスであれば、次に定義したメソッドが選択されるので "foo2 bar" が表示されます。このように、CLOS は総称関数 bar を呼び出すとき、引数のデータ型によってその中から適切なメソッドを選択して実行するのです。
また、複数の引数にデータ型を指定して、適切なメソッドを呼び出すこともできます。次の例を見てください。
* (defmethod baz ((x integer) (y integer)) (format t "integer ~D, integer ~D~%" x y)) #<STANDARD-METHOD COMMON-LISP-USER::BAZ (INTEGER INTEGER) {1001C35EF3}> * (defmethod baz ((x integer) (y float)) (format t "integer ~D, float ~E~%" x y)) #<STANDARD-METHOD COMMON-LISP-USER::BAZ (INTEGER FLOAT) {1001CC01E3}> * (defmethod baz ((x float) (y float)) (format t "float ~E, float ~E~%" x y)) #<STANDARD-METHOD COMMON-LISP-USER::BAZ (FLOAT FLOAT) {1001D42B73}> * (baz 1 2) integer 1, integer 2 NIL * (baz 1 2.0) integer 1, float 2.e+0 NIL * (baz 1.0 2.0) float 1.e+0, float 2.e+0 NIL
CLOS のメソッドは、クラスだけではなく通常のデータ型でも定義することができます。また、メソッドを定義する場合、引数の個数は同じでなければいけません。たとえば、最初に引数 2 個のメソッド baz を定義したら、引数 1 個の baz は定義できないのです。ご注意ください。
ここではメソッド baz を 3 つ定義していますが、引数 X と Y のデータ型により適切なメソッドが選択されます。もしも、引数 Y はどんなデータ型でもよければ、次のようにデータ型の指定を省略することで実現できます。
* (defmethod baz ((x integer) y) (format t "integer ~D, other ~S~%" x y)) #<STANDARD-METHOD COMMON-LISP-USER::BAZ (INTEGER T) {1001F4A4E3}> * (baz 1 "test") integer 1, other "test" NIL
引数 Y にはデータ型の指定がないことに注意してください。例のように引数 X が整数値で引数 Y が文字列の場合は、ここで定義したメソッドが選択されます。ただし、引数 Y が float の場合は、前に定義したメソッドが選択されます。
ここで defmethod の返り値に注目してください。引数のデータ型が表示されていますね。引数 Y はデータ型を指定していないはずですが、Y のデータ型は T になっています。実をいうと、メソッドの選択は「継承」と密接に関連しています。これは次回以降に詳しく説明しましょう。
ここで、スロットのアクセスで役に立つ関数を紹介しましょう。今までは defclass の :accessor で指定したメソッドを使ってスロットにアクセスしました。このほかに、スロットは関数 slot-value でアクセスすることができます。
slot-value instance slot-name
slot-value はインスタンスのスロットに格納されている値を返します。また、setf と slot-value を使ってスロットの値を更新することができます。簡単な使用例を示しましょう。
* (defclass foo () ((a :initform 10 :initarg :a) (b :initform 20 :initarg :b))) #<STANDARD-CLASS COMMON-LISP-USER::FOO> * (setq x (make-instance 'foo)) #<FOO {1001BE56A3}> * (slot-value x 'a) 10 * (slot-value x 'b) 20 * (slot-value x 'c) => エラー * (setf (slot-value x 'a) 100) 100 * (slot-value x 'a) 100
スロット A, B を持つクラス FOO を定義します。defclass の :assessor にはメソッドの指定がないことに注意してください。次に、make-instance でクラス FOO のインスタンスを生成して変数 X にセットします。
slot-value は :accessor にメソッドの指定がなくても、スロットにアクセスすることができます。slot-value でスロット A, B の値を求めると 10 と 20 になります。slot-value は関数なので引数は評価されます。スロット名はクォートすることをお忘れなく。また、(slot-value x 'c) はクラス FOO にスロット C が存在しないのでエラーになります。
次に、(setf (slot-value x 'a) 100) でスロット A に 100 をセットします。そのあと slot-value でスロット A の値を求めると 100 に書き換えられていることがわかります。
最後に簡単な例として、点を表すクラスを作ってみましょう。名前は POINT にしました。x 座標をスロット X に、y 座標をスロット Y に格納します。次のリストを見てください。
リスト : POINT クラス (defclass point () ((x :accessor get-x :initform 0 :initarg :x) (y :accessor get-y :initform 0 :initarg :y))) ;;; 2 点間の距離を求める (defmethod distance ((p1 point) (p2 point)) (let ((dx (- (get-x p1) (get-x p2))) (dy (- (get-y p1) (get-y p2)))) (sqrt (+ (* dx dx) (* dy dy)))))
define-class の :accessor でスロット X, Y のアクセス関数 get-x, get-y を定義します。メソッド distance は POINT クラスのインスタンスを 2 つ受け取り、その距離を計算します。その中でアクセスメソッド get-x, get-y を呼び出して、スロット X, Y の値を取得します。
それでは実際に試してみましょう。
* (setq p1 (make-instance 'point)) #<POINT {1001D82B53}> * (setq p2 (make-instance 'point :x 10 :y 10)) #<POINT {1001DFA763}> * (distance p1 p2) 14.142136
次は、3 次元の座標を表す POINT3D クラスを作ります。次のリストを見てください。
リスト : POINT3D クラス (defclass point3d () ((x :accessor get-x :initform 0 :initarg :x) (y :accessor get-y :initform 0 :initarg :y) (z :accessor get-z :initform 0 :initarg :z))) (defmethod distance ((p1 point3d) (p2 point3d)) (let ((dx (- (get-x p1) (get-x p2))) (dy (- (get-y p1) (get-y p2))) (dz (- (get-z p1) (get-z p2)))) (sqrt (+ (* dx dx) (* dy dy) (* dz dz)))))
クラス POINT3D は POINT を 3 次元に拡張しただけです。このように、POINT でも POINT3D でも距離を計算するメソッド distance を定義することができます。
それでは実行してみましょう。
* (setq p3 (make-instance 'point3d)) #<POINT3D {1001EAF533}> * (setq p4 (make-instance 'point3d :x 10 :y 10 :z 10)) #<POINT3D {1001F324B3}> * (distance p3 p4) 17.320509
このように、引数のクラスによって適切なメソッドが呼び出され、ポリモーフィズムが働いていることがわかります。もしも、ポリモーフィズムを利用せずにプログラムすると、distance の中でインスタンスのクラスをチェックしなければいけません。述語 typep を使って distance を書き換えると、次のようになります。
リスト : ポリモーフィズムを使わない distance の定義 (defun distance (p1 p2) (cond ((and (typep p1 'point) (typep p2 'point)) (let ((dx (- (get-x p1) (get-x p2))) (dy (- (get-y p1) (get-y p2)))) (sqrt (+ (* dx dx) (* dy dy))))) ((and (typep p1 'point3d) (typep p2 'point3d)) (let ((dx (- (get-x p1) (get-x p2))) (dy (- (get-y p1) (get-y p2))) (dz (- (get-z p1) (get-z p2)))) (sqrt (+ (* dx dx) (* dy dy) (* dz dz))))) (t (error "distance -- oops!"))))
* (distance p1 p2) 14.142136 * (distance p3 p4) 17.320509 * (distance p1 p4) => エラー "distance -- oops!"
distance は 2 つのデータを扱うだけなので、プログラムはそれほど複雑にはなりません。しかし、たくさんのデータを扱うようになると、それだけプログラムは複雑になります。とくに新しいデータを追加する場合、プログラムの内部でデータの種別をチェックしている箇所をすべて調べて、そこに新しい処理を追加しなければいけません。プログラムの規模が大きくなると、修正箇所を調べるだけでも大変です。
ところが、ポリモーフィズムを使ってプログラムを作ると、新しいデータを追加するにしても、そのデータを表すクラスとメソッドを定義するだけでいいのです。あとは CLOS がインスタンスに合わせて適切なメソッドを呼び出してくれます。オブジェクト指向では、オブジェクトをひとつの部品として扱います。新しい部品を追加するにしても、今までの部品を修正せずにそのまま使えた方が便利です。ポリモーフィズムはオブジェクト指向に必須の機能なのです。