多重继承到底有什么问题?

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

多重继承怎么了? 有没有混凝土样本?

86143 次浏览

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

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

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

这些问题对于编译器来说并不是真正需要克服的问题。但是编译器在这里必须做出的选择是相当武断的,这使得代码的直观性大打折扣。

我发现当我做好面向对象的设计时,我从不需要多重继承。在我确实需要它的情况下,我通常会发现我一直在使用继承来重用功能,而继承只适用于“ is-a”关系。

还有其他一些技术,比如 Mixin,可以解决同样的问题,而不会出现多重继承所面临的问题。

钻石问题 :

当两个 B 类和 C 类继承自 A 类,而 D 类继承自 B 类和 C 类时产生的歧义。如果 A 中有一个方法 B 和 C 都有 覆盖,而 D 没有覆盖它,那么 D 继承了哪个版本的方法: B 的方法还是 C 的方法?

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

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

我一直不明白为什么没有添加一个特性,只是因为它可能被滥用,当没有好的替代方案时。接口不是多重继承的替代品。首先,它们不允许您强制执行前置条件或后置条件。就像其他任何工具一样,您需要知道什么时候适合使用它,以及如何使用它。

最明显的问题是函数重写。

假设有两个类 AB,它们都定义了一个方法 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

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

多重继承让它变得非常棘手。

如果类 C同时从 AB继承,编译器必须决定是按 AB顺序还是按 BA顺序布局数据。

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

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

所以... 长话短说... 对于编译器作者来说支持多重继承是件痛苦的事情。因此,当有人喜欢吉多·范罗苏姆设计 python 或者安德斯·海尔斯伯格设计 c # 时,他们知道支持多重继承会使编译器的实现变得更加复杂,而且他们可能认为这种好处不值得付出代价。

你们提到的问题其实并不难解决。事实上,埃菲尔铁塔做得非常好!(并且不引入任意选择或者其他什么)

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

而且我认为多重继承通常是非常有用的。如果你看看埃菲尔铁塔的库,你会发现它被广泛使用,而我个人在回到 Java 编程的时候忽略了这个特性。

多重继承本身并没有错。问题在于,如果一门语言的设计从一开始就没有考虑到多重继承多重继承,那么现在就应该给它加入新的特性。

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

这个特性对于编译器开发者来说实现起来很复杂,但是好的多重继承支持可以避免其他特性的支持(例如不需要接口或者扩展方法)。

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

Common Lisp Object System (CLOS)是另一个支持 MI 同时避免 C + + 风格问题的例子: 继承被赋予一个 明智的违约,同时仍然允许你显式地决定如何准确地,比如说,调用一个 super 的行为。

我不认为钻石问题是个问题,我认为这是诡辩,没有别的。

在我看来,多重继承最糟糕的问题是 RAD 受害者,以及那些自称是开发人员但实际上只有一半知识的人(充其量也就是半知半解)。

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

public sealed class CustomerEditView : Form, MVCView<Customer>

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

在我看来,在现代语言中,代码的任何重复都是完全没有必要的,一点也不需要。

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

之前的 COM 模型。NET 尝试使用这种通用的方法,但是它并没有真正的继承——相反,每个类定义都有效地定义了一个类和一个同名的接口,其中包含了所有的公共成员。实例属于类类型,而引用属于接口类型。将类声明为从另一个类派生的类等同于将类声明为实现另一个类的接口,并要求新类重新实现从其派生的类的所有公共成员。如果 Y 和 Z 派生自 X,然后 W 派生自 Y 和 Z,那么 Y 和 Z 是否以不同的方式实现 X 的成员并不重要,因为 Z 不能使用它们的实现——它必须定义自己的实现。W 可能封装 Y 和/或 Z 的实例,并通过它们的方法链接 X 的方法的实现,但是对于 X 的方法应该做什么没有模糊性——它们会做 Z 的代码明确指示它们做的任何事情。

Java 和。NET 是允许代码继承成员并对其进行访问,毫无疑问引用父成员。假设一个类与 W-Z 相关,如上所示:

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 实例来调用在 X中定义的虚方法 Foo的实现。然而,假设 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 时,系统的任何部分都不可能认识到存在问题(除非在对 Y 和 Z 进行更改之前 W 被认为是非法的)。

菱形不是问题,只要你使用类似于 c + + 的虚继承,在正常继承中,每个基类类似于一个成员字段(实际上它们是以这种方式在 RAM 中布局的) ,给你一些语法糖和一个额外的能力来覆盖更多的虚方法。这可能会在编译时造成一些歧义,但通常很容易解决。

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

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

在 C + + 中,这是完全不可能的: 只要 FG合并成一个类,它们的 A也会合并,句号。这意味着您可能永远不会认为 C + + 中的基类是不透明的(在本例中,您必须在 H中构造 A,因此您必须知道它存在于层次结构中的某个位置)。然而,在其他语言中,它可以工作; 例如,FG可以明确地将 A 声明为“内部”,从而禁止后续的合并并有效地使自己变得坚固。

另一个有趣的例子(特定于 没有 C + +) :

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

在这里,只有 B使用虚继承。因此,E包含两个共享相同 AB。这样,您可以得到一个指向 EA*指针,但是您不能将其强制转换为 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 的答案)。