M.Hiroi's Home Page

Common Lisp Programming

お気楽 CLOS プログラミング入門

[ PrevPage | CLOS | NextPage ]

継承

前回は CLOS のオブジェクト指向機能を使った例題として「双方向リスト」というデータ構造を作成しました。今回は「継承 (inheritance : インヘリタンス)」のお話です。まずは最初に、一般的なオブジェクト指向言語での「継承」について簡単に説明します。

●継承とは?

継承はクラスに親子関係を持たせる機能です。子供のクラスは親クラスの性質を受け継ぐことができます。プログラミング言語の場合、引き継ぐ性質は定義されたデータ型、スロットやメソッドとなります。プログラムを作っていると、いままで作ったプログラムと同じような機能が必要になる場合があります。このような場合、継承を使うことでその機能を受け継ぎ、新規の機能や変更される機能だけプログラムする、いわゆる「差分プログラミング」が可能となります。

あるクラスを継承する場合、その元になるクラスを「スーパークラス」と呼びます。そして、継承したクラスを「サブクラス」と呼びます。この呼び方はプログラミング言語によってまちまちで統一されていません。C++の場合、元になるクラスを「基底クラス」といい、継承するクラスを「派生クラス」とか「導出クラス」といいます。

継承には「単一継承」と「多重継承」の 2 種類があります。単一継承は、ただひとつのクラスからしか機能を継承することができません。したがって、クラスの階層は次のような木構造で表すことができます。


  図 1 : 単一継承のクラスの階層

継承は何段階に渡って行われてもかまいません。たとえばクラス E の場合、スーパークラスが B で、B のスーパークラスが A に設定されています。サブクラスは複数あってもかまいません。たとえば、A のサブクラスは B, C, D の 3 つ、B のサブクラスは E, F の 2 つがあります。上図ではクラス A のスーパークラスはありませんが、ほかのクラスではただひとつのスーパークラスを持っています。これが単一継承の特徴です。プログラミング言語では Smalltalk, Java, Ruby が単一継承です。

これに対し、多重継承は複数のクラスから機能を継承することができます。このため、クラスの階層は木構造ではなく、次のようなグラフで表すことができます。


  図 2 : 多重継承のクラスの階層

クラス E に注目してください。スーパークラスには B と C の 2 つがあります。多重継承では、単一継承と同じくサブクラスを複数持つことができ、なおかつ、スーパークラスも複数持つことができるのです。C++や CLOS は多重継承をサポートしています。それから、スクリプト言語の Perl や Python も多重継承です。

●単一継承の使い方

まずは「単一継承」から説明しましょう。クラスを継承する場合、defclass でスーパークラスを指定します。簡単な使用例を示します。

リスト : クラスの定義

(defclass foo ()
  ((a :accessor foo-a :initform 1 :initarg :a)
   (b :accessor foo-b :initform 2 :initarg :b)))

(defclass bar (foo)
  ((c :accessor bar-c :initform 10 :initarg :c)
   (d :accessor bar-d :initform 20 :initarg :d)))

defclass でクラス名を指定し、その次のリストでスーパークラスを指定します。クラス BAR はクラス FOO を指定しているので、FOO は BAR のスーパークラスになります。この場合、スーパークラスはひとつしかないので「単一継承」になります。ここで複数のスーパークラスを指定すると「多重継承」になります。

●スロットとメソッドの継承

それでは、実際にインスタンスを生成してみましょう。次の例を見てください。

