聊聊Java构造函数的几个“陷阱”

2023年 10月 15日 41.9k 0

Java属性的实例化、构造函数的执行是有先后顺序的。 此外 当出现子类继承情况时,子类和父类也是有初始化顺序的,这让情况更加复杂! 下面我们重点看几个常见的构造函数陷阱!

对象属性间的平行依赖

属性的实例化和 构造函数执行顺序是有先后的,如果构造函数和属性实例之间存在依赖顺序,请小心!请看下面的例子。

public class Context{
    private A a;
    private B b;
}

属性 ab我们认为他们是“平行的”。现在他们没有存在互相依赖

Public class Context{
    Private A a = new A();
    Private B b = new B(a);
}

现在呢,可以认为 b 平行依赖于 a 。这就是对象属性间的平行依赖。当前这种情形是没有问题的。下面的场景有问题!

Public class Context{
    Private A a;
    Private B b = new B(a); //此时B如果实例化,那么a为null
    Public Context(A a){
        This.a = a;
    }
}

错误出现了,之所以B接受了一个null值,是因为属性 b 的实例化要优先于构造方法的。至于NullPointerException什么时候触发,没人能预测。如果是在B构造方法中触发空指针异常,你可能会恍然大悟,”原来传入了一个null值“。但是如果是在B的常规方法调用触发,你可能需要花点时间来排查空指针异常的原因了。

记住:实例属性的实例代码块要优于构造方法执行。

建议:不要依赖Java的对象初始化顺序,尽量将属性初始化放到构造方法中。

有些人喜欢在声明属性的同时进行初始化,并且还设成了final常量。这样的编码方式让人感到舒适,代码的可读性也相对较高。然而,当代码变得复杂,存在"对象属性的平行依赖"的情况时,就需要小心了!

对于这一点,C++的规范做得很好,属性声明时不能进行初始化,只能在构造函数中进行初始化。所以我们经常看到C++中的构造函数非常冗长,只做了简单的赋值操作。

构造函数陷阱

“构造函数陷阱”:构造方法中调用可被重写的方法

Public class A{
    Public A(){
        ....//初始化操作
        function();
    }

如果A的子类重写了function方法,那么A类构造方法执行的就是其子类的function实现,如果A类设计时没有考虑到这种情况,那么A的初始化就存在很大风险。所以要将 function 设为 private,或 final

建议:构造方法内不要使用public方法,如果必须要使用,则注意:子类可能会重写该方法,进而影响父类的初始化过程。

建议将存在子类重写需求的逻辑抽象出一个方法,设置为 protected 或者 public 方法设置为 final。避免子类重写 public 方法。提供给子类覆盖的方法设置为 protected,更加清晰。

另外记住 private 方法中 调用 public 方法也要想到子类可能会重写这个 public 方法~

这就是为什么不推荐使用继承的原因!子类重写父类方法 风险是很高的事情。

下面还会讲解构造方法中 执行 public 方法有多坑。

构造函数与重写带来的空指针异常

Public class Context{
    Public Context(){//构造方法
        ....
        Register();
        ....
    }

    Public void register(){
        beforeRegister();
        .....
        afterRegister();
    }
    
    Protected void beforeRegister(){
    }
    
    Protected void afterRegister(){
    }
}

现在 Context 需要向外提供注册功能.但是实例化时,需要先注册一些服务。注册操作前后会 执行 before 方法和 after 方法,子类可以重写,向提供扩展注册功能

当子类重写了 before,after 功能,烦人的空指针异常又出现了。

子类重写了注册的beforeafter方法,为了监听注册功能,在子类属性中维护了额外的数据结构。但是正是子类属性触发了空指针异常。

原因是父类的构造方法中执行了子类重写后的方法,子类重写的方法中使用了自己的属性。因为这个属性还未初始化,所以出现了空指针!

解决思路

最简单的思路是将子类中的变量map声明为static,这就先于父类构造方法执行。这的确能解决这个bug,但引入新的bug。

static变量属于整个类。不单单属于一个对象。那意味着所有的对象实例都共用这个static map, 这不是正确的逻辑。并且static map 在该进行垃圾回收时无法被回收。没准哪个时刻出现了 Out of Memory 内存溢出,服务器宕机,然后查到了这个root 根节点大对象正是static map.导致不可挽回的但本可避免的过失

另一个有争议的实现方法

父类构造方法中 先调用beforeInitialize,同时 beforeInitialize()方法供子类重写,这时子类就可以把属性初始化需求放到 beforeInitialize()方法中。实现了父类对子类的依赖,实现子类属性 先于 父类初始化,但是这么做倒转了依赖,破坏了子类,父类初始化顺序。

此外我们还可以在 beforeRegister 中 判断属性变量是否为 null,如果为 null 就初始化它。这个方法也不是很优雅

还有好多方法...但是归根结底,我们是在构造方法中调用可重写方法倒置了子类和父类的依赖,让父类依赖于子类,这与 Java面向对象的设计理念相冲突,才会出现这么多问题。

建议:尽量不要在构造方法中调用可以可被重写的方法。Public,protected方法尽可能少的出现在构造方法中

”构造函数陷阱“:构造方法中调用可被的重写方法

所以为什么有人一直强调,谨慎使用继承,优先使用委托。这就是原因!

父类对子类的依赖

面向对象的设计中,子类可以访问到父类方法,属性,但是父类无法访问子类属性,本应该是子类依赖于父类。

但是 设计模式中模板方法 的思路带来了父类对子类的依赖。(模板方法:父类的方法中调用了方法A,但是方法A由子类具体实现)模板设计虽然由父类定义了逻辑处理的流程,子类只是填空。但是父类的处理逻辑中包括了不可预测的子类实现。

模板方法要求父类充分考虑子类可能的具体实现,考虑哪种实现是正确的,符合要求的。所以模板的设计需要高度的抽象。

之前已经谈到了,构造方法中尽量不要使用模板方法的设计。

总结

通过这几种场景的分析会发现,继承模式有太多潜在的初始化问题、方法重写问题,使用难度远高于委托模式。我建议大家,尽量使用委托模式,而不是继承模式。

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论