为什么 Java 父类构造函数调用被重写的方法会调用到子类的

2022-11-17 13:45:05 +08:00
 movq
   public static void main(String[] args) {
        class Parent {

            Parent() {
                print();
            }

            void print() {
                System.out.println(10);
            }
        }

        class Child extends Parent {


            void print() {
                System.out.println(20);
            }
        }
        Parent parent = new Child();
    }

上面这段代码输出是 20.

感觉这样很奇怪,为什么父类的构造函数调用 print 给调用到子类了呢?这样难道不是一种反直觉的结果吗?

9681 次点击
所在节点    程序员
125 条回复
fkdog
2022-11-17 20:50:28 +08:00
你既然都已经覆盖掉父类的方法了,如果父构造器还在调用自身的方法,那你就不怕程序出 bug 么。。
ShukeShuke
2022-11-17 21:04:59 +08:00
建议看一下 java 的多态性
ajaxgoldfish
2022-11-17 21:13:18 +08:00
封装、继承、多态,去搜搜“Java 使用多态的的好处示例”这个关键词,学到后边就会明白了,spring 中的设计模式很大一部分就是用的这个特性
AerithLoveMe
2022-11-17 21:13:37 +08:00
我猜楼主应该是觉得创建了父类对象( new 父类),就应该调用父类自己的方法吧,但子类只是调用了父类的构造方法,是由子类调用的,所以用的是子类的 print 方法
maninfog
2022-11-17 21:28:06 +08:00
楼主这个贴其实很有意思。如果用 Kotlin 的话,在父类构造函数内掉非 final 方法,IDE 还会给你一个 lint 提示,因为字类如果重写了这个方法,并且在方法内访问了字类自己的成员变量,那么这个方法的调用其实会出问题,因为这个时候字类的成员变量是还没有初始化的。确实就当是特性就好了,习惯了也会避免去这样做。
ajaxgoldfish
2022-11-17 22:07:46 +08:00
@geelaw yes 是这个意思,兄弟你表达能力太强了。
haya
2022-11-17 22:08:31 +08:00
父类构造方法里的 this 都指向的是 (你 new 出来的)(未初始化完成的)子类对象
movq
2022-11-17 22:08:40 +08:00
@AerithLoveMe

我觉得父类不应该调用子类重写的方法,是因为我觉得这就把父类子类搅和在一起,破坏类的封装性了。

构造函数是给自己初始化的,父类他又不知道别的类写的是什么代码,怎么敢在构造函数里使用别的类的方法来初始化自己?

不过 Java 确实是这样的特性,因为它初始化父类时子类已经存在了,虚函数表里面的函数就是子类的函数。
LeegoYih
2022-11-17 22:39:29 +08:00
同一个方法,因为调用的对象不同,导致结果有多种,这样才是反直觉的吧?难道不觉得这种方式很恐怖吗?
littlewing
2022-11-17 22:54:29 +08:00
看了一遍回复,有接近一半的人不审题,或是大概瞄了一眼就开始回复了,连楼主想问什么都没搞清楚
iseki
2022-11-17 23:26:33 +08:00
@movq 其实父类子类本来就搅合在一起了,不差构造函数这一点吧
mind3x
2022-11-17 23:50:13 +08:00
写了 20 多年 Java ,也写过 JVM ,我来尝试解释一下。

首先 Java 这个行为和 C#是一样的:在子类尚未完成初始化时,父类的构造函数就已经能调用在子类中重载的函数。这意味着不注意的话很容易跑出 NPE 和别的毛病来。这个问题在 stackoverflow 上也经常有人问。

要理解这个行为,可以看一下 JVM spec 里对 invokevirtual 这个字节码的解释: https://docs.oracle.com/javase/specs/jvms/se9/html/jvms-6.html#jvms-6.5.invokevirtual

注:invokevirtual 是 JVM bytecode 调用在 class 中定义的 virtual 函数时所使用的指令。

简单的说,调用虚方法时,查找的是当前实例(this)的类的方法表,也就是你的 Child 的方法表。

这里 Java 和 C++的区别是,C++的虚函数表(vtable)的建立,在逻辑上是动态的,相当于每一层实例构造完以后,更新一次 vtable 。当然实际上 C++编译器不会这么没效率,就把构造函数里调用的函数当作非虚函数在编译期直接 resolve 完事。

而 Java 的类的方法表就那么一张,每个类在加载验证 link 完成以后,方法表就在那里不动了。而基类构造函数的调用是在初始化实例时动态发生的,调虚方法时查的表也是 Child 的表,自然会调用到 Child 中重载的函数,即使此时 Child 的数据成员并未初始化。

这样做在逻辑上确实有难以理解的地方:Child 整个实例都还没处于一个合法的状态,其方法就被调用了。

但是,C++这种做法也有其局限性:确实有场景是需要基类能在构造函数里调用子类重载的虚函数,只要子类的实现不依赖子类的数据成员即可。打个比方:

class Bike {
Bike () {
frontWheel = makeWheel();
rearWheel = makeWheel();
}
Wheel makeWheel();
}

class TitaniumBike {
Wheel makeWheel() {
return new TitaniumAllyWheel();
};
}

这样子类就可以正确产生一辆拥有钛合金狗眼(划掉)轮子的自行车。这里不讨论此种设计模式的优劣,只是举个例子。我本人反正是不会这么写。
geelaw
2022-11-18 00:11:40 +08:00
@mind3x #72

> 当然实际上 C++编译器不会这么没效率,就把构造函数里调用的函数当作非虚函数在编译期直接 resolve 完事。

很多时候不能这样做,因为构造函数、析构函数可以调用其他成员函数或者把 this 传入其他地方,在其他成员函数里或者通过复制的 this 调用虚函数必须仍然得到正在被构造的类的版本,而且对 this 所指向的对象用 typeid 也必须得到正在被构造的类。安全的做法是反复改变虚函数表指针。
mind3x
2022-11-18 00:18:18 +08:00
@geelaw 多谢指出,确实不写 C++好多年了。
qwertyegg
2022-11-18 04:31:49 +08:00
爪哇 101:多态性
mortalbibo
2022-11-18 07:57:22 +08:00
java 里称之为动态绑定机制...
duanguyuan
2022-11-18 09:12:41 +08:00
10 楼回答清楚明了,op 说这是打虚空打拳?

退一步讲,先不论别人回答对不对,别人花时间回答你的问题(从语气来看,并未讥讽、引战),你是给钱了还是怎么的,对别人就这么大脾气……
movq
2022-11-18 09:21:17 +08:00
@duanguyuan 我有啥脾气呢?你再看看我问的是什么,然后再看看 10 楼?我说他没看懂我在说什么,是在陈述事实。我跟你这种争论,如果你看懂了题,根本就不该发生。所以我说虚空打拳也是事实——你跟我争论的东西根本就是因为你在和我讨论与本帖毫无关联的内容。
movq
2022-11-18 09:22:21 +08:00
@duanguyuan 真正清楚明了的内容,是本帖里面 mind3x 和 geelaw 的回答,而不是 10 楼这种根本没看懂题还被同样没看懂题的人强行说好的回答。
movq
2022-11-18 09:25:05 +08:00
@duanguyuan 一群看不懂题的人不要在这虚空打拳和我争论浪费大家的时间,浪费版面

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://tanronggui.xyz/t/895919

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX