多重继承的确切问题是什么?


121

我可以看到人们一直在问,下一个版本的C#或Java是否应该包含多重继承。幸运的是,拥有这种能力的C ++人士说,这就像在给某人一条绳索,最终使自己吊死。

多重继承有什么关系?有没有具体样品?


54
我只想提一下C ++可以为您提供足够的绳索来吊死自己。
偷渡

1
对于替代多重继承该地址(和,恕我直言,解决了)许多相同的问题,看性状(iam.unibe.ch/~scg/Research/Traits
贝文

52
我以为C ++可以给您足够的力量,让您脚踏实地。
KeithB

6
这个问题似乎是假设MI总体上存在问题,而我发现很多语言都是随便使用MI的。某些语言对MI的处理当然存在问题,但我不知道MI通常存在重大问题。
David Thornley,2010年

Answers:


86

最明显的问题是函数覆盖。

假设有两个类AB,两个都定义了一个method doSomething。现在,您定义了第三个类C,该类继承自AB,但是您不覆盖该doSomething方法。

当编译器将此代码植入种子时...

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

...应使用哪种方法实现?如果没有进一步的澄清,编译器就不可能解决歧义。

除了覆盖之外,多重继承的另一个大问题是内存中物理对象的布局。

诸如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

当编译器生成机器代码(或字节码)时,它将使用这些数字偏移量来访问每个方法或字段。

多重继承使其非常棘手。

如果class CA和两者都继承B,则编译器必须决定是按AB顺序排列还是按BA顺序排列数据。

但是现在想象一下,您正在调用B对象上的方法。真的只是一个B吗?还是它实际上是C通过其B接口被多态调用的对象?根据对象的实际身份,物理布局将有所不同,并且无法知道要在调用站点处调用的函数的偏移量。

处理此类系统的方法是放弃固定布局方法,允许尝试调用功能或访问其字段之前查询每个对象的布局。

所以...长话短说...对于编译器作者来说,支持多重继承是一个痛苦的过程。因此,当像Guido van Rossum这样的人设计python时,或者当Anders Hejlsberg设计c#时,他们知道支持多重继承将使编译器的实现变得更加复杂,并且大概他们认为这样做并不值得。


62
嗯,Python支持MI
Nemanja Trifunovic,2009年

26
这些不是很令人信服的论点-在大多数语言中,固定布局的东西一点都不麻烦。在C ++中,这很棘手,因为内存不是不透明的,因此您可能会在指针算术假设方面遇到一些困难。在类定义是静态的语言中(例如在Java,C#和C ++中),可以禁止多次继承名称冲突(在C#中,接口无论如何都要这样做!)。
Eamon Nerbonne 09年

10
OP只是想了解问题,因此我在未亲自编辑此事的情况下对它们进行了解释。我只是说过,语言设计者和编译器实现者“大概不认为好处值得付出代价”。
benjismith

12
最明显的问题是与功能覆盖有关。 ”这与功能覆盖无关。这是一个简单的歧义问题。
curiousguy 2011年

10
由于Python支持MI,因此该答案具有有关Guido和Python的一些错误信息。“我决定,只要我要支持继承,就最好支持一个简单的多重继承版本。” — Guido van Rossumpython-history.blogspot.com/2009/02/…—此外,歧义解析在编译器中非常普遍(变量可以是局部于块,局部于函数,局部于封闭函数,对象成员,类成员,全局变量等),我看不到额外的范围会如何有所作为。
marcus

46

你们提到的问题并不是真的很难解决。实际上,例如,埃菲尔(Eiffel)做得很好!(并且不引入任意选择或任何其他选择)

例如,如果您从都具有方法foo()的A和B继承,那么您当然不希望在类C中从A和B继承任何选择。您必须重新定义foo,这样就很清楚了如果调用c.foo()则使用,否则必须重命名C中的方法之一(它可能会变成bar())

我还认为多重继承通常非常有用。如果您查看Eiffel的库,您会发现它已在各处使用,并且当我不得不回到Java编程时,我个人错过了该功能。


26
我同意。人们讨厌MI的主要原因与JavaScript或静态类型相同:大多数人只使用过非常糟糕的实现,或者使用得非常糟糕。通过C ++判断MI就像通过PHP判断OOP或通过Pintos判断汽车。
约尔格W¯¯米塔格

2
@curiousguy:MI引入了另一套需要担心的复杂性,就像C ++的许多“功能”一样。仅仅因为它是模棱两可的并不能使其易于使用或调试。删除该链,因为它脱离了主题,您无论如何都将其引爆了。
古凡特

4
@Guvante用任何一种语言编写的MI唯一的问题是低劣的程序员认为他们可以阅读教程并突然知道一种语言。
Miles Rout

2
我认为语言功能不只是减少编码时间。它们还与提高语言的表现力和提高性能有关。
Miles Rout

4
而且,仅当白痴错误使用MI时,MI才会发生错误。
Miles Rout

27

钻石问题

该产生的歧义时从B和C.两个类B和从A C继承和类继承d。如果在A中的方法,该方法B和C都重写,和d不覆盖它,然后的哪个版本D继承的方法是:B还是C?

...由于这种情况下类继承图的形状,它被称为“钻石问题”。在这种情况下,A类位于顶部,B和C分别位于其下方,D则在底部将两者结合在一起以形成菱形...


4
它有一个称为虚拟继承的解决方案。如果做错了,这只是一个问题。
伊恩·戈德比2013年

1
@IanGoldby:虚拟继承是一种解决部分问题的机制,如果一个实例不需要在实例派生或可替换的所有类型之间允许保留身份的上下行。给定X:B; Y:B; 和Z:X,Y; 假设someZ是Z的实例。通过虚拟继承,(B)(X)someZ和(B)(Y)someZ是不同的对象;给定其中一个,可以通过向下转换和向上转换获得另一个,但是如果一个具有a someZ并想要将其强制转换为Object然后转换为B?这B会得到它?
2013年

2
@supercat也许,但是类似的问题在很大程度上是理论上的,并且在任何情况下都可以由编译器发出信号。重要的是要知道您要解决的问题,然后使用最好的工具,而忽略那些宁愿不了解“为什么”的人的教条。
伊恩·戈德比

@IanGoldby:此类问题只有在编译器可以同时访问所有相关类的情况下,才能通过信号通知。在某些框架中,对基类的任何更改都将始终需要重新编译所有派生类,但是使用较新版本的基类而不必重新编译派生类(对于该类可能没有源代码)的功能是一项有用的功能。可以提供它的框架。此外,问题不只是理论上的。.NET中的许多类都依赖于这样的事实,即从任何引用类型Object
强制

3
@IanGoldby:足够公平。我的观点是,Java和.NET的实现者在决定不支持通用MI时并不只是“懒惰”。支持广义MI会阻止其框架支持各种公理,而这些公理对许多用户而言比MI更有用。
2013年

21

多重继承是不经常使用的事情之一,可以被滥用,但有时是必需的。

我从来不理解没有添加功能,只是因为在没有很好的选择时,它可能会被滥用。接口不是多重继承的替代方法。首先,它们不允许您强制执行先决条件或后置条件。与其他任何工具一样,您需要知道什么时候适合使用以及如何使用。


您能解释为什么他们不让您执行前后条件吗?
Yttrill 2011年

2
@Yttrill,因为接口不能具有方法实现。你放在哪里assert
curiousguy

1
@curiousguy:您使用一种具有适当语法的语言,该语法允许您将前置条件和后置条件直接放入界面中:无需“断言”。来自Felix的示例:fun div(num:int,den:当den!= 0时为int):int预期结果== 0意味着num == 0;
Yttrill 2011年

@Yttrill可以,但是某些语言(例如Java)不支持MI或“直接在接口中的前置条件和后置条件”。
curiousguy 2011年

它不经常使用是因为它不可用,而且我们也不知道如何很好地使用它。如果看一些Scala代码,您将看到事情如何开始变得普遍并且可以重构为特征(好吧,它不是MI,但是证明了我的意思)。
santiagobasulto 2012年

16

假设您有对象A和B都由C继承。A和B都实现foo()而C没有。我叫C.foo()。选择哪个实现?还有其他问题,但是这类事情很重要。


1
但这并不是一个具体的例子。如果A和B都具有功能,则C很可能也需要它自己的实现。否则,它仍然可以在自己的foo()函数中调用A :: foo()。
彼得·库恩(PeterKühne)

@Quantum:如果没有呢?很容易看到一个继承级别的问题,但是如果您有很多继承级别,并且有一些随机函数是某个继承级别的两倍,那么这将是一个非常困难的问题。
2008年

另外,重点不是您不能通过指定所需的方法来调用方法A或B,而是要指出的是,如果不指定方法,则没有很好的选择方法。我不确定C ++如何处理此问题,但是如果有人知道可以提一下吗?
08年

2
@tloach-如果C无法解决歧义,则编译器可以检测到此错误并返回编译时错误。
Eamon Nerbonne 09年

@Earmon-由于多态性,如果foo()是虚拟的,则编译器甚至可能在编译时不知道这将是一个问题。
10年

5

用tloach的例子很好地总结了多重继承的主要问题。从实现相同功能或字段的多个基类继承时,编译器必须决定要继承的实现。

当您从多个继承自同一基类的类继承时,情况变得更糟。(钻石继承,如果您绘制继承树,则会得到菱形)

这些问题对于编译器来说并不是真正的难题。但是编译器在这里必须做出的选择是相当随意的,这使代码不那么直观。

我发现在进行良好的OO设计时,我不需要多重继承。在确实需要的情况下,我通常会发现我一直在使用继承重用功能,而继承仅适用于“ is-a”关系。

还有其他技术,例如混合解决了相同的问题,并且没有多重继承的问题。


4
编译并不需要做出任意选择-它可以简单地报错了。在C#中,类型是([..bool..]? "test": 1)什么?
Eamon Nerbonne 09年

4
在C ++中,编译器永远不会做出这样的任意选择:定义一个类,编译器需要做出任意选择是一个错误。
curiousguy 2011年

5

我不认为钻石问题是一个问题,我会认为这是诡辩,仅此而已。

从我的角度来看,具有多重继承的最糟糕的问题是RAD(受害人和自称是开发人员,但实际上却只拥有一半知识的人)(至多)。

就我个人而言,如果我最终可以在Windows窗体中执行以下操作(这不是正确的代码,但应该可以给您带来想法),我将感到非常高兴:

public sealed class CustomerEditView : Form, MVCView<Customer>

这是我没有多重继承的主要问题。您可以对接口进行类似的操作,但是有所谓的“ s ***代码”,例如,您必须在每个类中编写这种重复的痛苦代码才能获取数据上下文。

我认为,绝对没有必要,也没有丝毫重复现代语言中的任何代码。


我倾向于同意,但只能倾向于:在任何语言中都需要某种冗余来检测错误。无论如何,您应该加入Felix开发人员团队,因为这是一个核心目标。例如,所有声明都是相互递归的,您可以看到正向和反向,因此不需要正向声明(scope是有向的,就像C goto标签一样)。
Yttrill 2011年

我完全同意这一点-我在这里遇到了类似的问题。人们谈论钻石问题,他们虔诚地引用它,但在我看来,这很容易避免。(我们并不需要像编写iostream库那样编写程序。)当您拥有一个需要两个不同基类且没有重叠函数或函数名的功能的对象时,应该在逻辑上使用多重继承。在右边,这是一种工具。
jedd.ahyoung 2011年

3
@Turing Complete:没有任何代码重复:这是一个不错的主意,但这是不正确且不可能的。用法模式有很多,我们希望将常见的用法模式抽象到库中,但是将所有这些抽象为抽象是很疯狂的,因为即使我们记住所有名称的语义负担过高。您想要的是一个不错的平衡点。不要忘记重复是赋予事物结构的原因(模式意味着冗余)。
Yttrill 2011年

@ lunchmeat317:通常不应以“菱形”会引起问题的方式编写代码,但这并不意味着语言/框架设计人员可以忽略该问题。如果一个框架提供了向上转换和向下转换来保留对象标识,那么希望允许类的更高版本增加其可以替换的类型数量,而不会造成重大变化,并希望允许运行时类型创建,我认为在实现上述目标的同时,它不能允许多个类继承(与接口继承相反)。
2012年

3

通用Lisp对象系统(CLOS)是支持MI的另一示例,同时避免了C ++样式的问题:继承被赋予合理的默认值,同时仍使您可以自由地决定如何精确地定义(例如,调用超级对象的行为) 。


是的,自从现代计算技术问世以来,CLOS一直是最出色的对象系统之一,它甚至可能已经过去很久了:)
rostamn739

2

多重继承本身没有错。问题是从一开始就将多重继承添加到不考虑多重继承的语言中。

Eiffel语言以一种非常有效和富有成效的方式无限制地支持多重继承,但是从那时候开始设计该语言就是为了支持它。

对于编译器开发人员来说,实现此功能很复杂,但是似乎可以通过以下事实来弥补这一缺陷:良好的多重继承支持可以避免支持其他功能(即不需要接口或扩展方法)。

我认为是否支持多重继承更多是选择问题,是优先事项。更复杂的功能需要花费更多时间才能正确实施和操作,并且可能会引起更多争议。C ++实现可能是未在C#和Java中实现多重继承的原因...


1
C ++对MI的支持不是“ 非常有效和富有成效 ”吗?
curiousguy 2011年

1
实际上,从某种意义上说它有些破损,不符合C ++的其他功能。分配不能与继承一起正常工作,更不用说多重继承了(查看真正糟糕的规则)。正确地创建菱形是如此困难,因此标准委员会搞砸了异常层次结构,以使其简单有效,而不是正确地进行处理。在测试此功能时,我正在使用一个较旧的编译器,使用了一些MI mixins和基本异常的实现需要花费超过一兆字节的代码,并且仅花了10分钟的时间来编译..仅定义。
Yttrill 2011年

1
钻石就是一个很好的例子。在埃菲尔铁塔中,钻石得到明确解决。例如,假设学生和老师都继承自Person。此人有一个日历,因此学生和教师都将继承此日历。如果您通过创建从Teacher和Student都继承的TeachingStudent来构建菱形,则可以决定重命名一个继承的日历,以使两个日历单独可用,或者决定合并它们,使其行为更像Person。多重继承可以很好地实现,但是最好从一开始就需要仔细的设计……
Christian Lemer,2012年

1
Eiffel编译器必须进行全局程序分析才能有效地实现此MI模型。对于多态的方法调用它们使用或者调度员的thunk或稀疏矩阵作为解释在这里。这与C ++的单独编译以及C#和Java的类加载功能不能很好地融合在一起。
cyco130

2

Java和.NET等框架的设计目标之一是使编译后的代码可以与预编译库的一个版本一起使用,使其与该库的后续版本同样良好地工作,即使这些后续版本添加新功能。尽管C或C ++等语言的常规范例是分发包含它们所需的所有库的静态链接的可执行文件,但.NET和Java中的范例是将应用程序分发为在运行时“链接”的组件的集合。 。

.NET之前的COM模型尝试使用这种通用方法,但实际上并没有继承-而是,每个类定义都有效地定义了一个类和一个包含所有公共成员的同名接口。实例属于类类型,而引用属于接口类型。声明一个类是从另一个派生的,等效于将一个类声明为实现另一个的接口,并要求新类重新实现派生自该类的所有公共成员。如果Y和Z从X派生,然后W从Y和Z派生,那么Y和Z是否以不同的方式实现X的成员无关紧要,因为Z无法使用它们的实现,因此必须定义其实现。拥有。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"); }

现在,调用应该产生什么效果,但是直到用户尝试使用新版本的Y和Z运行W之前,系统的任何部分都无法识别出问题(除非W被认为是非法的)更改为Y和Z之前)。W.Test()如何?如果程序必须在分发之前进行静态链接,则静态链接阶段可能可以识别出,虽然在更改Y和Z之前程序没有歧义,但是对Y和Z的更改使事情变得模棱两可,并且链接程序可能拒绝除非或直到解决此类歧义,否则请构建程序。另一方面,同时拥有W和Y和Z的新版本的人可能只是想运行该程序而没有任何源代码的人。当W.Test()运行时,它将不再明确什么W.Test()


2

只要您使用C ++虚拟继承之类的东西,钻石就没有问题:在正常继承中,每个基类都类似于一个成员字段(实际上,它们以这种方式布置在RAM中),为您提供了一些语法糖和一个覆盖更多虚拟方法的额外功能。这可能会在编译时产生一些歧义,但是通常很容易解决。

另一方面,通过虚拟继承,它很容易失控(然后变成一团糟)。以“心脏”图为例:

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

在C ++中,这是完全不可能的:一旦FG合并为一个类,它们的As也将被合并。这意味着您可能永远不会认为C ++中的基类是不透明的(在此示例中,您必须构造基类AH因此必须知道它在层次结构中的某个位置)。但是,在其他语言中,它可能会起作用。例如,FG可以显式地声明A作为“内部”,从而禁止随后的合并和有效地使自己固体。

另一个有趣的示例(不是特定于C ++的):

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

在这里,仅B使用虚拟继承。因此E包含两个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;
}

此外,实现可能非常复杂(取决于语言;请参见Benjismith的回答)。


这是MI的真正问题。程序员在一类中可能需要不同的分辨率。一种全语言的解决方案将限制可能的工作,并迫使程序员创建错误代码以使程序正常工作。
shawnhcorey
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.