檜山正幸のキマイラ飼育記 このページをアンテナに追加 RSSフィード Twitter

キマイラ・サイトは http://www.chimaira.org/です。
トラックバック/コメントは日付を気にせずにどうぞ。
連絡は hiyama{at}chimaira{dot}org へ。
蒸し返し歓迎!
このブログの更新は、Twitterアカウント @m_hiyama で通知されます。
Follow @m_hiyama
ところで、アーカイブってけっこう便利ですよ。

2008-03-04 (火)

なんで多重継承はそんなに嫌われるのか? ちょっくら分析してみるか

| 16:29 | なんで多重継承はそんなに嫌われるのか? ちょっくら分析してみるかを含むブックマーク

多重継承を嫌う人は多いですよね。「複雑だからダメだ」ってことらしい。でも、「複雑=ダメ」はちょっと乱暴。必然性/必要性がある複雑さなら、それは受け入れざるをえないのですから。それに、どの程度の複雑さなのか、その複雑さはどこから来るのかを知らないと「ダメ」かどうかの判断はできないと思います。

という次第で、多重継承の複雑さを調べてみます。ダメかどうかの判断は僕はしません圏論の道具を使うのだけど、事前の知識は一切不要です(最後の節を除いて)。最後にまとめて圏論的な解釈をしますが、ここは省略可能。

内容:

  1. クラスとその例
  2. 多重継承は集約と単純継承の組み合わせ
  3. 嫌われる理由 1:名前のバッティング
  4. 嫌われる理由 2:ダイアモンド継承
  5. ダイアモンド継承の対処
  6. とりあえずのまとめ
  7. 圏論からのアプローチと整理

●クラスとその例

多重継承の話をするので、もちろんクラス概念は仮定します。でも、複雑さの話を複雑にしないために、次の単純化(手抜きとも言う)をします。

  1. 変数(インスタンス変数、フィールド、プロパティ、スロット)とメソッドは全部パブリック、プライベートな変数やメソッドはない。
  2. インターフェースも扱うが、インターフェースの構文は導入しない。
  3. コンストラクタは書かない(適当に想像で補ってください)。

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; // 色を整数で表現
}

さて、次の状況を考えましょう。

  1. ColoredPoint := ColoredObject + Point
  2. 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

この状況では、指標の圏は有限余完備、モデルの圏は有限完備で、有限余極限を有限極限にうつす反変関手で対応が付いているので、なかなかよくまとまっています。でも、既に指摘したように、人間や処理系に扱いやすいかどうかは別問題。

*1ECMAScript 4を意識してるんだけど、構文をよく知らないから間違っているかも知れないよ。

*2:集約は他の意味でも用いられるので、並置(juxtaposition)とかのほうがいいような気もする。

*3:この「呼ぶ」は、名指しすることとメソッドコールすることの両方の意味です。

*4:接頭辞付きで p.Elevator:moveTo(5) とか、キャストして ((Elevator)p).moveTo(5) とかする必要があるでしょう。

*5:ちゃんと考えるときは、1つの余代数だけではなくて、可能な余代数全体の圏を考えるべきです。

太郎太郎 2008/03/05 00:39 Javaのインターフェイスはどうですかね。
あれは素敵だと思います。

m-hiyamam-hiyama 2008/03/05 09:13 太郎さん、
みなさんお好みの言語はあると思うのですが、今回の話とか http://d.hatena.ne.jp/m-hiyama/20080109/1199863428 の話とかでは、個別言語がどうであるかは*どうでもいいこと*だし、言語間の比較をするつもりも*まったくありません*。一般的な文脈において、言語機能のひとつを話題にしているだけです。

解説にJavaScriptもどきを使ったのは、 http://d.hatena.ne.jp/m-hiyama/20060121/1137811097 に書いてある事情です。Javaの構文を使っても別にいいのですが、Javaだとちゃんと隠蔽ができるので、モデルの構成が大変なんです(本格的なインスティチューションを作る必要がある)。手抜きするために、レコード(名前/値ペアの組)をモデルにしたかったので、プライベートは導入しないという判断です。

ついでに、(太郎さんのコメントとは無関係な)一般論として言っておけば:
個別言語の良し悪しや言語間の比較の話が悪くはないのだけど、感情論や宗教戦争は不毛ですよね。判定基準や尺度を準備し、それを合意してから議論したほうがいいと思いますよ(それならおそらく有意義)。

みずしまみずしま 2008/03/23 08:16 こんにちは。多重継承で名前がバッティングしたとき、renaming
を行うとリスコフの置換原則を破るというくだりが気になったので、リスコフの置換原則の元となったと思われる

Liskov, B. H. and Wing, J. M.: A behavioral notion of subtyping, ACM TransactIons on Programmmg Languages and Systems, Vol 16, No 6, November 1994

を読み直していたんですが、renamingも含めてsubtyping
が定義されており、renamingを行うことがbehavioral subtyping
を破壊することにつながる、ということではないようです。
Liskovの置換原則ではそれが異なるとすると、どの辺りで違いが
発生したのでしょうか。

m-hiyamam-hiyama 2008/03/24 09:23 みずしまさん、

> リスコフの置換原則を破るというくだり
「破る」って表現が誤解をまねくというか、間違ってますね。リネームを許す世界と許さない世界では、置換原則の定式化が変わってしまって、両立はできないってことです。
> renamingを行うことがbehavioral subtypingを破壊することにつながる、ということではないようです。
そのとおりです。リネームにともなう構文的translationをするか、実装側にreductという操作(ラッピングやデリゲーションのコードを書く)をすれば、リネームを含んだ置換原則を定式化できます。前述のとおり、「破壊する」のではなくて、リネームなしの状況と両立できないだけです。
ただし、translationまたはreductが必要だってってことは、ソースの追加や書き換えが必要(自動化できるでしょうが)となるので、「インターフェースを経由すれば、実装クラスが変わっても利用者側ソースは再利用できる」という御利益は失われます。実感としては、リネームにより何かが壊れた感じはします。

> Liskovの置換原則ではそれが異なるとすると、どの辺りで違いが発生したのでしょうか。
リネームがあるとないでは、事情が変わることは、http://d.hatena.ne.jp/m-hiyama-memo/20080307/1204856309 にメモしてますが、これは自分用メモで、まったく解説にはなっていません。明日以降、そのネタをエントリとして書くかもしれません。書かないかもしれません ^^;

みずしまみずしま 2008/03/26 00:53 > 「破る」って表現が誤解をまねくというか、間違ってますね。リネームを許す世界と許さない世界では、置換原則の定式化が変わってしまって、両立はできないってことです。

なるほど。了解しました。

> ただし、translationまたはreductが必要だってってことは、ソースの追加や書き換えが必要(自動化できるでしょうが)となるので、「インターフェースを経由すれば、実装クラスが変わっても利用者側ソースは再利用できる」という御利益は失われます。

ダイアモンド継承の問題を別にすれば、ご利益はそれほど失われないのでは、と思います。例えば、

class Foo1 {
function foo() :void {
print(”foo1”);
}
}

class Foo2 {
function foo() :void {
print(”foo2”);
}
}

class Foo3 extends Foo1, Foo2 {
rename Foo1.foo to foo1
}

class User {
function useFoo1(obj: Foo1) :void {
obj.foo();
}
}

というコードがあったとして、useFoo1にFoo1のインスタンス
を渡そうが、Foo3のインスタンスを渡そうが、処理系が
リネームを適切に取り扱う機構さえ持っていれば(たとえば
リネーム用のマップをオブジェクトに持たせるなど)、ソースの書き換えは必要ない
ように思います。もちろん、Foo3の型のオブジェクトをアップ
キャストせずに「Foo1のオブジェクトと思って」利用すると
問題が発生するのは承知していますが、Liskovの置換原則に
従うご利益として主に説かれているのは、あるオブジェクト
を利用するに当たって、静的な型さえ知っていれば、
そのオブジェクトがどのようなクラスであるかを知らなくても
問題ない、ということの方である気がします。

> これは自分用メモで、まったく解説にはなっていません。明日以降、そのネタをエントリとして書くかもしれません。書かないかもしれません ^^;

おお!そのネタで書いてくださることを楽しみにしております
(^^;

m-hiyamam-hiyama 2008/03/26 10:00 みずしまさん、
> 処理系がリネームを適切に取り扱う機構さえ持っていれば ...
> ソースの書き換えは必要ないように思います。
はい、「(自動化できるでしょうが)」と書いたのは、そのような機構を想定してです。で、処理系がリネームを追跡するのは別にいいんですが、人間のほうの負担はどうなのか、と思うわけです。

> Foo3の型のオブジェクトをアップキャストせずに「Foo1のオブジェクトと思って」利用すると問題が発生するのは承知しています
リネーム付き継承もサブタイピングだとするなら(そうしないと割り切るなら話は別)、Foo3 is-subtype-of Foo1 です。であるなら、型Foo3のインスタンスは無条件に型Foo1のインスタンスとみなせるとプログラマは期待します。しかし実際は、インスタンスがサブタイプ階層のどこに位置するか、そのサブタイプ階層内でリネームが起きているかを意識してキャストするのは、ちょっとしんどいのでは。

> そのネタで書いてくださることを楽しみにしております
いずれにしても(好悪・善悪は置いといて)、状況を把握するツールとしてインスティチューションが便利だと思います。となると、インスティチューションの解説になるのかな? たぶん、おそらく、そのうち。

rumrum 2015/03/27 08:41 数学もプログラミングもよく知らないので見当外れかもしれませんが、
メソッドを指定するときに「○○クラスの○○メソッド」みたいに指定するんじゃダメなんでしょうか?

m-hiyamam-hiyama 2015/03/27 11:45 rumさん、
> 「○○クラスの○○メソッド」みたいに指定する
それをしたくない、さてどうしましょうか? という話なんです。

リンク元