今回は「多重継承」について説明します。実をいうと、M.Hiroi は多重継承に対してあまりいいイメージを持っていません。私見ですが、多重継承はメリットよりもプログラムを複雑にするデメリットの方が大きいのではないか、と思っています。とくに、下図のクラス A, B, C, E のような菱形の関係をC++でプログラムする場合、とても複雑な問題を引き起こすことが知られています。
A / \ / \ B C \ / \ / D 図 1 : 多重継承
CLOS の多重継承はクラスの優先順位が明確に定められているので、C++よりも扱いやすいと思います。しかしながら、CLOS の多重継承にも問題点があります。多重継承は強力な機能ですが万能ではありません。多重継承は慎重に扱うべきだと思っています。
それでは最初に多重継承の基本について説明します。
簡単な例題として、2 つのクラス FOO と BAR を継承するクラス BAZ を考えてみましょう。次のリストを見てください。
リスト : 多重継承 ;;; クラス foo の定義 (defclass foo () ((a :accessor foo-a :initform 1 :initarg :a))) ;;; メソッド (defmethod method-1 ((x foo)) (print "foo method")) ;;; クラス bar の定義 (defclass bar () ((b :accessor bar-b :initform 2 :initarg :b))) ;;; メソッド (defmethod method-1 ((x bar)) (print "bar method")) ;;; クラス baz の定義 (defclass baz (foo bar) ())
クラス FOO にはスロット A とアクセスメソッド foo-a が、クラス BAR にはスロット B とアクセスメソッド bar-b が定義されています。そして、両方のクラスともメソッド method-1 が定義されています。
クラス BAZ で FOO と BAR を継承する場合、スーパークラスのリストに FOO と BAR をセットするだけです。これで FOO と BAR を継承することができます。さっそく実行してみましょう。
* (setq x (make-instance 'baz)) #<BAZ {1001CACB73}> * (foo-a x) 1 * (typep x 'baz) T * (typep x 'foo) T * (typep x 'bar) T * (method-1 x) "foo method" "foo method"
クラス BAZ にはスーパークラスから継承したスロット A, B と、アクセスメソッド foo-a, bar-b があります。BAZ のインスタンス X に foo-a を適用するとスロット A にアクセスし、bar-b を適用するとスロット B にアクセスすることができます。
それから、多重継承の場合でもデータ型は継承されます。クラス BAZ のインスタンスはデータ型が BAZ になりますが、クラス FOO と BAR を継承しているので、typep は FOO でも BAR でも真 (T) を返します。
それでは、両方のクラスに定義されている method-1 はどちらが評価されるのでしょう。"foo method" と表示されたので、クラス FOO のメソッドが評価されたことがわかります。このように、メソッドの探索はスーパークラスを格納するリストの先頭から順番 (左から右) に行われ、最初に見つかったメソッドが適用されます。これを「左優先則」といいます。したがって、スーパークラスの順番を逆にすると "bar method" と表示されます。
(defclass baz (bar foo) ())
* (setq z (make-instance 'baz)) #<BAZ ...> > (method-1 z) "bar method" "bar method"
では、FOO と BAR にスーパークラスが設定されている場合はどうなるのでしょうか。この場合、メソッドは「深さ優先」で探索されます。次の図を見てください。
A B C │ │ │ │ │ │ D E F \ │ / \│/ G G→D→A→E→B→F→C 図 2 : 多重継承のメソッドの探索 (1)
クラス G は、クラス D, E, F を多重継承しています。D, E, F のスーパークラスはそれぞれ A, B, C です。クラス G でスーパークラスのリストが (D E F) であれば、最初にクラス D のメソッドを探索します。次は深さ優先で探索するので、クラス E ではなくクラス A を探索します。
このように、スーパークラスを優先して探索し、それでも見つからないときはクラス E を探索します。したがって、探索順序は「G → D → A → E → B → F → C」となるのです。上図を経路と考えれば、まさに深さ優先探索そのものですね。これを「深さ優先則」といいます。
では、次の場合はどうなるのでしょうか。
A / \ / \ B C D→B→C→A \ / \ / D 図 3 : 多重継承のメソッドの探索 (2)
あるクラスからスーパークラスをたどり、複数の経路で到達できるクラスを「合流点」といいます。上図の場合、クラス A は D - B - A と D - C - A という 2 つの経路があるので合流点になります。メソッドの探索で合流点にぶつかると、そこで探索を中断して次の経路を探索します。
そして、最後の経路で合流点に到達したら、それ以降のスーパークラスを探索します。したがって、上図の探索順序は「D → B → C → A」となります。これを「合流則」といいます。
それでは実際に試してみましょう。
;;; クラスの定義 (defclass foo-a () ()) (defclass foo-b (foo-a) ()) (defclass foo-c (foo-a) ()) (defclass foo-d (foo-b foo-c) ()) ;;; メソッドの定義 (defmethod method-2 ((x foo-a)) (print "foo-a method")) (defmethod method-2 ((x foo-c)) (print "foo-c method"))
* (setq z (make-instance 'foo-d)) #<FOO-D {1001E16733}> * (method-2 z) "foo-c method" "foo-c method"
4 つのクラス FOO-A, FOO-B, FOO-C, FOO-D とメソッド method-2 を定義します。method-2 はクラス FOO-A と FOO-C に定義します。クラス FOO-A が合流点であることに注意してください。
クラス FOO-D のインスタンスを生成してメソッド method-2 を呼び出すと、"foo-c method" と表示されますね。FOO-A のメソッドではなく、FOO-C のメソッドが適用されたことがわかります。
このように CLOS は「適用可能なメソッド」を探索するのですが、実際にはもっと複雑な処理を行っています。CLOS の場合、適用可能なメソッドとは「クラス優先順位リスト」と呼ばれるものの中で、一番最初にその引数特定子があらわれるものになります。
たとえば、クラス FOO-A, FOO-B, FOO-C, FOO-D の優先順位リストは (foo-d foo-b foo-c foo-a) になります。これは、次のようにメソッド method-3 で call-next-method を適用することで確認できます。
;;; メソッドの定義 (defmethod method-3 ((x foo-a)) (print "foo-a method")) (defmethod method-3 ((x foo-b)) (print "foo-b method") (call-next-method)) (defmethod method-3 ((x foo-c)) (print "foo-c method") (call-next-method)) (defmethod method-3 ((x foo-d)) (print "foo-d method") (call-next-method))
* (setq z (make-instance 'foo-d)) #<FOO-D {100219B3B3}> * (method-3 z) "foo-d method" "foo-b method" "foo-c method" "foo-a method" "foo-a method"
このように、優先順位はリストの先頭の FOO-D がいちばん高く、最後の FOO-A がいちばん低くなります。ここで、メソッド method-2 の引数特定子は FOO-C と FOO-A がありますが、FOO-C の優先順位が高いので引数特定子 FOO-C のメソッドが適用されます。
CLOS の場合、このクラス優先順位を決めるアルゴリズムがとても複雑なのですが、たいていの場合は今まで説明した次の 3 つの規則を適用した結果と同じになります。
複雑な継承関係でなければ、これらの規則で十分理解できると思います。クラス優先順位リストを決定するアルゴリズムは参考文献 1 『LISP 原書第 3 版 (1) (2)』の付録で詳しく説明されています。興味のある方はお読みくださいませ。
拙作のページ「継承」で説明したように、CLOS は defclass でスロットを定義するときに、スーパークラスと同じスロット名があってもかまいません。ただし、インスタンス内では、同じスロット名でアクセスできるスロットはひとつしか存在しません。これは多重継承でも同じです。次の例を見てください。
;;; クラスの定義 (defclass foo () ((a :accessor foo-a :initform 1 :initarg :a))) (defclass bar () ((a :accessor bar-a :initform 2 :initarg :b))) (defclass baz (foo bar) ())
* (setq z (make-instance 'baz)) #<BAZ {1001C2E103}> * (foo-a z) 1 * (bar-a z) 1 * (setq z1 (make-instance 'baz :b 100)) #<BAZ {1001C31FE3}> * (foo-a z1) 100 * (bar-a z1) 100
クラス FOO はスロット A を定義しています。クラス BAR にも同じ名前のスロット A があります。そして、クラス BAZ は FOO と BAR を継承しています。この場合、BAZ のインスタンスを生成すると、A に対応するスロットはひとつしかありません。このとき、スロットオプションも継承されることに注意してください。
:accessor で指定されたメソッド foo-a, bar-a はどちらも利用することができます。この場合、同じスロット A をアクセスすることになります。:initform は「クラス優先順位リスト」と同じ規則で決定されます。
この場合、左優先則でクラス FOO の値が優先されます。したがって、(make-instance 'baz) とすると、スロット A の初期値は 1 になります。実際に、メソッド foo-a, bar-a で値を求めると、1 に初期化されていることがわかります。
:initarg はどちらのキーワードでも利用可能です。この場合も同じスロットに初期値を与えることになります。FOO で指定したキーワード :a でも BAR で指定した :b でも、スロット A の初期値を与えることができます。
ところで、多重継承を使う場合、異なる性質や機能を持つクラスを継承することがあります。たとえば、クラス FOO にはメソッド method-a があり、クラス BAR にはメソッド method-b があるとしましょう。この 2 つのメソッドはまったく異なる働きをします。ここで、method-a はスロット X を使っていて、method-b もスロット X を使っていると、多重継承で問題が発生します。
クラス FOO と BAR を多重継承してクラス BAZ を作成した場合、クラス BAZ のインスタンスにはスロット X がひとつしかありません。メソッド method-a と method-b はひとつしかないスロット X を使うことになります。この場合、どちらかのメソッドは正常に動作しないでしょう。これでは多重継承する意味がありませんね。これが CLOS における多重継承の問題点です。
このように、多重継承はどんなクラスでもできるというわけではありません。同名のスロットを持つクラスは多重継承できないと考えた方がよいでしょう。それから、多重継承にはもうひとつ問題点があります。それはクラスの階層構造が複雑になることです。
単一継承の場合、クラスの階層は木構造になりますが、多重継承ではグラフになります。木構造の場合、クラスの優先順位は簡単にわかりますが、グラフになると優先順位を理解するのは難しくなります。多重継承は強力な機能ですが、使うときには十分な注意が必要なのです。
これらの問題を回避するため、スロット (属性) を継承するスーパークラスはひとつだけに限定して、あとのスーパークラスはメソッド (実装) だけを継承するという方法があります。この方法を Mix-in といいます。
具体的には、スロットを定義せずにメソッドだけを記述したクラスを用意します。属性の継承は単一継承になりますが、実装のみを記述したクラスはいくつ継承してかまいません。ひとつのクラスに複数の実装を混ぜることから Mix-in と呼ばれています。
なお、Mix-in は特別な機能ではなく、多重継承を使いこなすための方法論にすぎません。多重継承を扱うことができるプログラミング言語であれば Mix-in を行うことが可能です。なお、もともと Mix-in は Flavors という Lisp にあるオブジェクト指向機能です。CLOS は Flavors の影響を強く受けています。ちなみに、Mix-in を言語仕様に取り込んだのが Ruby です。
CLOS は多重継承をサポートしているので、Mix-in を利用することができます。図 4 を見てください。
A / B Mixin A / \ Mixin B \ / \ / C D 図 4 : Mix-in
クラス C はクラス B を継承していて、そこにクラス Mixin A が Mix-in されています。クラス D もクラス B を継承していますが、Mix-in されているクラスは Mixin B となります。
多重継承の問題点は Mix-in ですべて解決できるわけではありませんが、クラスの階層構造がすっきりとしてわかりやすくなることは間違いありません。Mix-in は多重継承を使いこなす優れた方法だと思います。
それでは Mix-in の例題として、クラス ENUMERABLE を作ってみましょう。ENUMERABLE は DLIST のような複数のデータを格納するクラス (コレクションクラス) に高階関数 (メソッド) を Mix-in します。これは Ruby のモジュール (Mix-in 用のクラスのこと) Enumerable を参考にしました。追加するメソッドを下表に示します。
名前 | 機能 |
---|---|
enum-find obj func | func が真となる要素を返す |
enum-position obj func | func が真となる要素の位置を返す |
enum-count obj func | func が真となる要素の個数を返す |
enum-map obj func | 要素に func を適用した結果をリストに格納して返す |
enum-filter obj func | func が真となる要素をリストに格納して返す |
enum-fold obj func init | obj の要素を func で畳み込む |
なお、これらのメソッドは ENUMERABLE を Mix-in するクラスのメソッド enum-each obj func を呼び出して動作します。enum-each は obj の要素に関数 func を適用します。なお、enum-each を使わずにイテレータを使う方法もあります。これはあとで実際に試してみましょう。
プログラムは次のようになります。
リスト : Mix-in 用のクラス enumerable ;;; enumerable 用 (defmethod enum-each ((d dlist) func) (dlist-for-each d func)) ;;; クラス定義 (defclass enumerable () ()) ;;; 述語 pred が真となる要素を返す (defmethod enum-find ((e enumerable) pred) (enum-each e (lambda (x) (when (funcall pred x) (return-from enum-find x)))) nil) ;;; 述語 pred が真となる要素の位置を返す (defmethod enum-position ((e enumerable) pred) (let ((n 0)) (enum-each e (lambda (x) (when (funcall pred x) (return-from enum-position n)) (incf n))) nil)) ;;; 述語 pred が真となる要素の個数を求める (defmethod enum-count ((e enumerable) pred) (let ((c 0)) (enum-each e (lambda (x) (when (funcall pred x) (incf c)))) c)) ;;; マッピング (defmethod enum-map ((e enumerable) func) (let (a) (enum-each e (lambda (x) (push (funcall func x) a))) (nreverse a))) ;;; フィルター (defmethod enum-filter ((e enumerable) pred) (let (a) (enum-each e (lambda (x) (when (funcall pred x) (push x a)))) (nreverse a))) ;;; 畳み込み (defmethod enum-fold ((e enumerable) func a) (enum-each e (lambda (x) (setq a (funcall func a x)))) a)
クラス ENUMERABLE は Mix-in を前提としているので、スロットの定義は不要でメソッドだけを定義します。要素のアクセスは enum-each で行います。enum-each は Mix-in するクラスで定義されているものとします。
つまり、enum-each を定義さえすれば、どんなクラスでも ENUMBERABLE を Mix-in することができるわけです。DLIST の enum-each は dlist-for-each を呼び出すだけです。
Common Lisp の場合、defmethod は defun と同様に、関数の本体を暗黙のうちに block method-name で囲みます。したがって、return-from method-name value で、メソッドの返り値として value を返すことができます。これは enum-each が呼び出すラムダ式内であってもかまいません。retrun-from のタグ名の探索はレキシカルスコープで行われるので、そのラムダ式がメソッド内で定義されていれば正常に動作します。
それでは、DLIST と ENUMERABLE を継承したクラス enum-dlist を作って、実際に試してみましょう。
* (defclass enum-dlist (dlist enumerable) ()) #<STANDARD-CLASS COMMON-LISP-USER::ENUM-DLIST> * (setq a (make-instance 'enum-dlist)) #<dlist: NIL> * (dotimes (x 5) (dlist-insert a 0 x :from-end t)) NIL * a #<dlist: (0 1 2 3 4)> * (enum-find a #'evenp) 0 * (enum-find a #'oddp) 1 * (enum-position a #'(lambda (x) (< 5 x))) NIL * (enum-position a #'(lambda (x) (< 2 x))) 3 * (enum-count a #'evenp) 3 * (enum-count a #'oddp) 2 * (enum-map a (lambda (x) (* x x))) (0 1 4 9 16) * (enum-filter a #'evenp) (0 2 4) * (enum-filter a #'oddp) (1 3) * (enum-fold a #'+ 0) 10
正常に動作していますね。複数のクラスで共通の操作 (メソッド) を定義したい場合、Mix-in はとても役に立ちます。
ところで、DLIST が ENUMERABLE を継承すれば、DLIST のインスタンスに ENUMERABLE のメソッドを適用することができます。この場合、DLIST を継承するクラス、たとえば FIXED-DLIST は ENUMERABLE を Mix-in しなくても ENUMERABLE のメソッドを利用することができます。DLIST が ENUMERABLE を継承しない場合、FIXED-DLIST で ENUMERABLE を Mix-in すれば、FIXED-DLIST で ENUMERABLE のメソッドを利用することができます。
ENUMERABLE はメソッド enum-each を呼び出すことで動作しますが、enum-each のかわりにイテレータを使う方法もあります。Mix-in するクラスにイテレータを生成するメソッド enum-iterator が定義されているとすると、ENUMERABLE は次のようになります。
リスト : Mix-in 用クラス (イテレータバージョン) ;;; enumerable1 用 (defmethod enum-iterator ((d dlist)) (dlist-iterator d)) ;;; クラス定義 (defclass enumerable1 () ()) ;;; 述語 pred が真となる要素を返す (defmethod enum-find ((e enumerable1) pred) (loop with iter = (enum-iterator e) for (x p) = (multiple-value-list (funcall iter)) while p do (when (funcall pred x) (return x)))) ;;; 述語 pred が真となる要素の位置を返す (defmethod enum-position ((e enumerable1) pred) (loop with iter = (enum-iterator e) for n upfrom 0 for (x p) = (multiple-value-list (funcall iter)) while p do (when (funcall pred x) (return n)))) ;;; 述語 pred が真となる要素の個数を求める (defmethod enum-count ((e enumerable1) pred) (loop with iter = (enum-iterator e) with c = 0 for (x p) = (multiple-value-list (funcall iter)) while p do (when (funcall pred x) (incf c)) finally (return c))) ;;; マッピング (defmethod enum-map ((e enumerable1) func) (loop with iter = (enum-iterator e) with a = nil for (x p) = (multiple-value-list (funcall iter)) while p do (push (funcall func x) a) finally (return (nreverse a)))) ;;; フィルター (defmethod enum-filter ((e enumerable1) pred) (loop with iter = (enum-iterator e) with a = nil for (x p) = (multiple-value-list (funcall iter)) while p do (when (funcall pred x) (push x a)) finally (return (nreverse a)))) ;;; 畳み込み (defmethod enum-fold ((e enumerable1) func a) (loop with iter = (enum-iterator e) for (x p) = (multiple-value-list (funcall iter)) while p do (setq a (funcall func a x)) finally (return a))) ;;; 巡回 (defmethod enum-each ((e enumerable1) func) (loop with iter = (enum-iterator e) for (x p) = (multiple-value-list (funcall iter)) while p do (funcall func x)))
enum-iterator が生成するイテレータは多値 (要素と真偽値) を返します。これを変数 X と P にセットするため、ループマクロの「分配」という機能を使っています。ループマクロについては拙作のページ「ループ機能」をお読みくださいませ。あとはとくに難しいところはないと思います。
簡単な実行例を示します。
* (defclass enum-dlist1 (dlist enumerable1) ()) #<STANDARD-CLASS COMMON-LISP-USER::ENUM-DLIST1> * (setq a (make-instance 'enum-dlist1)) #<dlist: NIL> * (dotimes (x 8) (dlist-insert a 0 x :from-end t)) NIL * a #<dlist: (0 1 2 3 4 5 6 7)> * (enum-find a #'evenp) 0 * (enum-find a #'oddp) 1 * (enum-position a #'evenp) 0 * (enum-position a #'oddp) 1 * (enum-count a #'oddp) 4 * (enum-map a (lambda (x) (* x x))) (0 1 4 9 16 25 36 49) * (enum-filter a #'evenp) (0 2 4 6) * (enum-fold a (lambda (a x) (cons x a)) nil) (7 6 5 4 3 2 1 0) * (enum-each a #'print) 0 1 2 3 4 5 6 7 NIL
正常に動作していますね。双方向リストの場合、enum-each と enum-iterator どちらの方法でも Mix-in を簡単に実現することができます。
;;; ;;; enumerable.lisp : Mix-In 用のクラス ;;; ;;; Copyright (C) 2020 Makoto Hiroi ;;; (require :dlist "dlist.lisp") (use-package :dlist) ;;; enumerable 用 (defmethod enum-each ((d dlist) func) (dlist-for-each d func)) ;;; クラス定義 (defclass enumerable () ()) ;;; 述語 pred が真となる要素を返す (defmethod enum-find ((e enumerable) pred) (enum-each e (lambda (x) (when (funcall pred x) (return-from enum-find x)))) nil) ;;; 述語 pred が真となる要素の位置を返す (defmethod enum-position ((e enumerable) pred) (let ((n 0)) (enum-each e (lambda (x) (when (funcall pred x) (return-from enum-position n)) (incf n))) nil)) ;;; 述語 pred が真となる要素の個数を求める (defmethod enum-count ((e enumerable) pred) (let ((c 0)) (enum-each e (lambda (x) (when (funcall pred x) (incf c)))) c)) ;;; マッピング (defmethod enum-map ((e enumerable) func) (let (a) (enum-each e (lambda (x) (push (funcall func x) a))) (nreverse a))) ;;; フィルター (defmethod enum-filter ((e enumerable) pred) (let (a) (enum-each e (lambda (x) (when (funcall pred x) (push x a)))) (nreverse a))) ;;; 畳み込み (defmethod enum-fold ((e enumerable) func a) (enum-each e (lambda (x) (setq a (funcall func a x)))) a) ;;; ;;; イテレータの利用 ;;; (defmethod enum-iterator ((d dlist)) (dlist-iterator d)) ;;; クラス定義 (defclass enumerable1 () ()) ;;; 述語 pred が真となる要素を返す (defmethod enum-find ((e enumerable1) pred) (loop with iter = (enum-iterator e) for (x p) = (multiple-value-list (funcall iter)) while p do (when (funcall pred x) (return x)))) ;;; 述語 pred が真となる要素の位置を返す (defmethod enum-position ((e enumerable1) pred) (loop with iter = (enum-iterator e) for n upfrom 0 for (x p) = (multiple-value-list (funcall iter)) while p do (when (funcall pred x) (return n)))) ;;; 述語 pred が真となる要素の個数を求める (defmethod enum-count ((e enumerable1) pred) (loop with iter = (enum-iterator e) with c = 0 for (x p) = (multiple-value-list (funcall iter)) while p do (when (funcall pred x) (incf c)) finally (return c))) ;;; マッピング (defmethod enum-map ((e enumerable1) func) (loop with iter = (enum-iterator e) with a = nil for (x p) = (multiple-value-list (funcall iter)) while p do (push (funcall func x) a) finally (return (nreverse a)))) ;;; フィルター (defmethod enum-filter ((e enumerable1) pred) (loop with iter = (enum-iterator e) with a = nil for (x p) = (multiple-value-list (funcall iter)) while p do (when (funcall pred x) (push x a)) finally (return (nreverse a)))) ;;; 畳み込み (defmethod enum-fold ((e enumerable1) func a) (loop with iter = (enum-iterator e) for (x p) = (multiple-value-list (funcall iter)) while p do (setq a (funcall func a x)) finally (return a))) ;;; 巡回 (defmethod enum-each ((e enumerable1) func) (loop with iter = (enum-iterator e) for (x p) = (multiple-value-list (funcall iter)) while p do (funcall func x)))