* (setq x (make-instance 'foo))

#<FOO {1001C3FB53}>
* (setq y (make-instance 'bar))

#<BAR {1001C4CEA3}>
* (foo-a x)

1
* (foo-b x)

2
* (foo-a y)

1
* (foo-b y)

2
* (bar-c y)

10
* (bar-d y)

20

これを図に示すと次のようになります。

クラス FOO にはスロット A, B とメソッド foo-a, foo-b が定義されています。次に、クラス BAR を定義します。BAR は FOO を継承し、BAR 固有のスロット C, D とメソッド bar-c, bar-d が定義されています。FOO と BAR のインスタンスを生成すると、上図に示したように BAR のインスタンスはスロット C, D だけではなく、クラス FOO で定義されたスロット A, B も含まれます。

FOO のインスタンスにもスロット A, B がありますが、BAR のインスタンスのスロット A, B とメモリ領域を共有 [*1] することはありません。クラスはオブジェクトの設計図です。設計に共通な部分があったとしても、それから生み出されるインスタンスは別々の実体で、メモリ領域を共有することはないのです。

クラス BAR にはメソッド bar-c, bar-d しか定義されていませんが、クラス FOO を継承したことにより、メソッド foo-a, foo-b を利用することができます。BAR のインスタンス Y に対して foo-a を呼び出すと、インスタンス Y のスロット A の値を取り出すことができます。また、(setf (foo-a y) 100) とすれば、インスタンス Y のスロット A の値を書き換えることができます。次の例を見てください。

* (setf (foo-a y) 100)

100
* (foo-a y)

100
* (foo-a x)

1

setf でインスタンス Y のスロット A を 100 に変更しました。当然ですが、インスタンス X のスロット A の値は 1 のままです。スロット A は共有されていないので、インスタンス Y にメソッド foo-a を適用すれば、インスタンス Y のスロット A にアクセスすることになります。

このように、クラスを継承することでスーパークラスのスロットとメソッドをサブクラスでも利用することができます。スロットを受け継ぐことを「属性の継承」、メソッドを受け継ぐことを「実装の継承」と区別して呼ぶことがあります。

-- note --------
[*1] CLOS の場合、スロットオプション :allocation にキーワード :class を指定すると、そのスロットは共有されます。:allocation の指定がない場合、もしくはキーワード :instance を指定すると、スロットは共有されません。

●スーパークラスに同じスロット名がある場合

CLOS の場合、defclass でスロットを定義するときに、スーパークラスと同じスロット名があってもかまいません。ただし、インスタンス内では、同じスロット名でアクセスできるスロットはひとつしか存在しません。次の例を見てください。

* (defclass foo () ((a :accessor foo-a :initform 1 :initarg :a)))

#<STANDARD-CLASS COMMON-LISP-USER::FOO>
* (defclass bar (foo) ((a :accessor bar-a :initform 2 :initarg :b)))

#<STANDARD-CLASS COMMON-LISP-USER::BAR>
* (setq x1 (make-instance 'bar))

#<BAR {1001C11013}>
* (bar-a x1)

2
* (foo-a x1)

2
* (setq x2 (make-instance 'bar :a 10))

#<BAR {1001C14EE3}>
* (foo-a x2)

10
* (bar-a x2)

10
* (setq x3 (make-instance 'bar :b 100))

#<BAR {1001C176B3}>
* (bar-a x3)

100
* (foo-a x3)

100

クラス FOO はスロット A を定義しています。クラス BAR は FOO を継承していますが、同じ名前のスロット A を定義しています。この場合、インスタンスを生成すると、A に対応するスロットはひとつしかありません。このとき、スロットオプションも継承されることに注意してください。

:accessor で指定されたメソッド foo-a, bar-a はどちらも利用することができます。この場合、同じスロット A をアクセスすることになります。:initform はサブクラスの値が優先されます。したがって、(make-instance 'bar) とすると、スロット A の初期値は 2 になります。実際にメソッド foo-a, bar-a で値を求めると、2 に初期化されていることがわかります。

:initarg はどちらのキーワードでも利用可能です。この場合も同じスロットに初期値を与えることになります。FOO で指定したキーワード :a でも BAR で指定した :b でも、スロット A の初期値を与えることができます。

●データ型の継承

CLOS の場合、継承されるのはスロットやメソッドだけではなく「データ型」も継承されます。クラス名を表すシンボルは型指定子と同じ働きをします。つまり、クラス FOO のインスタンスは、データ型が FOO として扱われます。次の例を見てください。

* (defclass foo () ())

#<STANDARD-CLASS COMMON-LISP-USER::FOO>
* (defclass bar (foo) ())

#<STANDARD-CLASS COMMON-LISP-USER::BAR>
* (setq a (make-instance 'foo))

#<FOO {1001BD0013}>
* (setq b (make-instance 'bar))

#<BAR {1001BF6C33}>
* (typep a 'foo)

T
* (typep a 'bar)

NIL
* (typep b 'bar)

T
* (typep b 'foo)

T

クラス BAR はクラス FOO を継承しています。FOO のインスタンス A は typep でチェックすると、当然ですが FOO では T になり、BAR では NIL になります。ところが、クラス BAR のインスタンス B は、BAR で T になるのは当然ですが、FOO のサブクラスなのでデータ型が継承されて FOO でも T になります。

ここで、BAR を継承したクラス BAZ の作って、そのインスタンスを typep でチェックすると、FOO, BAR, BAZ のどれでも T になります。

* (defclass baz (bar) ())

#<STANDARD-CLASS COMMON-LISP-USER::BAZ>
* (setq c (make-instance 'baz))

#<BAZ {1001C13C13}>
* (typep c 'baz)

T
* (typep c 'bar)

T
* (typep c 'foo)

T

このように、クラスを継承してサブクラスを作ると、サブクラスはスーパークラスの部分集合として考えることができます。図 4 を見てください。

単一継承の場合、クラスとサブクラスは図 4 の関係になります。サブクラス BAZ は BAR や FOO に含まれているので、そのインスタンスに BAR や FOO のメソッドを適用することができるわけです。

●メソッドの選択

もうひとつ簡単な例を示しましょう。

リスト : クラスの定義 (再掲)

(defclass foo ()
  ((a :accessor foo-a :initform 1 :initarg :a)
   (b :accessor foo-b :initform 2 :initarg :b)))

(defclass bar (foo)
  ((c :accessor bar-c :initform 10 :initarg :c)
   (d :accessor bar-d :initform 20 :initarg :d)))
* (defmethod add ((z foo)) (+ (foo-a z) (foo-b z)))

#<STANDARD-METHOD COMMON-LISP-USER::ADD (FOO) {1001C8A933}>
* (setq x (make-instance 'foo :a 10 :b 20))

#<FOO {1001C8F843}>
* (setq y (make-instance 'bar :a 100 :b 200))

#<BAR {1001C983A3}>
* (add x)

30
* (add y)

300

メソッド add を定義しました。add はスロット A と B を足し算します。このメソッドは引数のデータ型 (引数特定子) にクラス FOO を指定しているので、FOO のインスタンスだけではなく、FOO のサブクラス BAR のインスタンスにも適用することができます。クラス BAR のインスタンスを生成して変数 Y にセットします。このインスタンスにメソッド add を適用すると、スロット A と B を足した値を求めることができます。

それでは、メソッドの引数特定子にサブクラス bar を指定した場合はどうなるのでしょうか。実際に試してみましょう。

* (defmethod sub ((z bar)) (- (foo-a z) (foo-b z)))

#<STANDARD-METHOD COMMON-LISP-USER::SUB (BAR) {1001D060D3}>
* (sub y)

-100
* (sub x)

=> エラーとなる

スロット A, B の差分を求めるメソッド sub を定義します。sub の引数特定子はクラス BAR なので、クラス BAR のインスタンス Y に sub を適用すると差分を求めることができます。ところが、スーパークラス FOO のインスタンス X に sub を適用すると、「適用できるメソッドがない」というエラーが発生します。クラス FOO にもスロット A, B があるのですが、このメソッドを FOO のインスタンスに適用することはできないのです。

ここで、CLOS がどのようにメソッドを選択するか簡単に説明しましょう。まず、総称関数 add に引数特定子が BAR のメソッドが定義されているか調べます。ところが、総称関数 add には引数特定子が BAR のメソッドは定義されていません。この場合、データ型のスーパークラスをチェックします。 BAR のスーパークラスは FOO なので、引数特定子が FOO のメソッドが定義されているか総称関数 add を調べます。ここで該当するメソッドが見つかり、それを評価するのです。

総称関数 sub の場合、引数特定子が FOO のメソッドは定義されていませんね。そこで、FOO のスーパークラスを調べようとするのですが、FOO のスーパークラスは定義されていません。これ以上探索するクラスがないのでエラーになるのです。このように、メソッドはサブクラスからスーパークラスの方向へ探索することを覚えておいてください。

●複数の引数がある場合

では、複数の引数特定子を持つメソッドの場合はどうなるのでしょうか。次の例を見てください。

;;; クラスの定義
(defclass foo () ((a :accessor foo-a :initform 1)))
(defclass bar () ((b :accessor bar-b :initform 2)))
(defclass foo1 (foo) ((c :accessor foo-c :initform 3)))
(defclass bar1 (bar) ((d :accessor bar-d :initform 4)))

;;; メソッド A
(defmethod baz ((x foo) (y bar)) (format t "foo-bar method~%"))

;;; メソッド B
(defmethod baz ((x foo1) (y bar1)) (format t "foo1-bar1 method~%"))
* (defvar x1 (make-instance 'foo))

X1
* (defvar x2 (make-instance 'foo1))

X2
* (defvar y1 (make-instance 'bar))

Y1
* (defvar y2 (make-instance 'bar1))

Y2
* (baz x1 y1)
foo-bar method
NIL
* (baz x2 y2)
foo1-bar1 method
NIL
* (baz x2 y1)
foo-bar method
NIL

総称関数 baz には、引数特定子が FOO と BAR のメソッド A と、FOO1 と BAR1 のメソッド B が定義されています。(baz x1 y1) の場合、X1 が FOO のインスタンスで Y1 が BAR のインスタンスなのでメソッド A が呼び出されます。次は (baz x2 y2) ですが、X2 が FOO1 のインスタンスで Y2 が BAR1 のインスタンスなのでメソッド B が呼び出されます。メソッドの選択は簡単なように思えますが、実際にはちょっと複雑な処理を行っています。

最初に、第 1 引数のインスタンス X2 に適用可能なメソッドを選びます。この場合、X2 のクラス FOO1 と引数特定子が一致するメソッド B のほかに、引数特定子が FOO であるメソッド A も適用することができますね。FOO1 は FOO のサブクラスなので、インスタンス X2 のデータ型は FOO として扱うことができるからです。ただし、メソッドを選ぶ優先順位はサブクラス FOO1 のメソッド B の方が高くなります。メソッドはサブクラスからスーパークラスに向かって探索されるので、優先順位はサブクラスのメソッドの方が高くなるのです。

次に、第 2 引数のインスタンス Y2 に適用可能なメソッドを選びます。この場合も、メソッド A と B を適用することができますが、第 1 引数と同じ理由でメソッド B の方が優先順位が高くなります。したがって、メソッド B が選択されます。このように、適用可能なメソッドが複数ある場合は、もっとも特定的なメソッド (引数特定子がサブクラスのメソッド) が選択されます。

その次の (baz x1 y2) は簡単です。第 1 引数のインスタンス X1 に適用可能なメソッドは A しかありません。メソッド A は第 2 引数のインスタンス Y2 にも適用できるので、メソッド A が選択されます。最後の (baz x2 y1) ですが、第 1 引数のインスタンス X2 に適用可能なメソッドは A と B の 2 つあります。次に第 2 引数のインスタンス Y1 に適用可能なメソッドを選びますが、インスタンス Y1 のデータ型は BAR なので、適用可能なメソッドは A しかありません。したがって、メソッド A が選択されます。

このように、引数が複数ある場合は第 1 引数から順番に適用可能なメソッドを調べていきます。この例は単一継承なのでそれほど難しくありませんが、CLOS は「多重継承」をサポートしているので、メソッドの選択はもっと複雑になります。これは多重継承のところで詳しく説明することにします。

●メソッドのオーバーライド

ところで、継承したクラスのメソッドとは違う働きをさせたい場合はどうするのでしょうか。これはとても簡単で、同名のメソッドを定義することで、そのクラスのメソッドを設定することができます。この機能を「オーバーライド (over ride)」といいます。

メソッド仕組みから見た場合、オーバーライドは必然の動作です。選択されるメソッドはもっとも特定的なメソッド、つまり引数特定子がサブクラスのメソッドになるので、サブクラスにメソッドを定義すれば、スーパークラスのメソッドではなくサブクラスのメソッドが選択されるのです。

簡単な例を示しましょう。

リスト : メソッドのオーバーライド (1)

(defclass foo ()
  ((a :accessor foo-a :initform 1 :initarg :a)
   (b :accessor foo-b :initform 2 :initarg :b)))

(defclass bar (foo)
  ((c :accessor bar-c :initform 10 :initarg :c)
   (d :accessor bar-d :initform 20 :initarg :d)))

(defmethod add ((z foo)) (+ (foo-a z) (foo-b z)))

(defmethod add ((z bar))
  (+ (foo-a z) (foo-b z) (bar-c z) (bar-d z)))
* (setq y (make-instance 'bar :a 10 :b 20 :c 30 :d 40))

#<BAR {1001CE3BB3}>
* (add y)

100

foo のメソッド add はスロット A, B の値を足し算しましたね。この状態で、メソッド add をクラス BAR のインスタンス Y に適用すると、スロット A, B を足した値を返します。ここで、BAR のメソッド ADD を定義します。今度は、スロット A, B, C, D の値を足し算します。そのあと、メソッド add をインスタンス Y に適用すると、すべてのスロットの値を足した値 100 を返します。このように FOO のメソッドではなく、サブクラス BAR のメソッドが評価されました。

ところで、スロット A, B の足し算は FOO のメソッド add で定義されていました。このメソッドを呼び出すことができれば、わざわざメソッド foo-a, foo-b を呼び出す必要はありません。いいかえれば、スーパークラスのメソッドと同じプログラムを書かなくてもよいわけです。スーパークラスのメソッドを呼び出す機能は、オブジェクト指向言語では当然の機能といえるでしょう。

CLOS の場合、スーパークラスのメソッドを呼び出すには関数 call-next-method を使います。

call-next-method [引数 ...]

引数が省略された場合、call-next-method には評価中のメソッドと同じ引数が与えられます。それでは、この関数を使ってクラス bar のメソッド add を書き直してみましょう。

リスト : メソッドのオーバーライド (2)

(defmethod add ((z bar))
  (+ (call-next-method) (bar-c z) (bar-d z)))

call-next-method により FOO のメソッド add が呼び出されて、スロット A, B を足し算した値を返します。この例は簡単すぎるので、あまりメリットを感じないかもしれません。ところが、スーパークラスのメソッドが複雑な処理をしていて、サブクラスのメソッドでも同様の処理が必要な場合には、その力を十分に発揮してくれるでしょう。

それでは、複数の引数特定子を持つメソッドの場合はどうなるのでしょうか。次の例を見てください。

;;; クラスの定義
(defclass foo () ((a :accessor foo-a :initform 1)))
(defclass bar () ((b :accessor bar-b :initform 2)))
(defclass foo1 (foo) ((c :accessor foo-c :initform 3)))
(defclass bar1 (bar) ((d :accessor bar-d :initform 4)))

;;; メソッド A
(defmethod baz ((x foo) y) (format t "foo-other method~%"))

;;; メソッド B
(defmethod baz ((x foo) (y bar))
  (call-next-method)
  (format t "foo-bar method~%"))

;;; メソッド C
(defmethod baz ((x foo1) (y bar1))
  (call-next-method)
  (format t "foo1-bar1 method~%"))
* (defvar x1 (make-instance 'foo))

X1
* (defvar x2 (make-instance 'foo1))

X2
* (defvar y1 (make-instance 'bar))

Y1
* (defvar y2 (make-instance 'bar1))

Y2
* (baz x1 y1)
foo-other method
foo-bar method
NIL
* (baz x2 y2)
foo-other method
foo-bar method
foo1-bar1 method
NIL

総称関数 baz には 3 つのメソッド A, B, C が定義されています。メソッド A の第 2 引数には引数特定子が指定されていないので、第 2 引数がどのデータ型でも適用することができます。最初の (baz x1 y1) の場合、適用可能なメソッドは A と B の 2 つがあります。メソッドの優先順位は引数特定子が指定されているメソッド B の方が高くなります。ここで、メソッドの優先順位をリスト (B A) と表すことにしましょう。

最初に、メソッド B が呼び出されます。次に、メソッド B は call-next-method を評価します。call-next-method は、実行しているメソッド B の次に優先順位が高いメソッドをリスト (B A) から探します。この場合、メソッド A が呼び出されます。したがって、実行結果は foo-other method と boo-bar method が表示されます。

次の (baz x2 y2) の場合、適用可能なメソッドは A, B, C の 3 つあり、優先順位は (C B A) になります。最初にメソッド C が呼び出され、call-next-method により次に優先順位が高いメソッド B が呼び出されます。そして、メソッド B の call-next-method によりメソッド A が呼び出されます。その結果、foo-other mthod, foo-bar method, foo1-bar1 method と表示されます。

もしも、call-next-method で次に適用できるメソッドが見つからない場合はエラーになります。次の例を見てください。

* (defmethod baz1 ((x foo)) (call-next-method) (print "foo baz1"))

#<STANDARD-METHOD COMMON-LISP-USER::BAZ1 (FOO) {100209CE13}>
* (baz1 x1)

=> エラー "There is no next method for the generic function"

新しいメソッド baz1 を定義しました。次に (baz1 x1) を評価しますが、適用可能なメソッドはひとつしかありませんね。次に、そのメソッドで call-next-method が評価されますが、適用可能なメソッドはもうありません。したがって、call-next-method でエラーが発生するのです。

call-next-method で呼び出されるメソッドを「次メソッド (next method)」と呼びます。次メソッドが存在するか否かを調べる述語に next-method-p があります。next-method-p は次メソッドが存在すれば真 (T) を返し、そうでなければ偽 (NIL) を返します。next-method-p を使ってメソッド baz1 を修正すると、次のようになります

リスト : メソッド baz1 の修正

(defmethod baz1 ((x foo))
  (if (next-method-p) (call-next-method))
  (print "foo baz1"))

baz1 の場合、next-method-p は NIL を返すので call-next-method は実行されません。これで正常に baz1 を評価することができます。


Copyright (C) 2003-2020 Makoto Hiroi
All rights reserved.

[ PrevPage | CLOS | NextPage ]