《Java8實戰》-第九章筆記(預設方法)

雷俠發表於2019-02-17

預設方法

傳統上,Java程式的介面是將相關方法按照約定組合到一起的方式。實現介面的類必須為介面中定義的每個方法提供一個實現,或者從父類中繼承它的實現。但是,一旦類庫的設計者需要更新介面,向其中加入新的方法,這種方式就會出現問題。現實情況是,現存的實體類往往不在介面設計者的控制範圍之內,這些實體類為了適配新的介面約定也需要進行修改。由於Java 8的API在現存的介面上引入了非常多的新方法,這種變化帶來的問題也愈加嚴重,一個例子就是前幾章中使用過的 List 介面上的 sort 方法。想象一下其他備選集合框架的維護人員會多麼抓狂吧,像Guava和Apache Commons這樣的框架現在都需要修改實現了 List 介面的所有類,為其新增sort 方法的實現。

且慢,其實你不必驚慌。Java 8為了解決這一問題引入了一種新的機制。Java 8中的介面現在支援在宣告方法的同時提供實現,這聽起來讓人驚訝!通過兩種方式可以完成這種操作。其一,Java 8允許在介面內宣告靜態方法。其二,Java 8引入了一個新功能,叫預設方法,通過預設方法你可以指定介面方法的預設實現。換句話說,介面能提供方法的具體實現。因此,實現介面的類如果不顯式地提供該方法的具體實現,就會自動繼承預設的實現。這種機制可以使你平滑地進行介面的優化和演進。實際上,到目前為止你已經使用了多個預設方法。兩個例子就是你前面已經見過的 List 介面中的 sort ,以及 Collection 介面中的 stream 。

第1章中我們看到的 List 介面中的 sort 方法是Java 8中全新的方法,它的定義如下:

default void sort(Comparator<? super E> c){
    Collections.sort(this, c);
}
複製程式碼

請注意返回型別之前的新 default 修飾符。通過它,我們能夠知道一個方法是否為預設方法。這裡 sort 方法呼叫了 Collections.sort 方法進行排序操作。由於有了這個新的方法,我們現在可以直接通過呼叫 sort ,對列表中的元素進行排序。

List<Integer> numbers = Arrays.asList(3, 5, 1, 2, 6);
numbers.sort(Comparator.naturalOrder());
複製程式碼

不過除此之外, 這段程式碼中還有些其他的新東西。注意到了嗎,我們呼叫了Comparator.naturalOrder 方法。這是 Comparator 介面的一個全新的靜態方法,它返回一個Comparator 物件,並按自然序列對其中的元素進行排序(即標準的字母數字方式排序)。

第4章中你看到的 Collection 中的 stream 方法的定義如下:

default Stream<E> stream() {
    return StreamSupport.stream(spliterator(), false);
}
複製程式碼

我們在之前的幾章中大量使用了該方法來處理集合,這裡 stream 方法中呼叫了SteamSupport.stream 方法來返回一個流。你注意到 stream 方法的主體是如何呼叫 spliterator 方法的了嗎?它也是 Collection 介面的一個預設方法。

喔噢!這些介面現在看起來像抽象類了吧?是,也不是。它們有一些本質的區別,我們在這一章中會針對性地進行討論。但更重要的是,你為什麼要在乎預設方法?預設方法的主要目標使用者是類庫的設計者啊。

簡而言之,向介面新增方法是諸多問題的罪惡之源;一旦介面發生變化,實現這些介面的類往往也需要更新,提供新添方法的實現才能適配介面的變化。如果你對介面以及它所有相關的實現有完全的控制,這可能不是個大問題。但是這種情況是極少的。這就是引入預設方法的目的:它讓類可以自動地繼承介面的一個預設實現。

因此,如果你是個類庫的設計者,這一章的內容對你而言會十分重要,因為預設方法為介面的演進提供了一種平滑的方式,你的改動將不會導致已有程式碼的修改。此外,正如我們後文會介紹的,預設方法為方法的多繼承提供了一種更靈活的機制,可以幫助你更好地規劃你的程式碼結構:類可以從多個介面繼承預設方法。因此,即使你並非類庫的設計者,也能在其中發現感興趣的東西。

