Java 中的虚拟表和抽象

2022-09-01 15:50:02

在一次采访中,我得到了以下代码:

public abstract class Base {
    public int x = 1;
    public Base() {
        foo();
    }
    public abstract void foo();
}

public class Derived extends Base {
    int x = 2;
    @Override
    public void foo() {
        System.out.println("Derived: "+x);
    }
}

class Main {
    public static void main(String... args) {
        Base base = new Derived();
        base.foo();
    }
}

他们问:

将打印什么?

如果我们使用C++我认为代码应该给出编译错误,因为当首先调用构造函数时,调用类的构造函数。此时,该方法不存在。DerivedBasefoo

此外,我知道首先调用继承的类构造函数,然后再创建所有变量。

然而,在Java中,我们得到:

Derived: 0
Derived: 2

为什么?

我知道像在C++Java继承总是基于虚拟表,并且类的构造函数在类的构造函数之前调用。BaseDerived


答案 1

这是代码的执行顺序。更多细节如下。

  • main()
    • invokes(隐式空构造函数)Derived.<init>()
      • 调用Base.<init>()
        • 设置为 。Base.x1
        • 调用Derived.foo()
          • prints ,其默认值仍为Derived.x0
      • 设置为 。Derived.x2
    • 调用。Derived.foo()
      • 打印,现在是 .Derived.x2

要完全了解正在发生的事情,您需要了解几件事。

场阴影

Base的 和 是完全不同的字段,碰巧具有相同的名称。 打印,不是,因为后者被前者“阴影”。xDerivedxDerived.fooDerived.xBase.x

隐式构造函数

由于没有显式构造函数,编译器将生成一个隐式零参数构造函数。在 Java 中,每个构造函数都必须调用一个超类构造函数(没有超类的除外),这使超类有机会安全地初始化其字段。编译器生成的空构造函数仅调用其超类的空构造函数。(如果超类没有空构造函数,则会产生编译错误。DerivedObject

因此,的隐式构造函数看起来像Derived

public Derived() {
    super();
}

初始值设定项块和字段定义

初始值设定项块按声明顺序组合,形成一个大代码块,该代码块插入到所有构造函数中。具体来说,它是在调用之后插入的,但在构造函数的其余部分之前插入的。字段定义中的初始值分配被视为初始值设定项块。super()

因此,如果我们有

class Test {
    {x=1;}
    int x = 2;
    {x=3;}

    Test() {
        x = 0;
    }
}

这等效于

class Test {
    int x;

    {
        x = 1;
        x = 2;
        x = 3;
    }

    Test() {
        x = 0;
    }
}

这就是编译的构造函数的实际外观:

Test() {
    // implicit call to the superclass constructor, Object.<init>()
    super();
    // initializer blocks, in declaration order
    x = 1
    x = 2
    x = 3
    // the explicit constructor code
    x = 0
}

现在让我们回到 和 。如果我们反编译它们的构造函数,我们会看到类似的东西BaseDerived

public Base() {
    super(); // Object.<init>()
    x = 1; // assigns Base.x
    foo();
}

public Derived() {
    super(); // Base.<init>()
    x = 2; // assigns Derived.x
}

虚拟调用

在 Java 中,实例方法的调用通常通过虚拟方法表。(也有例外。构造函数、私有方法、最终方法和最终类的方法不能被重写,因此无需通过 vtable 即可调用这些方法。并且调用不会通过 vtables,因为它们本质上不是多态的。super

每个对象都有一个指向类句柄的指针,该类句柄包含一个 vtable。一旦分配了对象(with ),就会在调用任何构造函数之前设置此指针。因此,在Java中,构造函数进行虚拟方法调用是安全的,并且它们将被正确定向到虚拟方法的目标实现。NEW

所以当 的构造函数调用 时,它会调用 ,它打印 。但尚未分配,因此 默认值为读取和打印。Basefoo()Derived.fooDerived.xDerived.x0


答案 2

显然,只调用派生类。foo()

它第一次打印,因为它发生在赋值之前,这只发生在 的构造函数中,在 初始化完成后。它打印而不是 ,因为 正在被访问而不是 ,并且它尚未初始化,并且仍然是 。的声明 in 隐藏了 中的字段,因此当 是打印时,它会打印 。0x = 2DerivedBase01Derived.xBase.x0xDerivedBaseDerivedxDerived.x

编辑:创建时激活顺序:[原理图]Derived()

1. create Base:
   1.1. assign Base.x = 1
   1.2. invoke foo()
      1.2.1 print Derived: Derived.x //Derived.x was not initialized here yet!
2. assign Derived.x = 2

第二个是微不足道的,[至少在我看来]是意料之中的。


推荐