多重継承を嫌う人は多いですよね。「複雑だからダメだ」ってことらしい。でも、「複雑=ダメ」はちょっと乱暴。必然性/必要性がある複雑さなら、それは受け入れざるをえないのですから。それに、どの程度の複雑さなのか、その複雑さはどこから来るのかを知らないと「ダメ」かどうかの判断はできないと思います。
という次第で、多重継承の複雑さを調べてみます。ダメかどうかの判断は僕はしません。圏論の道具を使うのだけど、事前の知識は一切不要です(最後の節を除いて)。最後にまとめて圏論的な解釈をしますが、ここは省略可能。
内容:
クラスとその例
多重継承の話をするので、もちろんクラス概念は仮定します。でも、複雑さの話を複雑にしないために、次の単純化(手抜きとも言う)をします。
- 変数(インスタンス変数、フィールド、プロパティ、スロット)とメソッドは全部パブリック、プライベートな変数やメソッドはない。
- インターフェースも扱うが、インターフェースの構文は導入しない。
- コンストラクタは書かない(適当に想像で補ってください)。
JavaScriptで作った擬似的なクラスみたいなものを想定しています。でも、クラス定義構文は今のJavaScriptよりはマシな形式を使いましょう*1。
class Point { var x:double; var y:double; function moveTo(toX:double, toY:double):void { this.x = toX; this.y = toY; } }
なにかのクラスCがあるとき、Cの変数の集合をVar(C)、メソッドの集合をMethod(C)とします。今の例なら:
- Var(Point) = {x, y}
- Method(Point) = {moveTo}
なにしろ手抜きなので、変数の型とかメソッドの引数個数とかの情報は無視します。名前だけしか考えない。
クラスCのオブジェクトが取り得る状態の全体をState(C)と書きましょう。Pointなら、変数xと変数yの値で状態が決まります。意図としては、x, yは座標なので、状態の全体は平面全体とみなせます。より具体的に言えば、(0, 0)とか(-1.2, 3982.1955)とか、double(浮動小数点数)をペアにしたタプルで状態が表現できます。結局、State(Point)は浮動小数点数をペアにしたタプルの全体です。
多重継承は集約と単純継承の組み合わせ
多重継承とは、複数のクラスを寄せ集めたクラスを作り、出来上がった集約クラス*2を単純継承することです。集約を「+」で表すなら、class C extends A, B は、class C extends (A + B) と同じってことです。逆に、集約 A + B とは、class C extends A, B { /*空っぽ */}
として定義されるクラスCだと思ってもいいです。
例を考えましょう。
class Elevator { var floor;int; function up():void { this.floor++; } function down():void { this.floor--; } }
既存のクラスPointとElevatorを集約すると、各フロアー(平面)を自由に動き回り、フロア間(z方向)の移動はエレベータを使うオブジェクトを定義できます。こいつに、Particleとか、どうでもいい名前を付けましょう(いい名前が思いつかなかった)。つまり、
- Particle := Point + Elevator
記号「:=」は、右辺に対して名前を付けるという意味です。別な書き方をすると:
class Particle extends Point, Elevator { /* 空っぽ */ }
次の関係が成立します。
- Var(Particle) = Var(Point + Elevator) = Var(Point) + Var(Elevator)
- Method(Particle) = Method(Point + Elevator) = Method(Point) + Method(Elevator)
ここの右辺で使っている記号「+」は集合の直和ですが、とりあえず合併「∪」とみなしてもOKです。
状態に関しては:
- State(Particle) = State(Point + Elevator) = State(Point)×State(Elevator)
となります。記号「×」は集合の直積です。変数x, yのタプル(x, y)と変数floorを組にした((x, y), floor)、あるいは入れ子をフラットにした (x, y, floor)が状態を表現するので、状態の全体は、(浮動小数点数の全体)×(浮動小数点数の全体)×(整数の全体) となります。
まとめておけば; クラスAとBの単純集約に関して
- インターフェース(変数とメソッドの名前達)は直和となる。
- 状態(変数のとりえる値の全体)は直積となる。
嫌われる理由 1:名前のバッティング
ここまでだと、多重継承の意味論はけっこう美しく単純なんですよね。もちろん、これで話は済みません。Elevatorの定義が次のようだったらどうでしょう。
class Elevator { var floor;int; function moveTo(toFloor:int):void { this.floor = toFloor; } }
同じメソッド名moveToがPointにもElevatorにも出てきてます。引数の個数や型が違えば同じ名前を許す方式(オーバーロード)なら名前のバッティングを回避できますが、引数の個数/型も同じメソッドが出現すれば、結局は同じ事態に陥ります。
このままでは、Point + Elevator は作れません。どうしたものか?
ひとつの方法は、moveToを呼ぶ*3ときに、Point:moveTo と Elevator:moveTo のように接頭辞を付けて区別するのです。でも、これ相当にめんどくさい。より便利な方法は、リネームです。例えば、こんな感じ。
class Particle extends Point, Elevator { rename Elevator:moveTo as updownTo; }
これで、Elevator:moveToはupdownToという名前で呼ぶことができます。
リネームは現実的な解決策ですが、リスコフの置換原則は破られてしまいます。つまり、ParticleのインスタンスpをElevatorだと思い込んで p.movetTo(5) とアクセスしたプログラマはエラーに出会います。「サブクラスParticleのインスタンスpをスーパークラスElevatorのインスタンスだと思い込んでよい」のがリスコフの置換原則なので、破綻してます*4。リスコフの置換原則を守れるのは、複数の継承元クラスに同じ名前がないときに限ります。
嫌われる理由 2:ダイアモンド継承
名前のバッティングが困るなら、「クラスAとクラスBに同名の変数またはメソッドがあったら集約は失敗する」としてはどうでしょう。かっこよくありませんが、これでもいいと割り切ればハッピーになれる、、、かな?
class ColoredObject { var color:int; // 色を整数で表現 }
さて、次の状況を考えましょう。
- ColoredPoint := ColoredObject + Point
- ColoredElevator := ColoredObject + Elevator
そして、ColoredPointとColoredElevatorの集約としてColoredParticleを定義します。
class ColoredParticle extends ColoredPoint, ColoredElevator { /* 空っぽ */ }
この場合、Var(ColoredPoint) = {color, x, y}、Var(ColoredElevator) = {color, floor} なので、変数colorが名前のバッティングを起こしています。じゃ、集約(多重継承の一種)は失敗でしょうか? このケースでは、変数名colorは共通のスーパークラスColoredObjectから来ているので、バッティング(たまたまかち合った)ではありません。同じ名前が、2つの継承経路からやって来ただけです。図示すると:
ColoredObject / \ / \ / \ ColoredPoint ColoredElevator \ / \ / \ / ColoredParticle
ダイヤモンド形(菱形)になります。このダイヤモンド形の継承の取り扱いが面倒なんです。ダイヤモンドができないように気を付けよう、って? そうはいきませんよ。今回のこの個別ケースでは回避できても、一般的な回避策はないもの。
いっそ、ダイヤモンド継承もエラーにしてはどうでしょうか? そうなると、継承グラフがどうなるか想像してみてください。サブクラスからスーパークラス(下から上)に向かってどんどん広がる図になりますよ。一人っ子政策で、孫は一人だけでジイチャンバーチャンは4人、ヒイジイチャンヒイバーチャンは8人みたいな。アダムとイブ(ルートクラス)の人口が異常に多いという、…… そんなクラス階層ヤダー!
ダイアモンド継承の対処
偶然による変数名/メソッド名のバッティングはエラーとするにしても、ダイヤモンド継承による名前の二重出現は適切に削除する必要があります。具体的には:
- Var(ColoredParticle) = Var(ColoredPoint)∪Var(ColoredElevator) = {color, x, y, floor}
のようにします。
エラーにするか、名前の合併集合を作るかは、継承グラフをたどって判断するしかありません。継承グラフは単純なダイヤモンド型よりずっと複雑になる可能性があります。2つのクラスの共通スーパークラスを探して調べる手間がかかります。
ダイヤモンド継承で作られたColoredParticleの状態の集合について考えてみましょう。タプル(color, x, y)とタプル(color, floor)を単純に組み合わせると、((color, x, y), (color, floor))、入れ子をフラットにすれば (color, x, y, color, floor)、しかし2つのcolorは同じですからまとめてしまえば (color, x, y, floor) という4成分タプルで状態を表現できます。
と、こう書くと簡単ですが、オブジェクトの効率的メモリレイアウトを考えてみると大変です。もっとも、JavaScriptのように、objectの変数colorはobject["color"]だとみなすなら、変数の名前さえ決まればメモリレイアウトを工夫する必要はありません。
理論的な話をするときは、(color1, x, y, color2, floor)という5成分のタプルを考えて、color1 == color2 という条件を付けます。つまり、
- State(ColoredParticle) = (State(ColoredPoint)×State(ColoredElevator) 条件:color1 == color2)
のようにしてState(ColoredParticle)を定義します。
とりあえずのまとめ
多重継承ってやっぱり面倒だな、という気はします。どうやってもリスコフの置換原則は守りきれないので、継承階層と型階層がずれることは避けられません。継承階層がツリーではなくてグラフになるので、クラス間の関係を把握する負担も増えます。処理系の実装も大変です。現実世界では意味を持たないような変なクラスも容易にでっちあげられます。今回の例 Point + Elevator だってけっこう変だしね。
でも、だからといって多重継承がわけのわからない愚劣なものかというと、そうではありません。人間にとっての知的負担が増えるとか実装が大変なのはその通りですが、多重継承の構造も首尾一貫した体系を持っているのです。次節でそれに触れておきましょう。
圏論からのアプローチと整理
今回の例では、クラスCは、変数とメソッド(それぞれの名前と型)で定義されます。Var(C)とMethod(C)に型情報も付けたものを指標(シグニチャ)と呼び、Sig(C)と書くことにします。Sig(A)からSig(B)への射はリネームのことだとします。リネームとは、Sig(A)に含まれる各名前を単射でSig(B)の名前に対応付けることです。ただし、リネームしても型(ソート)は変わらないとします。
{floor:int --> floor:int, moveTo(int):void --> updownTo(int):void}はリネームの例ですね。Sig(A)⊆Sig(B)のときの自然な包含写像もリネームの一種です。変数もメソッドも持たないクラスをEとすると、Sig(E) = {} であり、クラスCが何でも Sig(E)⊆Sig(C)。つまり、クラスEの指標は、指標の圏の始対象です。
指標の圏は始対象を持ち、指標集合の直和が圏論的直和にもなっていて、余デカルト圏(余積に関するモノイド圏)です。ダイヤモンド継承を指標の圏で考えると、融合和(amalgamated sum、押し出し、余ファイバー和)になっています。任意のスパン (B←A→C)に融合和が作れることと余デカルトであることは同じことですを有限余完備だ、と言うこともあります。いずれにしても、ダイヤモンド継承ができることが、任意の有限図式の余極限の存在を保証するので、どんなに複雑な継承グラフからでも多重継承ができて、up-to-isoで一意的にサブクラス指標が定義できます。
まとめると:
- 継承グラフ ⇔ 指標の圏における図式D
- 多重継承による定義 ⇔ 図式Dの余極限 Colim D
クラスのインスタンス変数(名前と型)の集合から状態空間が決定します。メソッドも考慮に入れると、状態空間を台集合とする余代数ができます*5。でも、余代数まで扱うのはやめて、状態空間(台集合)だけを考えることにします。状態空間のあいだの射は射影写像です。
クラスを状態空間の圏で考えると、指標の圏のときと双対になります。つまり、空なクラスEの状態空間はモデル側の終対象です。指標の直和は、状態空間の直積ですね。ダイヤモンド継承をモデル側で考えると、状態空間のファイバー積(fibre product, fibred product、引き戻し)となります。結局:
- 継承グラフ ⇔ 状態空間の圏における図式D
- 多重継承による定義 ⇔ 図式Dの極限 Lim D
この状況では、指標の圏は有限余完備、モデルの圏は有限完備で、有限余極限を有限極限にうつす反変関手で対応が付いているので、なかなかよくまとまっています。でも、既に指摘したように、人間や処理系に扱いやすいかどうかは別問題。