章的結構如下。首先,我們會跟你一起剖析一個API演化的用例,探討由此引發的各種問題。緊接著我們會解釋什麼是預設方法,以及它們在這個用例中如何解決相應的問題。之後,我們會展示如何建立自己的預設方法,構造Java語言中的多繼承。最後,我們會討論一個類在使用一個簽名同時繼承多個預設方法時,Java編譯器是如何解決可能的二義性(模糊性)問題的。

不斷演進的 API

為了理解為什麼一旦API釋出之後,它的演進就變得非常困難,我們假設你是一個流行Java繪相簿的設計者(為了說明本節的內容,我們做了這樣的假想)。你的庫中包含了一個 Resizable介面,它定義了一個簡單的可縮放形狀必須支援的很多方法, 比如: setHeight 、 setWidth 、getHeight 、 getWidth 以及 setAbsoluteSize 。此外,你還提供了幾個額外的實現(out-of-boximplementation),如正方形、長方形。由於你的庫非常流行,你的一些使用者使用 Resizable 介面建立了他們自己感興趣的實現,比如橢圓。

釋出API幾個月之後,你突然意識到 Resizable 介面遺漏了一些功能。比如,如果介面提供一個 setRelativeSize 方法,可以接受引數實現對形狀的大小進行調整,那麼介面的易用性會更好。你會說這看起來很容易啊:為 Resizable 介面新增 setRelativeSize 方法,再更新 Square和 Rectangle 的實現就好了。不過,事情並非如此簡單!你要考慮已經使用了你介面的使用者,他們已經按照自身的需求實現了 Resizable 介面,他們該如何應對這樣的變更呢?非常不幸,你無法訪問,也無法改動他們實現了 Resizable 介面的類。這也是Java庫的設計者需要改進Java API時面對的問題。讓我們以一個具體的例項為例,深入探討修改一個已釋出介面的種種後果。

初始版本的 API

Resizable 介面的最初版本提供了下面這些方法:

public interface Drawable {
    void draw();
}

public interface Resizable extends Drawable {
    int getWidth();

    void setWidth(int width);

    int getHeight();

    void setHeight(int height);

    void setAbsoluteSize(int width, int height);
}
複製程式碼

使用者實現
你的一位鐵桿使用者根據自身的需求實現了 Resizable 介面,建立了 Ellipse 類:

public class Ellipse implements Resizable {
    ...
}
複製程式碼

他實現了一個處理各種 Resizable 形狀(包括 Ellipse )的遊戲:

public class Square implements Resizable {
    ...
}
public class Triangle implements Resizable {
    ...
}
public class Game {
    public static void main(String[] args) {
        List<Resizable> resizableShapes =
                Arrays.asList(new Square(), new Triangle(), new Ellipse());
        Utils.paint(resizableShapes);
    }
}
public class Utils {
    public static void paint(List<Resizable> list) {
        list.forEach(r -> {
            r.setAbsoluteSize(42, 42);
            r.draw();
        });
    }
}
複製程式碼

第二版 API

庫上線使用幾個月之後,你收到很多請求,要求你更新 Resizable 的實現,讓 Square Triangle 以及其他的形狀都能支援 setRelativeSize 方法。為了滿足這些新的需求,你釋出了第二版API。

public interface Resizable extends Drawable {
    int getWidth();

    void setWidth(int width);

    int getHeight();

    void setHeight(int height);

    void setAbsoluteSize(int width, int height);

    void setRelativeSize(int wFactor, int hFactor);
}
複製程式碼

使用者面臨的窘境
對 Resizable 介面的更新導致了一系列的問題。首先,介面現在要求它所有的實現類新增setRelativeSize 方法的實現。但是使用者最初實現的 Ellipse 類並未包含 setRelativeSize方法。向介面新增新方法是二進位制相容的,這意味著如果不重新編譯該類,即使不實現新的方法,現有類的實現依舊可以執行。不過,使用者可能修改他的遊戲,在他的 Utils.paint 方法中呼叫setRelativeSize 方法,因為 paint 方法接受一個 Resizable 物件列表作為引數。如果傳遞的是一個 Ellipse 物件,程式就會丟擲一個執行時錯誤,因為它並未實現 setRelativeSize 方法:

Exception in thread "main" java.lang.AbstractMethodError:lambdasinaction.chap9.Ellipse.setRelativeSize(II)V
複製程式碼

其次,如果使用者試圖重新編譯整個應用(包括 Ellipse 類),他會遭遇下面的編譯錯誤:

