《Java8實戰》-第九章筆記(默認方法)

默認方法

傳統上,Java程序的接口是將相關方法按照約定組合到一塊兒的方式。實現接口的類必須爲接口中定義的每一個方法提供一個實現,或者從父類中繼承它的實現。可是,一旦類庫的設計者須要更新接口,向其中加入新的方法,這種方式就會出現問題。現實狀況是,現存的實體類每每不在接口設計者的控制範圍以內,這些實體類爲了適配新的接口約定也須要進行修改。因爲Java 8的API在現存的接口上引入了很是多的新方法,這種變化帶來的問題也越發嚴重,一個例子就是前幾章中使用過的 List 接口上的 sort 方法。想象一下其餘備選集合框架的維護人員會多麼抓狂吧,像Guava和Apache Commons這樣的框架如今都須要修改實現了 List 接口的全部類,爲其添加sort 方法的實現。java

且慢,其實你沒必要驚慌。Java 8爲了解決這一問題引入了一種新的機制。Java 8中的接口如今支持在聲明方法的同時提供實現,這聽起來讓人驚訝!經過兩種方式能夠完成這種操做。其一,Java 8容許在接口內聲明靜態方法。其二,Java 8引入了一個新功能,叫默認方法,經過默認方法你能夠指定接口方法的默認實現。換句話說,接口能提供方法的具體實現。所以,實現接口的類若是不顯式地提供該方法的具體實現,就會自動繼承默認的實現。這種機制可使你平滑地進行接口的優化和演進。實際上,到目前爲止你已經使用了多個默認方法。兩個例子就是你前面已經見過的 List 接口中的 sort ,以及 Collection 接口中的 stream 。git

第1章中咱們看到的 List 接口中的 sort 方法是Java 8中全新的方法,它的定義以下:github

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 接口的一個默認方法。dom

喔噢!這些接口如今看起來像抽象類了吧?是,也不是。它們有一些本質的區別,咱們在這一章中會針對性地進行討論。但更重要的是,你爲何要在意默認方法?默認方法的主要目標用戶是類庫的設計者啊。ide

簡而言之,向接口添加方法是諸多問題的罪惡之源;一旦接口發生變化,實現這些接口的類每每也須要更新,提供新添方法的實現才能適配接口的變化。若是你對接口以及它全部相關的實現有徹底的控制,這可能不是個大問題。可是這種狀況是極少的。這就是引入默認方法的目的:它讓類能夠自動地繼承接口的一個默認實現。函數

所以,若是你是個類庫的設計者,這一章的內容對你而言會十分重要,由於默認方法爲接口的演進提供了一種平滑的方式,你的改動將不會致使已有代碼的修改。此外,正如咱們後文會介紹的,默認方法爲方法的多繼承提供了一種更靈活的機制,能夠幫助你更好地規劃你的代碼結構:類能夠從多個接口繼承默認方法。所以,即便你並不是類庫的設計者,也能在其中發現感興趣的東西。性能

章的結構以下。首先,咱們會跟你一塊兒剖析一個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

相關文章
相關標籤/搜索