1. 程式人生 > >不要在構造方法中呼叫可覆寫的方法

不要在構造方法中呼叫可覆寫的方法

如題,這句話來自於《Java解惑》(《Effective java》同一作者)。

在步入正題前我們先來看看kotlin的兩個特性:

  • kotlin沒有引入受檢查的異常,設計這個特性一般認為是思考了Bruce Eckel的《Java是否需要受檢的異常?》,以及Anders Hejlsberg的觀點。
  • kotlin建立的類預設不可繼承,設計這個特性一般認為是思考了《Effective java》:要麼為了繼承而設計,要麼禁止繼承。

我在想kotlin能從語言級別就設計支援它認為是對的觀點,我為什麼不能設計一種機制支援我認為是對的觀點。下面來分析標題:不要在構造方法中呼叫可覆寫的方法

  • 可覆寫的方法,即指主題是可繼承的基類,如果不可繼承,談不上可覆寫;
  • 可覆寫的方法,即指非static,非private的方法;
  • 直接呼叫或間接呼叫都不行;

那這麼做有什麼問題呢?問題是不要指望定義一個不可變欄位並在你呼叫的那個可覆寫的方法裡“搭船”
首先,如果一個欄位能定義為不可變欄位,那就應該這麼做,這樣更有助於減少閱讀程式碼和除錯程式碼的工作量,也減少了多執行緒中狀態。
其次,一個放在構造方法中的可覆寫方法多半是需要強制保證執行的(設計模式中的)模板方法,是應該遵守以統一行為的。
換句話說,一旦你在構造方法中呼叫了可覆寫的方法將限制你子類的能力。

書中舉得例子如下:

class Point {
    protected final int x, y;
    private final String name; // Cached at construction time
    Point(int x, int y) {
        this.x = x;
        this.y = y;
        name = makeName(); // 3. Invoke subclass method
    }
    protected String makeName() {
        return "[" + x + ","
+ y + "]"; } public final String toString() { return name; } } public class ColorPoint extends Point { private final String color; ColorPoint(int x, int y, String color) { super(x, y); // 2. Chain to Point constructor this.color = color; // 5. Initialize blank final-Too late } protected String makeName() { // 4. Executes before subclass constructor body! return super.makeName() + ":" + color; } public static void main(String[] args) { // 1. Invoke subclass constructor System.out.println(new ColorPoint(4, 2, "purple")); } }

此示例列印 [4,2]:null

解決方案:
書中給出的建議是惰性初始化,即在真正使用的時候初始化,把可覆寫的方法踢出去。這不失為一種好的方案。
但是有的時候我們希望規定一套規則以統一行為,比如下面這樣:

public ViewA() {
    initComponents();
    initDefaults();
    initListeners();
    initKeyboardActions();
}

再或者:

public ViewB() {
    initInflater();
    findViewByIds();
    setListeners();
    loadCustomTheme();
    fillDataCached();
}

這樣的情況都是初始化的情況。我想到有兩種思路。
第一種是使用Builder模式,將模板方法各個階段都包裝起來,但是要想避免繞過Builder的話,一般需把構造器設定成私有比較好,所以pass。
第二種是定一個Resource介面,裡面定義一個initialize方法,讓基類實現這個介面,將構造方法中的這些模板方法放到initialize方法裡,然後保證建立物件例項的時候,呼叫initialize方法,以保證初始化完整。
對於第二種情況,如何保證建立物件例項的時候一定呼叫init方法呢?
一開始我想的是提供工廠方法,工廠方法裡完成new物件並呼叫init的動作,但是問題來了,也許使用者繞過工廠直接建立物件呢?因為構造器是非私有的,於是我又在構造器上加了文件,但是,你放心嗎?我是不放心。所以我又寫了一個idea外掛來做強制檢查,你可以從這裡看到我的努力:(載入的有點慢)
https://johnlee175.github.io/json2pojo/

後來我想,我都用了idea外掛了,那麼用介面有點侵入,所以又提供了@ManualInit和@InitMethod的註解,將基類和初始化方法標註起來,我用idea外掛幫你檢查物件例項化時是否呼叫了初始化方法,並結合工廠方法幫你提煉程式碼。

再後來遇到了一個坎,對於java來說這種沒問題,對於android中自定義的基view來說有問題。因為android的view例項化是LayoutInflater經過反射幫你做的,我作為idea的外掛無法獲取到new表示式那個節點,這就使的是否呼叫以及何時呼叫初始化方法變得不可控。

android中常見的把控初始化的思路,比如mPrivateFlags,在未初始化前某個標誌位未置位,初始化後檢查這個標誌位是否置位,如果沒有就丟擲異常。類似的做法可以參看Activity的onCreate如何保證super.onCreate被呼叫(在ActivityThread.java中),而且android也提供了@CallSuper註解。

可是這些思路有可能無法用在此處,首先你可能希望在執行期以前確認問題的存在(執行期能否及時發現問題取決於運氣,有可能半個月以後那個地方被調到),其次是你在什麼地方進行這個檢查,可能時機和你想的不吻合。

我們怎麼能解決這個問題呢?有個比較好的辦法。見下面:

class A {
    public A() {
        // init A fields here
        // at last
        if (this.getClass().equals(A.class)) {
            initialize();
        }
    }
}
class B extends A {
    public B() {
        super();
        // init B fields here
        // at last
        if (this.getClass().equals(B.class)) {
            initialize();
        }
    }
}

只要繼承體系的每個非this()系的構造方法都在尾部新增一個class比較,並依據比較結果來呼叫初始化方法即可,然後在初始化方法中做模板方法的規定和分發。
這樣的當建立B的時候,呼叫順序一定是A的構造->B的構造->B的initalize方法,這樣的順序執行。如果B覆寫了initalize,為了保持順序一致性,需要呼叫super.initialize(可以使用@CallSuper註解)。

但是這一行比較程式碼如何新增進去呢?gradle-plugin中通過transform api進行注入程式碼的方式無疑是最理想的,它能保證強制執行,不會造成遺忘,而又對使用者透明,不會造成反感,但是這樣也會拖慢編譯打包時間,尤其專案大了,推動工作比較困難。

另一種方法是使用idea外掛做inspection和intention,如果開發者沒寫那個比較則inspection報錯,intention也可以一鍵生成那一行程式碼。美中不足的是,這個很容易被破壞,比如我故意壓制報錯,去掉那行程式碼等。不過目前看這個推動起來相對gradle-plugin容易一些。
目前只能想想這方面的事,我只能說要建一套機制,保證好的開發實踐方式減少不必要的限制。