Error:(9, 8) java: xin.codeream.java8.chap9.Ellipse不是抽象的, 並且未覆蓋
xin.codeream.java8.chap9.Resizable中的抽象方法setRelativeSize(int,int)
複製程式碼

最後,更新已釋出API會導致後向相容性問題。這就是為什麼對現存API的演進,比如官方釋出的Java Collection API,會給使用者帶來麻煩。當然,還有其他方式能夠實現對API的改進,但是都不是明智的選擇。比如,你可以為你的API建立不同的釋出版本,同時維護老版本和新版本,但這是非常費時費力的,原因如下。其一,這增加了你作為類庫的設計者維護類庫的複雜度。其次,類庫的使用者不得不同時使用一套程式碼的兩個版本,而這會增大記憶體的消耗,延長程式的載入時間,因為這種方式下專案使用的類檔案數量更多了。

這就是預設方法試圖解決的問題。它讓類庫的設計者放心地改進應用程式介面,無需擔憂對遺留程式碼的影響,這是因為實現更新介面的類現在會自動繼承一個預設的方法實現。

概述預設方法

經過前述的介紹,我們已經瞭解了向已釋出的API新增方法,對現存程式碼實現會造成多大的損害。預設方法是Java 8中引入的一個新特性,希望能借此以相容的方式改進API。現在,介面包含的方法簽名在它的實現類中也可以不提供實現。那麼,誰來具體實現這些方法呢?實際上,缺失的方法實現會作為介面的一部分由實現類繼承(所以命名為預設實現),而無需由實現類提供。

那麼,我們該如何辨識哪些是預設方法呢?其實非常簡單。預設方法由 default 修飾符修飾,並像類中宣告的其他方法一樣包含方法體。比如,你可以像下面這樣在集合庫中定義一個名為Sized 的介面,在其中定義一個抽象方法 size ,以及一個預設方法 isEmpty :

public interface Sized {
    int size();

    default boolean isEmpty() {
        return size() == 0;
    }
}
複製程式碼

太棒了!這樣任何一個實現了 Sized 介面的類都會自動繼承 isEmpty 的實現。因此,向提供了預設實現的介面新增方法就不是原始碼相容的。

現在,我們回顧一下最初的例子,那個Java畫圖類庫和你的遊戲程式。具體來說,為了以相容的方式改進這個庫(即使用該庫的使用者不需要修改他們實現了 Resizable 的類),可以使用預設方法,提供 setRelativeSize 的預設實現:

default void setRelativeSize(int wFactor, int hFactor){
    setAbsoluteSize(getWidth() / wFactor, getHeight() / hFactor);
}
複製程式碼

由於介面現在可以提供帶實現的方法,是否這意味著Java已經在某種程度上實現了多繼承?如果實現類也實現了同樣的方法,這時會發生什麼情況?預設方法會被覆蓋嗎?現在暫時無需擔心這些,Java 8中已經定義了一些規則和機制來處理這些問題。

你可能已經猜到,預設方法在Java 8的API中已經大量地使用了。本章已經介紹過我們前一章中大量使用的 Collection 介面的 stream 方法就是預設方法。 List 介面的 sort 方法也是預設方法。第3章介紹的很多函式式介面,比如 Predicate 、 Function 以及 Comparator 也引入了新的預設方法,比如 Predicate.and 或者 Function.andThen (記住,函式式介面只包含一個抽象方法,預設方法是種非抽象方法)。

預設方法的使用模式

現在你已經瞭解了預設方法怎樣以相容的方式演進庫函式了。除了這種用例,還有其他場景也能利用這個新特性嗎?當然有,你可以建立自己的介面,併為其提供預設方法。這一節中,我們會介紹使用預設方法的兩種用例:可選方法和行為的多繼承。

可選方法

你很可能也碰到過這種情況,類實現了介面,不過卻刻意地將一些方法的實現留白。我們以Iterator 介面為例來說。 Iterator 介面定義了 hasNext 、 next ,還定義了 remove 方法。Java 8之前,由於使用者通常不會使用該方法, remove 方法常被忽略。因此,實現 Interator 介面的類通常會為 remove 方法放置一個空的實現,這些都是些毫無用處的模板程式碼。

採用預設方法之後,你可以為這種型別的方法提供一個預設的實現,這樣實體類就無需在自己的實現中顯式地提供一個空方法。比如,在Java 8中, Iterator 介面就為 remove 方法提供了一個預設實現,如下所示:

