Java属性的实例化、构造函数的执行是有先后顺序的。 此外 当出现子类继承情况时,子类和父类也是有初始化顺序的,这让情况更加复杂! 下面我们重点看几个常见的构造函数陷阱!
对象属性间的平行依赖
属性的实例化和 构造函数执行顺序是有先后的,如果构造函数和属性实例之间存在依赖顺序,请小心!请看下面的例子。
kotlin
public class Context{
private A a;
private B b;
}
属性 a
和 b
我们认为他们是"平行的"。现在他们没有存在互相依赖
css
Public class Context{
Private A a = new A();
Private B b = new B(a);
}
现在呢,可以认为 b
平行依赖于 a
。这就是对象属性间的平行依赖。当前这种情形是没有问题的。下面的场景有问题!
css
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++中的构造函数非常冗长,只做了简单的赋值操作。
构造函数陷阱
"构造函数陷阱":构造方法中调用可被重写的方法
javascript
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
方法有多坑。
构造函数与重写带来的空指针异常
scss
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由子类具体实现)模板设计虽然由父类定义了逻辑处理的流程,子类只是填空。但是父类的处理逻辑中包括了不可预测的子类实现。
模板方法要求父类充分考虑子类可能的具体实现,考虑哪种实现是正确的,符合要求的。所以模板的设计需要高度的抽象。
之前已经谈到了,构造方法中尽量不要使用模板方法的设计。
总结
通过这几种场景的分析会发现,继承模式有太多潜在的初始化问题、方法重写问题,使用难度远高于委托模式。我建议大家,尽量使用委托模式,而不是继承模式。