• JAVA中几个常见的构造函数陷阱
  • 发布于 2个月前
  • 170 热度
    0 评论

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


对象属性间的平行依赖
属性的实例化和 构造函数执行顺序是有先后的,如果构造函数和属性实例之间存在依赖顺序,请小心!请看下面的例子。
public class Context{
    private A a;
    private B b;
}
属性 a 和 b我们认为他们是“平行的”。现在他们没有存在互相依赖
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 功能,烦人的空指针异常又出现了。


子类重写了注册的before和after方法,为了监听注册功能,在子类属性中维护了额外的数据结构。但是正是子类属性触发了空指针异常。原因是父类的构造方法中执行了子类重写后的方法,子类重写的方法中使用了自己的属性。因为这个属性还未初始化,所以出现了空指针!


解决思路

最简单的思路是将子类中的变量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由子类具体实现)模板设计虽然由父类定义了逻辑处理的流程,子类只是填空。但是父类的处理逻辑中包括了不可预测的子类实现。


模板方法要求父类充分考虑子类可能的具体实现,考虑哪种实现是正确的,符合要求的。所以模板的设计需要高度的抽象。之前已经谈到了,构造方法中尽量不要使用模板方法的设计。


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


用户评论