V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
movq
V2EX  ›  程序员

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

  •  
  •   movq · 2022-11-17 13:45:05 +08:00 · 8893 次点击
    这是一个创建于 523 天前的主题,其中的信息可能已经有所发展或是发生改变。
       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 给调用到子类了呢?这样难道不是一种反直觉的结果吗?

    第 1 条附言  ·  2022-11-17 16:43:45 +08:00
    我说的和 C++的比较是这样的:
    ```cpp

    class Parent {

    public:
    Parent() {
    print();
    }

    void virtual print() {
    std::cout << "parent" << std::endl;
    }
    };

    class Child : public Parent {

    public:
    Child() {
    print();
    }

    void print() {
    std::cout << "child" << std::endl;
    }
    };

    int main() {
    Parent *p = new Child();
    p->print();
    return 0;
    }
    ```

    这段代码里面,初始化父类的时候,调用的 print 是父类的,而不是子类的,所以输出结果为

    parent
    child
    child
    第 2 条附言  ·  2022-11-17 19:21:39 +08:00
    不懂为啥有这么多人看不懂题目,比如#10 ,#14 ,#36 ,#41 你题目不看在这说什么呢,建议你们重开另一个帖子发,不要虚空打拳

    为啥有些很多人能看懂,比如#8 ,#23 ,#32 ,#47 等等,你看不懂呢?

    还有些人不知道是不是骨子里自卑,所以觉得我说一个我觉得 Java 里面不如 C++符合我直觉的点,就觉得我在秀 C++优越
    125 条回复    2022-11-21 16:29:04 +08:00
    1  2  
    Keen06
        101
    Keen06  
       2022-11-18 13:29:29 +08:00
    @geelaw 你好,你说的两种情况,当编译器直接将虚函数当作非虚函数解析时会出现什么问题呢?我没有想明白,希望解答一下,十分感谢
    geelaw
        102
    geelaw  
       2022-11-18 13:41:56 +08:00
    @Keen06 #101 问题在于“完事”(以及行文中暗示的编译器不会真的反复修改虚表指针)而不是在于“直接 resolve”,当然可以把构造、析构函数里直接在 this 上调用的虚函数绑定到当前类的版本,但是这样做并不总是可以省去修改虚表指针,否则很难处理通过 this 的副本(包括调用其他成员函数时的 this )调用的情况。
    fcten
        103
    fcten  
       2022-11-18 13:42:50 +08:00
    看了一下帖子,关于 op 的问题已经很好地回答了。主要的冲突在于 op 说 c++的行为要比 java 的行为更符合直觉。
    是否符合直觉是一个很主观的问题,但确实是编程语言设计时一个重要的参考标准之一,在降低学习成本,减少程序员犯错的几率方面非常重要。
    一种很正常的观点是,在这个问题上保持行为一致会更容易理解:如果一个方法被声明可以重写,那么就总是调用被重写的最终版本。从这个角度来说,c++引入了不一致,不符合直觉。
    但是,在父类构造函数中调用子类方法,此时子类的成员变量尚未初始化。程序员如果没有意识到这一点,就非常容易犯错。这里要 @Opportunity ,关于构造函数只是 inintializer 的观点是错误的,至少 java 这里不是。
    更好的方式是,构造函数只应该做构造函数的事情,它不应该调用子类的方法,因为子类的构造不归它管。它更不应该去执行与构造无关的初始化逻辑,这些逻辑应该被放在 inintializer 里。如果子类要干预父类的构造,应该在自己的构造函数里去做。因此,与父类构造函数相关的逻辑都不应该被子类重写。从这个角度来看,c++的行为反而更符合直觉。
    因此我代表个人投 c++一票。
    liuhan907
        104
    liuhan907  
       2022-11-18 13:49:31 +08:00   ❤️ 1
    @movq
    表现不同的原因是虽然 C++ 的 print 方法是 virtual 修饰的,但是 C++ 在执行继承层次的时候 vtable 是分步构造的。这导致在执行父类构造方法的时候 vtable 是指向父类的,因此 virtual 修饰的方法仍然是调用父类而不是子类。在父类构造完成后,执行子类构造方法的时候才会将 vtable 指向子类。
    而 Java 的类似功能的指针初始化会在构造方法执行前就把整个虚表指针设置完,因此父类调用遵循 virtual 修饰语义。
    其实我倒是认为 Java 的选择更好一点,因为没有违反 virtual 语义,而 C++ 的话其实是违反了 virtual 语义的:virtual 修饰的方法没有多态性。
    Java 里你可以通过用别的方法简单的在父类调用父类自己的方法,即使是 virtual 修饰的;反过来 C++ 里父类构造想要调用子类重写,我还真不知道什么简单的办法,都比较 hack 。比较一下我觉得 Java 的选择可能是大部分场景下的更优解。
    ( ps:但是 C# 才是 yyds ^_^
    happyabs
        105
    happyabs  
       2022-11-18 13:59:29 +08:00
    可以参考 On Java 8 (或 On Java 中文版:基础卷+进阶卷)中第九章多态的 构造器内部多态方法的行为:

    构造器内部多态方法的行为

    构造器调用的层次结构带来了一个困境。如果在构造器中调用了正在构造的对象的动态绑定方法,会发生什么呢?

    在普通的方法中,动态绑定的调用是在运行时解析的,因为对象不知道它属于方法所在的类还是类的派生类。

    如果在构造器中调用了动态绑定方法,就会用到那个方法的重写定义。然而,调用的结果难以预料因为被重写的方法在对象被完全构造出来之前已经被调用,这使得一些 bug 很隐蔽,难以发现。

    从概念上讲,构造器的工作就是创建对象(这并非是平常的工作)。在构造器内部,整个对象可能只是部分形成——只知道基类对象已经初始化。如果构造器只是构造对象过程中的一个步骤,且构造的对象所属的类是从构造器所属的类派生出的,那么派生部分在当前构造器被调用时还没有初始化。然而,一个动态绑定的方法调用向外深入到继承层次结构中,它可以调用派生类的方法。如果你在构造器中这么做,就可能调用一个方法,该方法操纵的成员可能还没有初始化——这肯定会带来灾难。
    happyabs
        106
    happyabs  
       2022-11-18 14:00:39 +08:00   ❤️ 1
    下面例子展示了这个问题:

    // polymorphism/PolyConstructors.java
    // Constructors and polymorphism
    // don't produce what you might expect
    class Glyph {
    void draw() {
    System.out.println("Glyph.draw()");
    }

    Glyph() {
    System.out.println("Glyph() before draw()");
    draw();
    System.out.println("Glyph() after draw()");
    }
    }

    class RoundGlyph extends Glyph {
    private int radius = 1;

    RoundGlyph(int r) {
    radius = r;
    System.out.println("RoundGlyph.RoundGlyph(), radius = " + radius);
    }

    @Override
    void draw() {
    System.out.println("RoundGlyph.draw(), radius = " + radius);
    }
    }

    public class PolyConstructors {
    public static void main(String[] args) {
    new RoundGlyph(5);
    }
    }
    输出:

    Glyph() before draw()
    RoundGlyph.draw(), radius = 0
    Glyph() after draw()
    RoundGlyph.RoundGlyph(), radius = 5
    happyabs
        107
    happyabs  
       2022-11-18 14:01:21 +08:00   ❤️ 3
    Glyph 的 draw() 被设计为可重写,在 RoundGlyph 这个方法被重写。但是 Glyph 的构造器里调用了这个方法,结果调用了 RoundGlyph 的 draw() 方法,这看起来正是我们的目的。输出结果表明,当 Glyph 构造器调用了 draw() 时,radius 的值不是默认初始值 1 而是 0 。这可能会导致在屏幕上只画了一个点或干脆什么都不画,于是我们只能干瞪眼,试图找到程序不工作的原因。

    前一小节描述的初始化顺序并不十分完整,而这正是解决谜团的关键所在。初始化的实际过程是:

    在所有事发生前,分配给对象的存储空间会被初始化为二进制 0 。
    如前所述调用基类构造器。此时调用重写后的 draw() 方法(是的,在调用 RoundGraph 构造器之前调用),由步骤 1 可知,radius 的值为 0 。
    按声明顺序初始化成员。
    最终调用派生类的构造器。
    这么做有个优点:所有事物至少初始化为 0 (或某些特殊数据类型与 0 等价的值),而不是仅仅留作垃圾。这包括了通过组合嵌入类中的对象引用,被赋予 null 。如果忘记初始化该引用,就会在运行时出现异常。观察输出结果,就会发现所有事物都是 0 。

    另一方面,应该震惊于输出结果。逻辑方面我们已经做得非常完美,然而行为仍不可思议的错了,编译器也没有报错( C++ 在这种情况下会产生更加合理的行为)。像这样的 bug 很容易被忽略,需要花很长时间才能发现。

    因此,编写构造器有一条良好规范:做尽量少的事让对象进入良好状态。如果有可能的话,尽量不要调用类中的任何方法。在基类的构造器中能安全调用的只有基类的 final 方法(这也适用于可被看作是 final 的 private 方法)。这些方法不能被重写,因此不会产生意想不到的结果。你可能无法永远遵循这条规范,但应该朝着它努力。
    QingchuanZhang
        108
    QingchuanZhang  
       2022-11-18 14:04:04 +08:00
    @movq https://stackoverflow.com/a/962148 "Calling virtual functions from a constructor or destructor is dangerous and should be avoided whenever possible."
    TWorldIsNButThis
        109
    TWorldIsNButThis  
       2022-11-18 14:14:38 +08:00 via iPhone
    @happyabs
    所以 kotlin 里如果属性的初始化用到了可被覆写的其他属性,编译器会给出警告
    GuuJiang
        110
    GuuJiang  
       2022-11-18 14:25:14 +08:00   ❤️ 1
    作为没仔细看就强答的一员,首先给 op 道歉
    以下从旁观者角度总结一下来龙去脉,并非给自己辩解
    这个问题之所以会引起这么大的争议,其中一个原因是在 5L 时给出了一段极具误导性的示例代码
    主题想表达的是“C++和 Java 在 **构造方法** 中调用 **virtual** 方法时的行为不同”,而 5L 给出的示例代码却不是 virtual 的,偏偏“C++中需要显式声明 virtual ,而 Java 中不存在 non virtual”这个知识非常地深入人心,几乎成了一个所有 C++/Java 双修的程序员必然会曾经遇到过的月经问题,所以这个贴里的绝大多数人(包括我自己在内)没有仔细看就第一时间想着“果然又是这个问题”,而忽略了“从构造方法调用”这个前提,事实上 5L 的示例代码并不能用来验证主题中本来想问的那个问题,因为首先这段代码中存在非 virtual 方法的重写(更准确地说应该是隐藏),而 Java 中并不存在等价的代码,其次 C++中的非 virtual 方法在从非构造方法中调用时得到的结果也和主题描述中的一致,这两点共同作用进一步加深了第一眼看到这段示例代码的人对于“这个差异是由 virtual/non virtual 造成的”这一印象,而“从 constructor/非 constructor 调用”这一差异几乎完全被隐藏了,事实上如果开始在 5L 给出的代码就是调用 virtual 方法,那么更多的人就会把注意力放到“是否从 constructor 调用”上来,楼也不至于歪得那么厉害了
    season8
        111
    season8  
       2022-11-18 17:30:49 +08:00   ❤️ 1
    Q:为什么会这样调用? A:故意设计成这样。
    Q:为什么设计成这样? A:java 是面向对象的。
    Q:面向对象就能这样? A:是的,你以为方法是类的方法,但不是,方法是对象的方法,所以才有 override 。

    你构造的就是 Child 对象,默认调用的 super()并没有创建一个 Parent 对象,super 里面调用的 print 是 this.print() ,这个 this 是 Child 对象,不是 Parent 对象。
    cybird
        112
    cybird  
       2022-11-19 00:23:44 +08:00
    类似 C++虚函数
    msg7086
        113
    msg7086  
       2022-11-19 03:06:26 +08:00 via Android
    @newmlp #83 一般可以在父类加一个不能被覆盖的方法来实现。
    wenzhoou
        114
    wenzhoou  
       2022-11-19 13:38:37 +08:00 via Android
    我是 10 楼。针对你提问里头说的:这样难道不是一种反直觉的结果吗?的这句疑问回的帖子。

    同时也是告诉给看帖子的人,也许有新人,告诉他们,这样不违反直觉。反过来来说,这样很顺应直觉。

    如果你不喜欢我的回答,可以屏蔽。你开心就好。

    我也写过 1 年多 c++,也知道 Java 里所以函数都是相当于 c++里面的虚函数,这些 8 楼说的已经很具体了。我是针对 8 楼最后一句话做了一个补充。不知道哪里不合您老的意思了?所以您要说我看不懂题目。我那一句哈否认了 8 楼说的的东西了吗?

    就上面这个问题向 op 求教。
    wenzhoou
        115
    wenzhoou  
       2022-11-19 13:53:34 +08:00 via Android
    修改一下,我哪一句话否认了 8 楼说的的东西了吗?
    wenzhoou
        116
    wenzhoou  
       2022-11-19 14:00:59 +08:00 via Android
    另外指出 23 楼一个错误。Java 默认不是 public ,Java 默认是 package private 。
    wenzhoou
        117
    wenzhoou  
       2022-11-19 14:29:40 +08:00 via Android
    to op
    还有看到你 85 楼的回复,我不太明白,想要求问一下,
    1. 父类是静态类型,子类动态类型 这句话是什么意思。java 里面有静态类型和动态类型吗,恕我愚钝,我还真不懂这个区别。然后你是从哪里判断出来父类是静态类型,子类是动态类型呢?
    2. 你说的 调用静态类型被重写的方法,可以调到子类里被重写的方法。这句话我就更不理解了,什么叫做 子类明明是重写了父类的方法。为什么说 可以调到子类 ***被*** 重写的方法?这个被 字是不是多余。
    3. 不懂这个区别的人可以闭嘴。我可以闭嘴,但是你能不能给我个文献让我了解一下你所说的 我 10 楼回复是在说 什么什么 的这句话,到底是什么意思。不然的话,我就不知道你是 不懂我说的,还是懂了我说的,但是觉得对你没有帮助,亦或是,你把我说的理解成了另外一件事情,所以才认为和你问的问题没有关系。
    wenzhoou
        118
    wenzhoou  
       2022-11-19 15:23:14 +08:00 via Android
    如果你非要说构造函数的事情。那我把 10 楼的回答稍微改一下,你看有区别吗?

    class 人
    method 人的构造函数
    做自我介绍

    method 自我介绍
    说:我叫 xxx

    class 哑巴 extends 人
    method 自我介绍
    说:阿巴阿巴

    人 张三 = new 哑巴()

    所以我认为对于 Java 来说,关键不在于是不是构造函数调用的,关键在于 Java 编译的时候自动把他编译成了 invokeVirtual 。
    你学 Java 的时候,有哪本书告诉你,Java 的方法分为被构造函数调用的,和没有被构造函数调用的这两种吗?

    而对于 c++来说,你必须去在意他是不是构造函数调用的,那是 c++的问题。c++里面已经明确解释了为什么会出现这个问题。c++ FAQ lite 23.7 (也就是说我明明在构造函数里面调用了一个被声明为 virtual 的方法,为什么实际上却无法调用出来 virtual 方法,这不是很明显有问题吗?)
    movq
        119
    movq  
    OP
       2022-11-19 17:45:40 +08:00
    @wenzhoou #117
    “父类是静态类型,子类动态类型 这句话是什么意思。java 里面有静态类型和动态类型吗,恕我愚钝,我还真不懂这个区别”

    《深入理解 JVM 虚拟机》 8.3.2 分派

    `Human man = new Man();`

    > 我们把上面代码中的“Human”称为变量的“静态类型”( Static Type ),或者叫“外观类型”( Apparent Type ),后面的“Man”则被称为变量的“实际类型”( ActualType )或者叫“运行时类型”( Runtime Type )

    "你说的 调用静态类型被重写的方法,可以调到子类里被重写的方法。这句话我就更不理解了,什么叫做 子类明明是重写了父类的方法。为什么说 可以调到子类 ***被*** 重写的方法?这个被 字是不是多余。"

    你在这抠这个字眼有啥用呢?不是一般人,不会误解这句话的意思吧。
    movq
        120
    movq  
    OP
       2022-11-19 17:48:26 +08:00
    @wenzhoou #118

    那你在发 10 楼的时候和构造函数提了一嘴吗,你说的不就是 Java 调用静态类型的方法,会调用到动态类型(运行时类型)里面重写的方法

    我问的是构造函数里面为什么调用函数调用到子类重写的方法了,重点是构造函数,然后也说了 C++和 Java 的区别

    然后你在这解释什么是运行时多态,这不和主题无关吗。C++难道不是运行时多态?
    movq
        121
    movq  
    OP
       2022-11-19 17:50:05 +08:00
    @wenzhoou 你现在在这里补充的内容,和题目相关,但是你在 10 楼本身的内容,是和题目无关的。我不是针对你,你说的也没错,但是有些人逼逼赖赖的说什么让我多看 10 楼的内容几遍,意思我不懂运行时多态,有啥意思呢
    Keen06
        122
    Keen06  
       2022-11-19 17:55:38 +08:00
    @geelaw 谢谢回复,我设想了一下在虚函数中调用另一个虚函数的场景,感觉可能修改虚表指针相比于继续将该虚函数解析为非虚函数更简单,修改虚表指针只需要在构造函数调用的第一个虚函数那里修改即可,后面自然可以调用合适的虚函数。再次感谢!
    zsdroid
        123
    zsdroid  
       2022-11-19 19:55:47 +08:00
    op 写的太高深了,让小白(包括我)看不出问题在哪。
    改造了一下应该可以看出问题了:
    ```java
    /**
    输出:
    Parent!
    Child.print
    Child!
    */
    class Main {
    public static void main(String[] args) {
    class Parent {
    public Parent() {
    System.out.println("Parent!");
    print();
    }
    public void print() {
    System.out.println("Parent.print");
    }
    }

    class Child extends Parent {
    public Child() {
    System.out.println("Child!");
    }
    public void print() {
    System.out.println("Child.print");
    }
    }
    Parent parent = new Child();

    }
    }
    ```
    wenzhoou
        124
    wenzhoou  
       2022-11-20 11:23:57 +08:00
    TO OP ,可以可以,我们不扣字眼。你开心就行。
    恰好我也是 20 多年 java 经验,jvm 也深入研究过,对于小白问题,我回答不回答,我又不损失什么。
    我也不是为了别人点赞才回复的。
    你是我的第一个 block ,以后你的问题我也不会回答了。谢谢。
    book1925
        125
    book1925  
       2022-11-21 16:29:04 +08:00
    @mind3x
    学习了
    1  2  
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   4020 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 28ms · UTC 10:19 · PVG 18:19 · LAX 03:19 · JFK 06:19
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.