public interface Iterator<E> {
    ...
    default void remove() {
        throw new UnsupportedOperationException("remove");
    }
    ...
}
複製程式碼

通過這種方式,你可以減少無效的模板程式碼。實現 Iterator 介面的每一個類都不需要再宣告一個空的 remove 方法了,因為它現在已經有一個預設的實現。

行為的多繼承

預設方法讓之前無法想象的事兒以一種優雅的方式得以實現,即行為的多繼承。這是一種讓類從多個來源重用程式碼的能力。

Java的類只能繼承單一的類,但是一個類可以實現多介面。要確認也很簡單,下面是Java API中對 ArrayList 類的定義:

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
}
複製程式碼
  1. 型別的多繼承

這個例子中 ArrayList 繼承了一個類,實現了六個介面。因此 ArrayList 實際是七個型別的直接子類,分別是: AbstractList 、 List 、 RandomAccess 、 Cloneable 、 Serializable 、Iterable 和 Collection 。所以,在某種程度上,我們早就有了型別的多繼承。

由於Java 8中介面方法可以包含實現,類可以從多個介面中繼承它們的行為(即實現的程式碼)。讓我們從一個例子入手,看看如何充分利用這種能力來為我們服務。保持介面的精緻性和正交效能幫助你在現有的程式碼基上最大程度地實現程式碼複用和行為組合。

  1. 利用正交方法的精簡介面

假設你需要為你正在建立的遊戲定義多個具有不同特質的形狀。有的形狀需要調整大小,但是不需要有旋轉的功能;有的需要能旋轉和移動,但是不需要調整大小。這種情況下,你怎麼設計才能儘可能地重用程式碼?

你可以定義一個單獨的 Rotatable 介面,並提供兩個抽象方法 setRotationAngle 和getRotationAngle ,如下所示:

public interface Rotatable {
    int getRotationAngle();

    void setRotationAngle(int angleInDegrees);

    default void rotateBy(int angleInDegrees) {
        setRotationAngle((getRotationAngle() + angleInDegrees) % 360);
    }
}
複製程式碼

這種方式和模板設計模式有些相似,都是以其他方法需要實現的方法定義好框架演算法。

現在,實現了 Rotatable 的所有類都需要提供 setRotationAngle 和 getRotationAngle的實現,但與此同時它們也會天然地繼承 rotateBy 的預設實現。

類似地,你可以定義之前看到的兩個介面 Moveable 和 Resizable 。它們都包含了預設實現。下面是 Moveable 的程式碼:

public interface Moveable {
    int getX();

    void setX(int x);

    int getY();

    void setY(int y);

    default void moveHorizontally(int distance) {
        setX(getX() + distance);
    }

    default void moveVertically(int distance) {
        setY(getY() + distance);
    }
}
複製程式碼

下面是 Resizable 的程式碼:

public interface Resizable extends Drawable {
    int getWidth();

    void setWidth(int width);

    int getHeight();

    void setHeight(int height);

    void setAbsoluteSize(int width, int height);

    default void setRelativeSize(int wFactor, int hFactor){
        setAbsoluteSize(getWidth() / wFactor, getHeight() / hFactor);
    }
}
複製程式碼
  1. 組合介面

通過組合這些介面,你現在可以為你的遊戲建立不同的實體類。比如, Monster 可以移動、旋轉和縮放。

public class Monster implements Rotatable, Moveable, Resizable {
    ...
}
複製程式碼

Monster 類會自動繼承 Rotatable 、 Moveable 和 Resizable 介面的預設方法。這個例子中,Monster 繼承了 rotateBy 、 moveHorizontally 、 moveVertically 和 setRelativeSize 的實現。

你現在可以直接呼叫不同的方法:

Monster m = new Monster();
m.rotateBy(180);
m.moveVertically(10);
複製程式碼

像你的遊戲程式碼那樣使用預設實現來定義簡單的介面還有另一個好處。假設你需要修改moveVertically 的實現,讓它更高效地執行。你可以在 Moveable 介面內直接修改它的實現,所有實現該介面的類會自動繼承新的程式碼(這裡我們假設使用者並未定義自己的方法實現)。

