多重継承の正確な問題は何ですか?


121

C#またはJavaの次のバージョンに多重継承を含めるかどうかを常に尋ねる人がいます。この能力を持つ幸運なC ++の人々は、これは誰かが最終的に自分自身を吊るすためのロープを与えるようなものだと言います。

多重継承の問題は何ですか?具体的なサンプルはありますか?


54
私は、C ++があなた自身をぶら下げるのに十分なロープを与えるのに最適であることを述べておきます。
2008年

1
多重継承の代替アドレス(と、私見を解き)トレイト(見、同じ問題の多くそのためiam.unibe.ch/~scg/Research/Traits
ベヴァン

52
私はC ++が足元を撃ち抜くのに十分なロープを与えると思いました。
KeithB、2008年

6
この質問は、一般的にMIに問題があることを想定しているようですが、MIが何気なく使用されている言語をたくさん見つけました。特定の言語のMIの処理には確かに問題がありますが、MIが一般的に重大な問題を抱えていることは知りません。
David Thornley、2010

回答:


86

最も明らかな問題は、関数のオーバーライドにあります。

2つのクラスAとがありB、どちらもメソッドを定義しているとしますdoSomething。次にCAおよびの両方から継承する3番目のクラスを定義しBますが、doSomethingメソッドをオーバーライドしません。

コンパイラがこのコードをシードするとき...

C c = new C();
c.doSomething();

...メソッドのどの実装で使用する必要がありますか?これ以上の説明がないと、コンパイラーが曖昧さを解決することは不可能です。

オーバーライドの他に、多重継承のもう1つの大きな問題は、メモリ内の物理オブジェクトのレイアウトです。

C ++やJava、C#などの言語は、オブジェクトのタイプごとに固定アドレスベースのレイアウトを作成します。このようなもの:

class A:
    at offset 0 ... "abc" ... 4 byte int field
    at offset 4 ... "xyz" ... 8 byte double field
    at offset 12 ... "speak" ... 4 byte function pointer

class B:
    at offset 0 ... "foo" ... 2 byte short field
    at offset 2 ... 2 bytes of alignment padding
    at offset 4 ... "bar" ... 4 byte array pointer
    at offset 8 ... "baz" ... 4 byte function pointer

コンパイラは、マシンコード(またはバイトコード)を生成するときに、それらの数値オフセットを使用して各メソッドまたはフィールドにアクセスします。

多重継承は非常にトリッキーになります。

クラスCがとの両方Aを継承する場合B、コンパイラはデータをAB順番にレイアウトするか、順番にレイアウトするかを決定する必要がありますBA

しかし、Bオブジェクトのメソッドを呼び出しているとしましょう。本当にB?それともC、そのBインターフェイスを介して実際に多態的に呼び出されているオブジェクトですか?オブジェクトの実際のIDに応じて、物理的なレイアウトは異なり、呼び出しサイトで呼び出す関数のオフセットを知ることは不可能です。

この種のシステムを処理する方法は、固定レイアウトアプローチを廃止して、関数の呼び出しやフィールドへのアクセスを試行するに、各オブジェクトのレイアウトを照会できるようにすることです。

ですから...簡単に言えば、コンパイラの作成者が多重継承をサポートするのは大変です。したがって、Guido van Rossumのような人がpythonを設計するとき、またはAnders Hejlsbergがc#を設計するとき、多重継承をサポートすることでコンパイラーの実装が大幅に複雑になることを知っており、おそらくそのメリットはコストに見合うものではないと考えています。


62
エーム、PythonがMIをサポート
Nemanja Trifunovic

26
これらはあまり説得力のある議論ではありません-ほとんどの言語では、固定レイアウトのことはまったくトリッキーではありません。C ++では、メモリが不透明ではないため、注意が必要です。そのため、ポインタ演算の仮定が困難になる可能性があります。クラス定義が静的である言語(Java、C#、C ++など)では、複数の継承名の衝突がコンパイル時に禁止される可能性があります(C#はインターフェイスでこれを行います)。
Eamon Nerbonne

10
OPは単に問題を理解したかったので、この問題について個人的に編集することなく説明しました。私は、言語の設計者とコンパイラの実装者は、「おそらく、そのメリットはコストに見合う価値があるとは思わない」と述べました。
ベンジスミス2009

12
最も明白な問題は、関数のオーバーライドにあります。」これは、関数のオーバーライドとは何の関係もありません。これは単純なあいまいさの問題です。
curiousguy

10
PythonはMIをサポートしているため、この回答にはGuidoとPythonに関する誤った情報が含まれています。「継承をサポートする限り、多重継承の単純なバージョンもサポートする可能性があると判断しました。」— Guido van Rossumpython-history.blogspot.com/2009/02/…—さらに、あいまいさの解決はコンパイラーでかなり一般的です(変数はローカルからブロック、ローカルから関数、ローカルから囲み関数、オブジェクトメンバー、クラスメンバー、グローバルなど)、余分なスコープがどのように違いを生むかわかりません。
marcus

46

皆さんが言及する問題は、実際にはそれほど難しいものではありません。実際、例えばエッフェルはそれを完璧にこなします!(そして任意の選択などを導入することなく)

たとえば、AとBから継承し、両方にメソッドfoo()がある場合、もちろん、クラスCでAとBの両方から継承する任意の選択は必要ありません。 c.foo()が呼び出された場合、またはCのメソッドの1つを名前変更する必要がある場合に使用されます(それはbar()になる可能性があります)

また、多重継承はしばしば非常に役立つと思います。エッフェルのライブラリーを見ると、至る所で使用されていることがわかります。個人的には、Javaでのプログラミングに戻らなければならなかったときに、その機能を逃してしまいました。


26
同意する。MIが嫌いな主な理由は、JavaScriptや静的型付けと同じです。ほとんどの人は、MIの非常に悪い実装を使用したことがあるだけです。C ++によるMIの判断は、PHPによるOOPの判断や、ピントスによる自動車の判断に似ています。
イェルクWミッターク

2
@curiousguy:MIは、C ++の多くの「機能」と同じように、さらに複雑な一連の問題を導入します。曖昧でないからといって、簡単に操作したりデバッグしたりすることはできません。話題から外れてこのチェーンを削除し、とにかくそれを吹き飛ばした。
Guvante 2012年

4
@GuvanteがMIのあらゆる言語での唯一の問題は、プログラマーがチュートリアルを読んで突然言語を知っていると思っていることです。
Miles Rout 2013年

2
言語機能はコーディング時間を短縮するだけではない、と私は主張します。また、言語の表現力を高め、パフォーマンスを向上させることも目的としています。
Miles Rout 2013年

4
また、バグはMIからのみ発生します。
Miles Rout 2013年

27

ダイヤモンドの問題

2つのクラスBとCがAから継承し、クラスDがBとCの両方から継承する場合に生じるあいまいさ。BとCがオーバーライドしたメソッドがあり、Dがオーバーライドしていない場合、メソッドはDを継承します:Bのそれ、またはCのそれ?

...この状況でのクラス継承図の形から、「ダイヤモンド問題」と呼ばれています。この場合、クラスAが一番上にあり、BとCの両方がその下に別々にあり、Dが2つを一番下で結合して菱形を形成しています...


4
これには、仮想継承と呼ばれるソリューションがあります。それはあなたがそれを間違えた場合にのみ問題です。
Ian Goldby 2013

1
@IanGoldby:仮想継承は、インスタンスの派生元または置換可能なすべてのタイプ間でIDを保持するアップキャストおよびダウンキャストを許可する必要がない場合に、問題の一部を解決するためのメカニズムです。与えられたX:B; Y:B; およびZ:X、Y; someZがZのインスタンスであると想定します。仮想継承では、(B)(X)someZと(B)(Y)someZは別個のオブジェクトです。どちらかを指定すると、ダウンキャストとアップキャストでもう一方を取得できますが、があり、それを次にsomeZキャストしたい場合はどうObjectなりBますか?それBはどちらになりますか?
スーパーキャット2013

2
@supercatおそらく、しかし、そのような問題は大部分が理論上のものであり、いずれの場合でもコンパイラーによって通知されます。重要なことは、どのような問題を解決しようとしているのかを認識してから、最善のツールを使用して、「なぜ」の理解に関心がない人からの教義を無視することです。
Ian Goldby 2013

@IanGoldby:このような問題は、コンパイラが問題のすべてのクラスに同時にアクセスできる場合にのみ通知されます。一部のフレームワークでは、基本クラスに変更を加えると常にすべての派生クラスの再コンパイルが必要になりますが、派生クラス(ソースコードがない可能性がある)を再コンパイルせずに基本クラスの新しいバージョンを使用できる機能は便利な機能ですそれを提供できるフレームワークのために。さらに、問題は理論的なものだけではありません。.NETの多くのクラスは、任意の参照型からその型へのキャストObjectとその型へのキャストが返されるという事実に依存しています...
supercat

3
@IanGoldby:結構です。私の要点は、Javaと.NETの実装者は、一般化されたMIをサポートしないことを決定する際に、単に「怠惰」ではないということでした。一般化されたMIをサポートすることにより、そのフレームワークがその有効性がMIよりも多くのユーザーにとってより役立つさまざまな公理を支持することができなくなります。
スーパーキャット2013

21

多重継承は、あまり使用されず、誤用される可能性がありますが、必要になる場合があることの1つです。

機能が追加されていないことを理解できなかったのは、それが悪用される可能性があるという理由だけで、良い代替手段がない場合です。インターフェースは多重継承の代替手段ではありません。1つには、事前条件または事後条件を強制することはできません。他のツールと同じように、いつ使用するのが適切で、どのように使用するかを知る必要があります。


事前条件と事後条件を強制できない理由を説明できますか?
Yttrill

2
@Yttrillは、インターフェースにメソッド実装を含めることができないためです。どこに置くのassert
curiousguy

1
@curiousguy:事前条件と事後条件をインターフェイスに直接入力できる適切な構文の言語を使用します。「アサート」は不要です。Felixの例:fun div(num:int、den:int when den!= 0):int expect result == 0はnum == 0を意味します。
イットリル

@Yttrillは問題ありませんが、Javaなどの一部の言語では、MIも、「事前条件および事後条件を直接インターフェースに」もサポートしていません。
curiousguy

入手できないため、あまり使われていませんし、使い方もわかりません。いくつかのScalaコードを見ると、物事がどのように一般的になり始め、特性にリファクタリングできるかがわかります(わかりました、MIではありませんが、私のポイントを証明しています)。
santiagobasulto 2012

16

Cに継承されているオブジェクトAとBがあるとします。AとBはどちらもfoo()を実装し、Cは実装していません。C.foo()を呼び出します。どの実装が選択されますか?他にも問題はありますが、この種のものは大きな問題です。


1
しかし、それは実際には具体的な例ではありません。AとBの両方に関数がある場合、Cも独自の実装を必要とする可能性が非常に高くなります。それ以外の場合は、独自のfoo()関数でA :: foo()を呼び出すことができます。
PeterKühne、

@Quantum:そうでない場合はどうなりますか?1つの継承レベルで問題を確認するのは簡単ですが、多くのレベルがあり、どこかに2倍のランダム関数がある場合、これは非常に難しい問題になります。
2008年

また、どちらかを指定してメソッドAまたはBを呼び出せないということではなく、指定しないと適切な方法を選択できないという点も重要です。C ++がこれをどのように処理するかはわかりませんが、誰かが言及できるとわかっている場合はどうでしょうか。
08年

2
@tloach-Cがあいまいさを解決しない場合、コンパイラーはこのエラーを検出してコンパイル時エラーを返すことができます。
Eamon Nerbonne

@Earmon-ポリモーフィズムにより、foo()が仮想の場合、コンパイラーはコンパイル時にこれが問題になることさえ知らない可能性があります。
10

5

多重継承の主な問題は、tloachの例とうまくまとめられています。同じ関数またはフィールドを実装する複数の基本クラスから継承する場合、コンパイラーはどの実装を継承するかを決定する必要があります。

同じ基本クラスから継承する複数のクラスから継承する場合、これはさらに悪化します。(ダイヤモンドの継承、継承ツリーを描くとダイヤモンドの形になります)

これらの問題は、コンパイラが克服するために実際には問題ではありません。ただし、コンパイラがここで行う必要のある選択はかなり恣意的であり、これによりコードがはるかに直観的になりません。

優れたOO設計を行う場合、多重継承は必要ありません。必要な場合は、通常、継承を使用して機能を再利用してきましたが、継承は「is-a」関係にのみ適しています。

同じ問題を解決し、多重継承のような問題がないミックスインのような他のテクニックがあります。


4
コンパイルされたものは任意の選択をする必要ありません -それは単にエラーになる可能性があります。C#では、タイプは([..bool..]? "test": 1)何ですか?
Eamon Nerbonne

4
C ++では、コンパイラーがそのような任意の選択を行うことはありません。コンパイラーが任意の選択を行う必要があるクラスを定義することはエラーです。
curiousguy

5

ダイヤモンドの問題は問題ではないと私は思います。

私の観点から見ると、多重継承の最悪の問題はRADです。被害者と開発者であると主張しているが、実際には知識が(せいぜい)半分しか残っていない人々です。

個人的には、このようなWindowsフォームで最終的に何かを実行できたら非常に嬉しく思います(これは正しいコードではありませんが、アイデアがわかるはずです)。

public sealed class CustomerEditView : Form, MVCView<Customer>

これは、多重継承がない場合の主な問題です。インターフェイスでも同様のことができますが、私が「s ***コード」と呼んでいるものがあります。これは、たとえば、データコンテキストを取得するためにクラスのそれぞれに記述しなければならない、この痛みを伴う反復的なc ***です。

私の意見では、現代の言語でコードを繰り返す必要はまったくなく、最低でもないはずです。


私は同意する傾向がありますが、ただ傾向があります。間違いを検出するために、どの言語にも冗長性があることが必要です。とにかく、Felix開発者チームに参加する必要があります。それが中心的な目標だからです。たとえば、すべての宣言は相互に再帰的であり、前方だけでなく後方も確認できるため、前方宣言は必要ありません(スコープはC gotoラベルのようにセットワイズです)。
Yttrill

私はこれに完全に同意します- ここで同様の問題に遭遇しました。人々はダイヤモンドの問題について話します、彼らはそれを宗教的に引用します、しかし私の意見ではそれはとても簡単に避けられます。(iostreamライブラリを作成したようにすべてのプログラムを作成する必要はありません。)重複する関数や関数名のない2つの異なる基本クラスの機能を必要とするオブジェクトがある場合は、論理的に多重継承を使用する必要があります。右手では、それはツールです。
jedd.ahyoung

3
@Turing Complete:コードの繰り返しがないwrt:これは良いアイデアですが、正しくないため不可能です。非常に多くの使用パターンがあり、一般的なものをライブラリに抽象化したいのですが、すべての名前を覚えているセマンティックロードが高すぎても、それらすべてを抽象化するのはおかしいです。あなたが欲しいのは素晴らしいバランスです。繰り返しは物事の構造を与えるものであることを忘れないでください(パターンは冗長性を意味します)。
Yttrill

@ lunchmeat317:「ダイヤモンド」が問題を引き起こすような方法でコードが一般的に記述されるべきではないという事実は、言語/フレームワークの設計者が問題を無視できることを意味するのではありません。フレームワークがアップキャスティングとダウンキャスティングがオブジェクトのアイデンティティを保持することを提供する場合、クラスの新しいバージョンで、互換性のあるタイプの数を増やし、それが重大な変更になることなく、実行時のタイプの作成を許可したい場合、上記の目標を満たしている間は、インターフェイスの継承ではなく、複数のクラスの継承を許可することはできないと思います。
スーパーキャット2012


2

多重継承自体に問題はありません。問題は、最初から多重継承を考慮して設計されていない言語に多重継承を追加することです。

エッフェル言語は非常に効率的かつ生産的な方法で制限なしに多重継承をサポートしていますが、言語はそれをサポートするために最初から設計されました。

この機能はコンパイラ開発者にとって実装が複雑ですが、優れた多重継承サポートが他の機能のサポートを回避できる(つまり、インターフェイスや拡張メソッドの必要がない)ことで欠点を補うことができるようです。

多重継承をサポートするかどうかは、選択の問題、優先順位の問題だと思います。機能が複雑になるほど、正しく実装されて動作するまでに時間がかかり、問題が生じる可能性があります。C ++実装が多重継承がC#およびJavaで実装されなかった理由かもしれません...


1
MIのC ++サポートは「非常に効率的で生産的」ではありませんか?
curiousguy

1
実際には、C ++の他の機能に適合しないという意味で多少壊れています。割り当ては継承では適切に機能しません。多重継承はもちろんです(本当に悪いルールをチェックしてください)。ダイヤモンドを正しく作成することは、標準委員会が例外階層を台無しにして、正しく行うのではなく、単純かつ効率的に維持するのが非常に困難です。当時使用していた古いコンパイラでは、これといくつかのMIミックスインをテストし、基本的な例外の実装にメガバイト以上のコードコストがかかり、定義だけをコンパイルするのに10分かかりました。
イットリル

1
ダイヤモンドは良い例です。エッフェルでは、ダイヤモンドは明示的に解決されます。たとえば、Personから継承するStudentとTeacherを想像してみてください。Personにはカレンダーがあるため、StudentとTeacherの両方がこのカレンダーを継承します。TeacherとStudentの両方から継承するTeachingStudentを作成してひし形を作成する場合、継承されたカレンダーのいずれかの名前を変更して両方のカレンダーを個別に使用できるようにするか、Personのように動作するようにそれらをマージすることを決定できます。多重継承はうまく実装できますが、最初からできれば注意深い設計が必要です...
Christian Lemer

1
Eiffelコンパイラは、このMIモデルを効率的に実装するために、グローバルなプログラム分析を行う必要があります。ここで説明するように、ポリモーフィックメソッド呼び出しでは、ディスパッチャーサンクまたはスパースマトリックスを使用します。これは、C ++の個別のコンパイルやC#やJavaのクラス読み込み機能とうまく混ざりません。
cyco130

2

Javaや.NETなどのフレームワークの設計目標の1つは、コンパイルされたコードが、コンパイル済みライブラリの1つのバージョンで機能することを可能にすることです。新しい機能を追加します。CやC ++などの言語の通常のパラダイムは、必要なすべてのライブラリを含む静的にリンクされた実行可能ファイルを配布することですが、.NETおよびJavaのパラダイムは、実行時に「リンク」されるコンポーネントのコレクションとしてアプリケーションを配布することです。

.NETに先行するCOMモデルはこの一般的なアプローチを使用しようとしましたが、実際には継承がありませんでした。代わりに、各クラス定義は、すべてのパブリックメンバーを含む同じ名前のクラスとインターフェイスの両方を効果的に定義しました。インスタンスはクラス型でしたが、参照はインターフェース型でした。クラスを別のクラスから派生するものとして宣言することは、クラスを他のクラスのインターフェースを実装するものとして宣言することと同等であり、新しいクラスに、派生元のクラスのすべてのパブリックメンバーを再実装することを要求しました。YとZがXから派生し、WがYとZから派生する場合、Zは実装を使用できないため、YとZがXのメンバーを異なる方法で実装しても問題はありません。自分の。WはYやZのインスタンスをカプセル化します。

Javaと.NETの問題は、コードがメンバーを継承し、それらにアクセスして暗黙的に親メンバーを参照できることです。上記のように関連するクラスWZがあるとします。

class X { public virtual void Foo() { Console.WriteLine("XFoo"); }
class Y : X {};
class Z : X {};
class W : Y, Z  // Not actually permitted in C#
{
  public static void Test()
  {
    var it = new W();
    it.Foo();
  }
}

W.Test()Wのインスタンスを作成すると、でFoo定義された仮想メソッドの実装が呼び出されるようになりますX。ただし、YとZは実際には別々にコンパイルされたモジュールにあり、XとWがコンパイルされたときに上記のように定義されていたが、後で変更されて再コンパイルされたとします。

class Y : X { public override void Foo() { Console.WriteLine("YFoo"); }
class Z : X { public override void Foo() { Console.WriteLine("ZFoo"); }

では、呼び出しの効果はW.Test()何でしょうか?配布前にプログラムを静的にリンクする必要がある場合、静的リンクステージは、YとZが変更される前にプログラムに曖昧さはなかったものの、YとZへの変更により不明確になり、リンカーが拒否できることを認識できる場合があります。このようなあいまいさが解消されるまで、または解消されるまで、プログラムをビルドします。一方、WとYおよびZの新しいバージョンの両方を持っている人は、単にプログラムを実行したいだけで、そのいずれのソースコードも持っていない人である可能性があります。ときにW.Test()実行されると、それはもはやクリアされないであろうものをW.Test() する必要がありますが、ユーザーが新しいバージョンのYとZでWを実行しようとするまで、システムのどの部分でも問題があると認識できません(WがYとZの変更前であっても不正と見なされた場合を除く) 。


2

C ++の仮想継承のようなものを使用しない限り、ひし形は問題になりません。通常の継承では、各基本クラスはメンバーフィールドに似ており(実際には、このようにRAMに配置されます)、構文上の砂糖とより多くの仮想メソッドをオーバーライドする追加機能。コンパイル時にあいまいさが生じる可能性がありますが、通常は簡単に解決できます。

一方、仮想継承を使用すると、簡単に制御できなくなります(その後、混乱します)。例として「ハート」図を考えてみましょう:

  A       A
 / \     / \
B   C   D   E
 \ /     \ /
  F       G
    \   /
      H

C ++では完全に不可能です。単一のクラスにマージされるFとすぐにG、それらAのsもマージされます。あなたは++基底クラスがCに不透明考慮しないかもしれないことを意味は、(この例では、あなたが構築しなければならないAH、あなたが知っている必要がありますので、そのこと階層に存在するどこか)。ただし、他の言語では機能する場合があります。例えば、FG明示的従って結果としてマージを禁止し、効果自体は固体行う「内部」としてAを宣言することができました。

別の興味深い例(C ++固有ではない):

  A
 / \
B   B
|   |
C   D
 \ /
  E

ここでBは、仮想継承のみを使用します。だから、E2含まれているBのは同じことを共有A。このようにして、をA*指すポインターを取得EできB*ますが、オブジェクト実際B にそのようなキャストであることは不明ですが、ポインターにキャストすることはできず、このあいまいさはコンパイル時に検出できません(コンパイラーがプログラム全体)。ここにテストコードがあります:

struct A { virtual ~A() {} /* so that the class is polymorphic */ };
struct B: virtual A {};
struct C: B {};
struct D: B {};
struct E: C, D {};

int main() {
        E data;
        E *e = &data;
        A *a = dynamic_cast<A *>(e); // works, A is unambiguous
//      B *b = dynamic_cast<B *>(e); // doesn't compile
        B *b = dynamic_cast<B *>(a); // NULL: B is ambiguous
        std::cout << "E: " << e << std::endl;
        std::cout << "A: " << a << std::endl;
        std::cout << "B: " << b << std::endl;
// the next casts work
        std::cout << "A::C::B: " << dynamic_cast<B *>(dynamic_cast<C *>(e)) << std::endl;
        std::cout << "A::D::B: " << dynamic_cast<B *>(dynamic_cast<D *>(e)) << std::endl;
        std::cout << "A=>C=>B: " << dynamic_cast<B *>(dynamic_cast<C *>(a)) << std::endl;
        std::cout << "A=>D=>B: " << dynamic_cast<B *>(dynamic_cast<D *>(a)) << std::endl;
        return 0;
}

さらに、実装は非常に複雑になる場合があります(言語によって異なります。ベンジスミスの回答を参照してください)。


それがMIの本当の問題です。プログラマーは、1つのクラス内で異なる解像度を必要とする場合があります。言語全体のソリューションは、可能なことを制限し、プログラマーにプログラムを正しく機能させるためのクラッジを作成するように強制します。
shawnhcorey
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.