在构造函数中调用虚方法:Java和C++之间的区别

19

在Java中:

class Base {
    public Base() { System.out.println("Base::Base()"); virt(); }
    void virt()   { System.out.println("Base::virt()"); }
}

class Derived extends Base {
    public Derived() { System.out.println("Derived::Derived()"); virt(); }
    void virt()      { System.out.println("Derived::virt()"); }
}

public class Main {
    public static void main(String[] args) {
        new Derived();
    }
}

这将会输出

Base::Base()
Derived::virt()
Derived::Derived()
Derived::virt()

但在C ++中,结果是不同的:

Base::Base()
Base::virt() // ← Not Derived::virt()
Derived::Derived()
Derived::virt()

(有关C++代码,请参见http://www.parashift.com/c++-faq-lite/calling-virtuals-from-ctors.html

是什么导致了Java和C++之间的这种差异?是vtable初始化的时间吗?

编辑:我确实理解Java和C++机制。我想知道的是这个设计决策背后的原因。


你指向的常见问题解答已经解释了发生了什么。你还想知道什么? - Mat
1
@Mat,我想知道为什么Java在这方面有所不同.. - Xiao Jia
2
因为它们是两种具有不同语义的不同编程语言?为什么它们应该做相同的事情呢? - Mat
@Mat,实际上我想知道这个设计决策背后的思路是什么。 - Xiao Jia
“这是vtable初始化的时候吗?” 你可能指的是vtable指针,或者vptr,而不是vtable本身。 - curiousguy
我简直不敢相信没有人提到在C++中从构造函数和析构函数中调用虚函数是未定义的。你可能会得到任何结果。 - Ayxan Haqverdili
7个回答

16

两种方法都有明显的缺点:

  • 在Java中,调用的方法无法正确使用 this ,因为其成员尚未初始化。
  • 在C++中,如果您不知道如何构造类,则会调用一个不直观的方法(即不是派生类中的方法)。

为什么每种语言都这样做是一个开放性问题,但两种语言都可能声称自己是“更安全”的选择: C++的方法可以防止使用未初始化的成员; Java的方法允许在类的构造函数中使用多态语义(在某种程度上),这是一个完全合理的用例。


8
一旦你接受了 C++ 实例是像洋葱一样从内到外逐步构建的概念,调用基类的虚方法就变得相当直观了。因此,在构造基类时,并没有对其方法进行重写。这只会在洋葱生长时发生... - user1284631
关于“两者都可能声称是更安全的选项”的问题,我不这么认为,因为这据报道是Java中最常见的错误之一。 - Cheers and hth. - Alf
@Cheersandhth.-Alf,我现在无法给你提供来源,但我确信我读过这个。我模糊地记得这是Josh Bloch的一本书/演讲,但我同意你的评估,我无法想象Josh会犯这样的错误,所以也许我记错了。 - Konrad Rudolph
2
还有一件事需要修正:短语“即不是虚拟的”误导性地表明从构造函数调用不是虚拟的,但实际上在构造函数中虚拟调用机制与其他地方完全相同。特别是,如果存在对基类方法的调用,该方法反过来调用虚拟方法v,并且在此类中已经重写了v,那么将调用v的覆盖者。 - Cheers and hth. - Alf

13

好的,您已经链接到了常见问题解答中的讨论,但那主要是针对问题而非原理进行讨论。

简而言之,这是为了类型安全

这是C++在类型安全方面击败Java和C#的少数情况之一。 ;-)

当您创建一个类A时,在C++中,您可以让每个A构造函数初始化新实例,以使其状态的所有常见假设(称为类不变式)保持一致。例如,类不变式的一部分可以是指针成员指向某些动态分配的内存。当每个公开可用的方法保留类不变式时,它也保证在进入每个方法时保持不变,这极大地简化了事情 - 至少对于选择良好的类不变式!

然后,在每种方法中,就无需进行进一步的检查。

相反,使用两阶段初始化,例如Microsoft的MFC和ATL库,您永远无法确定在调用方法(非静态成员函数)时是否已正确初始化了所有内容。这与Java和C#非常相似,只不过在这些语言中,缺乏类不变量保证来自这些语言仅启用但不积极支持类不变量的概念。简而言之,从基类构造函数调用Java和C#虚拟方法可能会在尚未初始化的派生实例上进行调用,其中(派生)类不变式尚未建立!

因此,C++对类不变性的语言支持非常出色,有助于消除许多检查和许多令人沮丧的困惑错误。

但是,在基类构造函数中进行派生类特定的初始化比较困难,例如在顶层GUI Widget类的构造函数中执行通用操作。

有关如何模拟基类构造函数内对当前对象进行动态绑定的行为,可以参考常见问题解答中的相关内容。

此外,如需了解更详尽的内容,也可参阅我的博客文章“使用部件工厂避免后期构造”


然而,在基类构造函数中进行派生类特定的初始化有点困难,例如在顶层GUI Widget类的构造函数中执行一般操作。这是一个引用。这是我第一次看到C++方法的缺点。非常感谢。 - user1284631
“在基类构造函数中很难实现针对派生类的特定初始化” - 这是无意义的。当基类构造函数正在执行时,派生类根本不存在,因此基类永远无法对派生类做出任何操作... - Califf
@Califf:你可以继续阅读下一段,其中链接到有关此问题的常见问题解答。然后你可以学到一些东西,包括这个链接现在已经过时,导致原来的常见问题解答位置不存在了,以及常见问题解答已经移动到哪里。虽然你不知道这一点,也不是你的意图,但感谢你在这里让我意识到了这个问题。我以为所有这样的链接都已经更新了。无论如何,检查常见问题解答总是/通常是一个好主意。现在请这样做,并且如果还有任何未解决的问题,请随时提问。 - Cheers and hth. - Alf
@Califf:请不要把常见问题解答作为权威论据。它只是技术信息而已。如果以此作为权威论据,那么会循环论证,因为正是我说服 Marshall Cline 把它加入原版常见问题解答的。但其中使用了 Marshall Cline 的措辞,包括从未流行过的 DBDI 缩写。 - Cheers and hth. - Alf
@Califf:哦,你因为无知而投了反对票。你能否现在把那个反对票撤下来? - Cheers and hth. - Alf

7
无论如何实现,这是语言定义中所规定的差异。Java允许您在尚未完全初始化(已进行了零初始化但其构造函数尚未运行)的派生对象上调用函数。C++不允许这样做;直到派生类的构造函数运行之前,没有派生类存在。

2
希望这能帮到你:
当执行你的代码new Derived()时,首先发生的是内存分配。程序将分配足够大的内存来容纳BaseDerrived的成员变量。此时,没有对象。它只是未初始化的内存。
Base的构造函数完成后,内存中将包含一个Base类型的对象,并且Base的类不变式应该成立。在那个内存中仍然没有Derived对象。 基类的构造期间,Base对象处于部分构建状态,但语言规则足够信任你调用部分构建对象的成员函数。而Derived对象并没有部分构建。它不存在。
你对虚函数的调用最终会调用基类的版本,因为在那个时间点上,Base是对象的最派生类型。如果它调用了Derived::virt,它将会使用一个不是Derrived类型的this指针调用Derived的成员函数,破坏类型安全。
从逻辑上讲,一个类是被构建、调用函数,然后被销毁的东西。你不能在一个未构建的对象上调用成员函数,也不能在一个已被销毁的对象上调用成员函数。这对于面向对象编程来说是相当基本的,C++语言规则只是帮助你避免做破坏这个模型的事情。

0

在C++和Java语言中,构造函数具有多态性,而方法可以在两种语言中具有多态性。这意味着,当一个多态方法出现在构造函数中时,设计者将面临两个选择。

  • 要么严格遵守非多态构造函数的语义,因此将在构造函数内调用的任何多态方法视为非多态。这是C++的做法§
  • 要么妥协于非多态构造函数的严格语义,并遵守多态方法的严格语义。因此,构造函数中的多态方法始终是多态的。这是Java的做法。

由于没有任何一种策略提供或妥协任何真正的好处,与其他方式相比,Java的做法减少了很多开销(不需要根据构造函数的上下文区分多态),而且由于Java是在C++之后设计的,我会认为Java的设计者选择了第二个选项,看到了更少的实现开销的好处。

添加于2016年12月21日


§如果类C直接定义了某个虚函数F,并且它的构造函数调用了F,那么对子类T的任何(间接)构造函数的调用都不会影响F的选择;事实上,C::F总是从C的构造函数中调用。从这个意义上说,虚拟F的调用是不太多态化的(与Java相比,Java将根据T选择F)。此外,重要的是要注意,如果C从某个父类P继承了F的定义,并且没有覆盖F,那么C的构造函数将调用P::F,即使这样,我认为这也可以在静态上确定。


C ++ 构造函数内部调用的“方法”(虚函数)是多态的。 - curiousguy
@curiousguy - 首先,如果您把自己放在语言设计师的位置上,您就会发现,在从类C的构造函数中调用一个带有C++关键词virtual修饰的函数F与实时类型T的对象相关时,“只要”T和C之间没有继承关系(排除任何边角情况),强制实现多态F是非常琐碎的。我猜这就是你在发布第一条评论时考虑的情况。然而,我的陈述(答案中的第一条要点)是针对C和T有关联的情况。希望这能澄清混乱。 - KGhatak
@curiousguy - 上述声明适用于所有语言还是特定的语言?能否请您具体说明! - KGhatak
@curiousguy,定义如下:“多态性是指编程语言根据对象的数据类型或类别以不同方式处理对象的能力”。在C++中,构造函数不遵守该定义,因此可以完全正确地说,在构造函数中调用的任何多态方法都被处理为非多态的。正如KGhatak所述。 - JRr
@curiousguy 1. 我不会定义什么是“作为多态处理/调用”,因为KGhatak已经这样做了。2. 没有人声明构造函数不是编程语言结构。我声明多态的定义不被构造函数遵守。3. 一般方法也是编程语言结构,但它们遵守定义并提供以不同方式处理对象的能力,与构造函数相反。4. 多态的定义不涉及实现细节,例如构造函数T::T()是否生成T对象或苹果对象。 - JRr
显示剩余17条评论

0
在Java中,方法调用是基于对象类型的,这就是为什么它会像那样行为的原因(我不太了解C++)。
在这里,您的对象是Derived类型,所以jvm在Derived对象上调用方法。
如果清楚地理解虚拟概念,在Java中等价于抽象,您现在的代码实际上不是Java术语中的虚拟代码。
如果有错误,很乐意更新我的答案。

2
据我所知,Java 中的 abstract 相当于 C++ 中的 virtual Type func() = 0 - Xiao Jia

0
实际上我想知道这个设计决策背后的见解是什么。
可能在Java中,每种类型都派生自Object,每个Object都是某种叶子类型,并且有一个单独的JVM,在其中构建所有对象。
在C++中,许多类型根本不是虚拟的。此外,在C++中,基类和子类可以分别编译成机器代码:所以基类会执行它应该做的事情,而不管它是否是其他类的超类。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接