M.Hiroi's Home Page

Functional Programming

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

オブジェクト指向編

[ PrevPage | Scheme | NextPage ]

継承

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

●継承とは?

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

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

たとえば、クラス Foo1 を継承してクラス Foo2 を定義しましょう。クラス Foo1 にはメソッド bar が定義されています。クラス Foo2 にメソッド bar は定義されていませんが、Foo2 のオブジェクトに対して bar を呼び出すと、スーパークラス Foo1 のメソッド bar が実行されるのです。

メソッドの選択は次のように行われます。まず、オブジェクトが属するクラス Foo2 にメソッド bar が定義されているか調べます。ところが、Foo2 には bar が定義されていないので、スーパークラスである Foo1 に bar が定義されているか調べます。ここでメソッド bar が見つかり、それを実行するのです。このように、メソッドが見つかるまで順番にスーパークラスを調べていきますが、最上位のスーパークラスまで調べてもメソッドが見つからない場合はエラーとなります。

継承したクラスのメソッドとは違う働きをさせたい場合、同名のメソッドを定義することで、そのクラスのメソッドを設定することができます。これを「オーバーライド (over ride) 」といいます。メソッドを選択する仕組みから見た場合、オーバーライドは必然の動作です。メソッドはサブクラスからスーパークラスに向かって探索されるので、スーパークラスのメソッドよリサブクラスのメソッドが先に選択されるわけです。

●単一継承と多重継承

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


  図 1 : 単一継承におけるクラスの階層

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

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


  図 2 : 多重継承におけるクラスの階層

クラス E に注目してください。スーパークラスには B と C の 2 つがあります。多重継承では、単一継承と同じくサブクラスを複数持つことができ、なおかつ、スーパークラスも複数持つことができるのです。C++, Perl, Python, CLOS は多重継承をサポートしています。Gauche のオブジェクト指向システムも多重継承です。

●単一継承の使い方

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

リスト 1 : クラスの定義

(define-class <foo> ()
  ((a :accessor foo-a :init-value 1 :init-keyword :a)
   (b :accessor foo-b :init-value 2 :init-keyword :b)))

(define-class <bar> (<foo>)
  ((c :accessor bar-c :init-value 10 :init-keyword :c)
   (d :accessor bar-d :init-value 20 :init-keyword :d)))

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

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

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

gosh> (define x (make <foo>))
x
gosh> (define y (make <bar>))
y
gosh> (foo-a x)
1
gosh> (foo-b x)
2
gosh> (bar-c y)
10
gosh> (bar-d y)
20
gosh> (foo-a y)
1
gosh> (foo-b y)
2

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

クラス <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 の値を取り出すことができます。また、(set! (foo-a y) 100) とすれば、インスタンス y のスロット a の値を書き換えることができます。次の例を見てください。

gosh> (set! (foo-a y) 100)
#<undef>
gosh> (foo-a y)
100
gosh> (foo-a x)
1

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

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

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

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

リスト 2 : クラスの定義

(define-class <baz> (<foo>)
  ((a :accessor baz-a :init-value 100 :init-keyword :baz-a)))
gosh> (define z (make <baz>))
z
gosh> (baz-a z)
100
gosh> (foo-a z)
100
gosh> (define z2 (make <baz> :baz-a 1000))
z2
gosh> (baz-a z2)
1000
gosh> (foo-a z2)
1000
gosh> (define z3 (make <baz> :a 100000))
z3
gosh> (baz-a z3)
100
gosh> (foo-a z3)
100

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

ただし、:accessor で指定されたメソッド foo-a, baz-a はどちらも利用することができます。この場合、同じスロット a をアクセスすることになります。:init-value はサブクラスの値が優先されます。したがって、(make <bar>) とすると、スロット a の初期値は 100 になります。実際に、メソッド foo-a, bar-a で値を求めると、100 に初期化されていることがわかります。:init-keyword はサブクラスのキーワードが優先されます。<foo> で指定したキーワード :a で、スロット a の初期値を与えることはできません。

●データ型の継承

Gauche の場合、クラスはインスタンスのデータ型を表す識別子として使うことができます。継承はスロットやメソッドだけに作用するのではなく、データ型も継承されます。次の例を見てください。

gosh> (define-class <foo> () ())
<foo>
gosh> (define-class <bar> (<foo>) ())
<bar>
gosh> (define a (make <foo>))
a
gosh> (define b (make <bar>))
b
gosh> (is-a? a <foo>)
#t
gosh> (is-a? b <bar>)
#t
gosh> (is-a? b <foo>)
#t
gosh> (is-a? a <bar>)
#f
is-a? object class

関数 is-a? は object が class のインスタンス、または class のサブクラスのインスタンスであれば真を返し、そうでなければ偽を返します。

クラス <bar> はクラス <foo> を継承しています。<foo> のインスタンス a は is-a? でチェックすると、当然ですが <foo> では #t になり、<bar> では #f になります。ところが、クラス <bar> のインスタンス b は、<bar> で #t になるのは当然ですが、<foo> のサブクラスなのでデータ型が継承されて <foo> でも #t になります。

ここで、<bar> を継承したクラス <baz> の作って、そのインスタンスを is-a? でチェックすると、<foo>, <bar>, <baz> のどれでも #t になります。

