127

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

多重继承是怎么回事?有具体的样品吗?

4

11 回答 11

94

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

假设有两个类AB,它们都定义了一个方法doSomething。现在您定义了第三个 class C,它继承自Aand B,但您不覆盖该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

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

多重继承使它非常棘手。

如果 classC继承自Aand B,编译器必须决定是按AB顺序还是按BA顺序布局数据。

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

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

所以......长话短说......编译器作者支持多重继承是一件很痛苦的事情。因此,当像 Guido van Rossum 这样的人设计 python,或者当 Anders Hejlsberg 设计 c# 时,他们知道支持多重继承将使编译器实现变得更加复杂,并且大概他们认为收益不值得付出代价。

于 2008-10-22T14:44:37.210 回答
50

你们提到的问题其实并没有那么难解决。事实上,例如 Eiffel 做得非常好!(并且没有引入任意选择或其他)

例如,如果您从 A 和 B 继承,都具有方法 foo(),那么您当然不希望在从 A 和 B 继承的类 C 中进行任意选择。您必须重新定义 foo,以便清楚将是什么如果 c.foo() 被调用,则使用,否则您必须重命名 C 中的方法之一。(它可能变成 bar())

另外我认为多重继承通常非常有用。如果您查看 Eiffel 的库,您会发现它在所有地方都被使用,而且当我不得不回到 Java 编程时,我个人已经错过了这个功能。

于 2008-11-14T22:03:03.990 回答
28

钻石问题

当两个类 B 和 C 继承自 A,而类 D 继承自 B 和 C 时,就会出现歧义。如果 A 中存在 B 和 C 已覆盖的方法,并且 D 没有覆盖它,那么哪个版本的D 继承的方法:B 的方法,还是 C 的方法?

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

于 2008-10-22T14:30:42.340 回答
22

多重继承是不经常使用的东西之一,可能会被误用,但有时需要。

我从来不理解不添加一个功能,只是因为它可能被滥用,当没有好的替代品时。接口不是多重继承的替代品。一方面,它们不允许您强制执行前置条件或后置条件。就像任何其他工具一样,您需要知道何时适合使用以及如何使用它。

于 2008-10-22T14:33:05.797 回答
17

假设您有对象 A 和 B,它们都由 C 继承。A 和 B 都实现了 foo() 而 C 没有。我打电话给 C.foo()。选择哪个实现?还有其他问题,但这种事情是一个大问题。

于 2008-10-22T14:19:47.663 回答
6

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

从我的角度来看,多重继承最糟糕的问题是 RAD——受害者和自称是开发人员但实际上被困在半边知识(充其量)的人。

就个人而言,如果我最终能在 Windows 窗体中做这样的事情,我会非常高兴(这不是正确的代码,但它应该给你这个想法):

public sealed class CustomerEditView : Form, MVCView<Customer>

这是我没有多重继承的主要问题。你可以对接口做类似的事情,但是我称之为“s***代码”,例如,你必须在每个类中编写这种痛苦的重复c***来获取数据上下文。

在我看来,对于现代语言中的任何代码重复,绝对没有必要,一点也没有。

于 2010-07-06T07:57:52.250 回答
5

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

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

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

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

还有其他技术(例如 mixins)可以解决相同的问题,并且没有多重继承所存在的问题。

于 2008-10-22T14:28:15.213 回答
3

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

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

对于编译器开发人员来说,这个特性实现起来很复杂,但似乎可以通过良好的多重继承支持可以避免支持其他特性(即不需要接口或扩展方法)来弥补这个缺点。

我认为是否支持多重继承更多的是一个选择问题,一个优先级问题。更复杂的功能需要更多时间才能正确实施和操作,并且可能更具争议性。C++ 实现可能是 C# 和 Java 中没有实现多重继承的原因......

于 2009-09-02T14:54:44.900 回答
3

Common Lisp 对象系统 (CLOS) 是另一个例子,它支持 MI 同时避免了 C++ 风格的问题:继承被赋予一个合理的默认值,同时仍然允许您自由地明确决定如何准确地调用 super 的行为.

于 2010-03-26T08:19:31.093 回答
3

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

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

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

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

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

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

这里,只B使用虚拟继承。所以E包含两个B共享相同的 s 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 的回答)。

于 2016-05-22T21:54:10.510 回答
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"); }

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

于 2013-04-08T15:49:38.213 回答