通過前面的介紹,你已經瞭解了預設方法多種強大的使用模式。不過也可能還有一些疑惑:如果一個類同時實現了兩個介面,這兩個介面恰巧又提供了同樣的預設方法簽名,這時會發生什麼情況?類會選擇使用哪一個方法?這些問題,我們會在接下來的一節進行討論。

解決衝突的規則

我們知道Java語言中一個類只能繼承一個父類,但是一個類可以實現多個介面。隨著預設方法在Java 8中引入,有可能出現一個類繼承了多個方法而它們使用的卻是同樣的函式簽名。這種情況下,類會選擇使用哪一個函式?在實際情況中,像這樣的衝突可能極少發生,但是一旦發生這樣的狀況,必須要有一套規則來確定按照什麼樣的約定處理這些衝突。這一節中,我們會介紹Java編譯器如何解決這種潛在的衝突。我們試圖回答像“接下來的程式碼中,哪一個 hello 方法是被 C 類呼叫的”這樣的問題。注意,接下來的例子主要用於說明容易出問題的場景,並不表示這些場景在實際開發過程中會經常發生。

public interface A {
    default void hello() {
        System.out.println("Hello from A");
    }
}
public interface B extends A {
    default void hello() {
        System.out.println("Hello from B");
    }
}
public class C implements A, B {
    public static void main(String[] args) {
        // 猜猜列印的是什麼?
        new C().hello();
    }
}
複製程式碼

此外,你可能早就對C++語言中著名的菱形繼承問題有所瞭解,菱形繼承問題中一個類同時繼承了具有相同函式簽名的兩個方法。到底該選擇哪一個實現呢? Java 8也提供瞭解決這個問題的方案。請接著閱讀下面的內容。

解決問題的三條規則

如果一個類使用相同的函式簽名從多個地方(比如另一個類或介面)繼承了方法,通過三條規則可以進行判斷。

  1. 類中的方法優先順序最高。類或父類中宣告的方法的優先順序高於任何宣告為預設方法的優先順序。
  2. 如果無法依據第一條進行判斷,那麼子介面的優先順序更高:函式簽名相同時,優先選擇擁有最具體實現的預設方法的介面,即如果 B 繼承了 A ,那麼 B 就比 A 更加具體。
  3. 最後,如果還是無法判斷,繼承了多個介面的類必須通過顯式覆蓋和呼叫期望的方法,顯式地選擇使用哪一個預設方法的實現。

是的,就是這三條準則就是你需要知道的全部了!

執行結果

讓我們回顧一下開頭的例子,這個例子中 C 類同時實現了 B 介面和 A 介面,而這兩個介面恰巧又都定義了名為 hello 的預設方法。

編譯器會使用宣告的哪一個 hello 方法呢?其實上面的程式碼是編譯不通過的,按照規則(2),應該選擇的是提供了最具體實現的預設方法的介面。但,在C中不知道誰比誰更具體,所以需要顯示的指定呼叫哪個介面的方法:

public class C implements A, B {
    public static void main(String[] args) {
        new C().hello();
    }

    @Override
    public void hello() {
       A.super.hello();
    }

    OR

    @Override
    public void hello() {
       B.super.hello();
    }

    OR

    @Override
    public void hello() {
       System.out.println("Hello from C!");
    }
}
複製程式碼

比如:呼叫 A.super.Hello(),那麼列印的是 Hello form A!,呼叫 B.super.Hello() 那麼輸出的是 Hello from B!。

如果,你碰到類似的問題,以上的三條準則將可以幫助你解決這個問題!

小結

  1. Java 8中的介面可以通過預設方法和靜態方法提供方法的程式碼實現。
  2. 預設方法的開頭以關鍵字 default 修飾,方法體與常規的類方法相同。
  3. 向釋出的介面新增抽象方法不是原始碼相容的。
  4. 預設方法的出現能幫助庫的設計者以後向相容的方式演進API。
  5. 預設方法可以用於建立可選方法和行為的多繼承。
  6. 我們有辦法解決由於一個類從多個介面中繼承了擁有相同函式簽名的方法而導致的衝突。
  7. 類或者父類中宣告的方法的優先順序高於任何預設方法。如果前一條無法解決衝突,那就選擇同函式簽名的方法中實現得最具體的那個介面的方法。
  8. 兩個預設方法都同樣具體時,你需要在類中覆蓋該方法,顯式地選擇使用哪個介面中提供的預設方法。

程式碼

Github:chap9

Gitee:chap9

相關文章