gosh> (define-class <baz> (<bar>) ())
<baz>
gosh> (define c (make <baz>))
c
gosh> (is-a? c <baz>)
#t
gosh> (is-a? c <bar>)
#t
gosh> (is-a? c <foo>)
#t

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

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

●メソッドの選択

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

gosh> (define-method add ((x <foo>)) (+ (foo-a x) (foo-b x)))
#<generic add (1)>
gosh> (define a1 (make <foo> :a 10 :b 20))
a1
gosh> (define b1 (make <bar> :a 100 :b 200))
b1
gosh> (add a1)
30
gosh> (add b1)
300

いま、メソッド add を定義しました。add はスロット a と b の値を足し算します。このメソッドは引数のクラス指定(引数特定子)に <foo> を指定しているので、<foo> のインスタンスだけではなく、<foo> のサブクラス <bar> のインスタンスにも適用することができます。クラス <bar> のインスタンスを生成して変数 b1 にセットします。このインスタンスに add を適用すると、スロット a と b を足した値を求めることができます。

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

gosh> (define-method sub ((x <bar>)) (- (foo-a x) (foo-b x)))
#<generic sub (1)>
gosh> (sub b1)
-100
gosh> (sub a1)
*** ERROR: no applicable method for #<generic sub (1)> with arguments (#<<foo> 0pba1da0>)

スロット a, b の差分を求めるメソッド sub を定義します。sub の引数特定子は <bar> なので、<bar> のインスタンス y に sub を適用すると a と b の差分を求めることができます。ところが、スーパークラス <foo> のインスタンス a1 に sub を適用すると、「適用できるメソッドがない」というエラーが発生します。<foo> にもスロット a, b があるのですが、このメソッドを <foo> のインスタンスに適用することはできないのです。

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

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

●複数の引数がある場合

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

gosh> (define-class <foo> () ())
<foo>
gosh> (define-class <foo1> (<foo>) ())
<foo1>
gosh> (define-class <bar> () ())
<bar>
gosh> (define-class <bar1> (<bar>) ())
<bar1>
gosh> (define-method baz ((x <foo>) (y <bar>)) (print "foo bar!"))
#<generic baz (1)>
gosh> (define-method baz ((x <foo1>) (y <bar1>)) (print "foo1 bar1!"))
#<generic baz (2)>
gosh> (define x1 (make <foo>))
x1
gosh> (define x2 (make <foo1>))
x2
gosh> (define y1 (make <bar>))
y1
gosh> (define y2 (make <bar1>))
y2
gosh> (baz x1 y1)
foo bar!
#<undef>
gosh> (baz x2 y2)
foo1 bar1!
#<undef>
gosh> (baz x1 y2)
foo bar!
#<undef>
gosh> (baz x2 y1)
foo bar!
#<undef>

総称関数 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 引数から順番に適用可能なメソッドを調べていきます。この例は単一継承なのでそれほど難しくありませんが、Gauche (CLOS) は多重継承をサポートしているので、メソッドの選択はもっと複雑になります。これは「多重継承」のところで詳しく説明しましょう。

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

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

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

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

リスト 3 : メソッドのオーバーライド

(define-method add ((x <bar>))
  (+ (foo-a x) (foo-b x) (bar-c x) (bar-d x)))
gosh> (define y (make <bar> :a 10 :b 20 :c 30 :d 40))
y
gosh> (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 を呼び出す必要はありません。いいかえれば、スーパークラスのメソッドと同じプログラムを書かなくてもよいわけです。スーパークラスのメソッドを呼び出す機能は、オブジェクト指向言語では当然の機能といえるでしょう。

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

next-method [引数 ...]

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

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

(define-method add ((x <bar>))
  (+ (next-method) (bar-c x) (bar-d x)))

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

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

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

; クラス定義
(define-class <foo> () ((a :accessor foo-a :init-value 1)))
(define-class <bar> () ((b :accessor bar-b :init-value 2)))
(define-class <foo1> (<foo>) ((c :accessor foo-c :init-value 3)))
(define-class <bar1> (<bar>) ((d :accessor bar-d :init-value 4)))

; メソッド A
(define-method baz ((x <foo>) y) (print "foo-other method"))

; メソッド B
(define-method baz ((x <foo>) (y <bar>))
  (next-method)
  (print "foo-bar method"))

; メソッド C
(define-method baz ((x <foo1>) (y <bar1>))
  (next-method)
  (print "foo1-bar1 method"))
gosh> (define x1 (make <foo>))
x1
gosh> (define x2 (make <foo1>))
x2
gosh> (define y1 (make <bar>))
y1
gosh> (define y2 (make <bar1>))
y2
gosh> (baz x1 y1)
foo-other method
foo-bar method
#<undef>
gosh> (baz x2 y2)
foo-other method
foo-bar method
foo1-bar1 method
#<undef>

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

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

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

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

gosh> (define-method baz1 ((x <foo>)) (next-method) (print "foo baz1"))
#<generic baz1 (1)>
gosh> (baz1 x1)
*** ERROR: no applicable method for #<generic baz1 (1)> with arguments (#<<foo> 0pba55a8>)

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


Copyright (C) 2010 Makoto Hiroi
All rights reserved.

[ PrevPage | Scheme | NextPage ]