我今天有一个测试,其中一个问题是关于在 C++ 构造函数中使用虚拟方法。我没有通过这个问题,我回答应该没有任何问题,但是在阅读后我发现我错了。
所以我知道不允许这样做的原因是因为派生对象没有完全初始化,因此调用它的虚拟方法可能会导致无效的后果。
我的问题是如何在 Java/C# 中解决?我知道我可以在我的基本构造函数中调用派生方法,我会假设这些语言有完全相同的问题。
Java 具有与 C++ 非常不同的对象模型。在 Java 中,您不能拥有属于类类型对象的变量——相反,您只能拥有对(类类型)对象的引用。因此,类的所有成员(它们只是引用)一开始都是微不足道的,null
直到整个派生对象都已在内存中设置完毕。只有这样构造函数才会运行。因此,当基构造函数调用虚函数时,即使该函数被覆盖,被覆盖的函数至少可以正确地引用派生类的成员。(这些成员自己可能还没有被分配,但至少他们存在。)
(如果有帮助,您还可以认为Java 中没有成员的每个类final
在技术上都是可默认构造的,至少在原则上是这样:与 C++ 不同,Java 没有常量或引用(必须在 C++ 中初始化)之类的东西,并且实际上根本没有初始化列表。Java 中的变量根本不需要初始化。它们要么是以 0 开头的原语,要么是以 . 开头的类类型引用null
。一个例外来自非静态final
类成员,它不能被反弹,实际上必须通过在每个构造函数中的某个位置精确地“初始化”一个赋值语句[感谢@josefx 指出这一点!]。)
理解不允许这样做的原因是因为派生对象没有完全初始化,因此调用它的虚方法可能会导致无效的后果
错误的。C++ 将调用方法的基类实现,而不是派生类的实现。没有“无效后果”。避免该构造的唯一正当理由是该行为有时会出人意料。
这与 Java不同,因为 Java 调用派生类的实现。
每个 Java 构造函数都如下所示:
class Foo extends Bar {
Foo() {
super(); // creates Bar
// do things
}
}
因此,如果您将在派生方法上工作的代码do things
放在super();
在 C++ 中,每个多态类(具有至少一个虚函数的类)在它的开头都有一个隐藏指针(通常命名为 v-table 或类似名称),它将被初始化为虚拟表(指向的函数数组)该类的每个虚函数的主体),当您调用虚函数时,C++ 只需调用((v-table*)class)[index of your function]( function-parameters )
,因此,如果您在基类构造函数中调用虚函数,则 v-table 指向基类的虚拟表,因为您的类是基类并且它仍然需要一些初始化才能成为子函数,因此您将从基础而不是子函数调用函数的实现,如果这是一个纯虚函数,您将遇到访问冲突。
但是在java中这不是这样的,在java中整个类是这样std::map<std::string, JValue>
的JValue
是某种变体类型(例如联合或boost::variant
)当您在 base 的构造函数中调用函数时,它会在映射中找到函数名称并调用它,它仍然不是来自孩子的值,但您仍然可以调用它,如果您更改了它prototype
,因为原型在构造函数之前创建,您可以成功地从子函数调用函数,但如果函数需要从子构造函数进行一些初始化,您仍然会收到错误或无效结果。
所以一般来说,从基类中的子函数(例如虚函数)调用函数不是一个好习惯。如果您的类需要这样做,请添加一个初始化方法并从子类的构造函数中调用它。
我认为 Java/C# 通过从派生类向后构造而不是在 C++ 中从基类向前构造来避免这个问题。
Java 在类构造函数中隐式调用 super(),因此当派生类构造函数中的第一行编写代码被调用时,所有继承类的所有构造函数都保证已被调用,因此新实例将已完全初始化.
我也认为在 C++ 中,一个类的新实例作为基类开始生命,并随着我们沿继承链向下移动而“升级”为最终类类型。这意味着当您在构造函数中调用虚函数时,您实际上将调用基类的该函数的版本。
在 Java 和可能的 C# 中,一个新实例作为所需的类类型开始生命,因此将调用正确版本的虚拟方法。
Java 并没有完全避免这个问题。
从依赖于子类字段的超类构造函数调用的重写方法将在这些字段被初始化之前被调用。
如果您控制整个类层次结构,您当然可以确保您的覆盖不依赖于子类字段。但是不从构造函数调用虚方法更安全。