本章內容
什麼是默認方法
如何以一種兼容的方式改進API
默認方法的使用模式
解析規則
傳統上,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); ←─sort是List接口的默認方法
不過除此之外,這段代碼中還有些其他的新東西。注意到了嗎,我們調用了Comparator.naturalOrder
方法。這是Comparator
接口的一個全新的靜態方法,它返回一個Comparator
對象,並按自然序列對其中的元素進行排序(即標準的字母數字方式排序)。
第4章中你看到的Collection
中的stream
方法的定義如下:
default Stream<E> stream {
return StreamSupport.stream(spliterator, false);
}
我們在之前的幾章中大量使用了該方法來處理集合,這裡stream
方法中調用了SteamSupport.stream
方法來返回一個流。你注意到stream
方法的主體是如何調用spliterator
方法的了嗎?它也是Collection
接口的一個默認方法。
喔噢!這些接口現在看起來像抽像類了吧?是,也不是。它們有一些本質的區別,我們在這一章中會針對性地進行討論。但更重要的是,你為什麼要在乎默認方法?默認方法的主要目標用戶是類庫的設計者啊。正如我們後面所解釋的,默認方法的引入就是為了以兼容的方式解決像Java API這樣的類庫的演進問題的,如圖9-1所示。
圖 9-1 向接口添加方法
簡而言之,向接口添加方法是諸多問題的罪惡之源;一旦接口發生變化,實現這些接口的類往往也需要更新,提供新添方法的實現才能適配接口的變化。如果你對接口以及它所有相關的實現有完全的控制,這可能不是個大問題。但是這種情況是極少的。這就是引入默認方法的目的:它讓類可以自動地繼承接口的一個默認實現。
因此,如果你是個類庫的設計者,這一章的內容對你而言會十分重要,因為默認方法為接口的演進提供了一種平滑的方式,你的改動將不會導致已有代碼的修改。此外,正如我們後文會介紹的,默認方法為方法的多繼承提供了一種更靈活的機制,可以幫助你更好地規劃你的代碼結構:類可以從多個接口繼承默認方法。因此,即使你並非類庫的設計者,也能在其中發現感興趣的東西。
靜態方法及接口
同時定義接口以及工具輔助類(companion class)是Java語言常用的一種模式,工具類定義了與接口實例協作的很多靜態方法。比如,
Collections
就是處理Collection
對象的輔助類。由於靜態方法可以存在於接口內部,你代碼中的這些輔助類就沒有了存在的必要,你可以把這些靜態方法轉移到接口內部。為了保持後向的兼容性,這些類依然會存在於Java應用程序的接口之中。
本章的結構如下。首先,我們會跟你一起剖析一個API演化的用例,探討由此引發的各種問題。緊接著我們會解釋什麼是默認方法,以及它們在這個用例中如何解決相應的問題。之後,我們會展示如何創建自己的默認方法,構造Java語言中的多繼承。最後,我們會討論一個類在使用一個簽名同時繼承多個默認方法時,Java編譯器是如何解決可能的二義性(模糊性)問題的。
9.1 不斷演進的API
為了理解為什麼一旦API發佈之後,它的演進就變得非常困難,我們假設你是一個流行Java繪圖庫的設計者(為了說明本節的內容,我們做了這樣的假想)。你的庫中包含了一個Resizable
接口,它定義了一個簡單的可縮放形狀必須支持的很多方法, 比如:setHeight
、setWidth
、getHeight
、getWidth
以及setAbsoluteSize
。此外,你還提供了幾個額外的實現(out-of-box implementation),如正方形、長方形。由於你的庫非常流行,你的一些用戶使用Resizable
接口創建了他們自己感興趣的實現,比如橢圓。
發佈API幾個月之後,你突然意識到Resizable
接口遺漏了一些功能。比如,如果接口提供一個setRelativeSize
方法,可以接受參數實現對形狀的大小進行調整,那麼接口的易用性會更好。你會說這看起來很容易啊:為Resizable
接口添加setRelativeSize
方法,再更新Square
和Rectangle
的實現就好了。不過,事情並非如此簡單!你要考慮已經使用了你接口的用戶,他們已經按照自身的需求實現了Resizable
接口,他們該如何應對這樣的變更呢?非常不幸,你無法訪問,也無法改動他們實現了Resizable
接口的類。這也是Java庫的設計者需要改進Java API時面對的問題。讓我們以一個具體的實例為例,深入探討修改一個已發佈接口的種種後果。
9.1.1 初始版本的API
Resizable
接口的最初版本提供了下面這些方法:
public interface Resizable extends Drawable{
int getWidth;
int getHeight;
void setWidth(int width);
void setHeight(int height);
void setAbsoluteSize(int width, int height);
}
用戶實現
你的一位鐵桿用戶根據自身的需求實現了Resizable
接口,創建了Ellipse
類:
public class Ellipse implements Resizable {
…
}
他實現了一個處理各種Resizable
形狀(包括Ellipse
)的遊戲:
public class Game{
public static void main(String...args){
List<Resizable> resizableShapes =
Arrays.asList(new Square, new Rectangle, new Ellipse); ←─可以調整大小的形狀列表
Utils.paint(resizableShapes);
}
}
public class Utils{
public static void paint(List<Resizable> l){
l.forEach(r -> {
r.setAbsoluteSize(42, 42); ←─調用每個形狀自己的setAbsoluteSize方法
r.draw;
});
}
}
9.1.2 第二版API
庫上線使用幾個月之後,你收到很多請求,要求你更新Resizable
的實現,讓Square
、Rectangle
以及其他的形狀都能支持setRelativeSize
方法。為了滿足這些新的需求,你發佈了第二版API,具體如圖9-2所示。
public interface Resizable {
int getWidth;
int getHeight;
void setWidth(int width);
void setHeight(int height);
void setAbsoluteSize(int width, int height);
void setRelativeSize(int wFactor, int hFactor); ←─第二版API 添加了一個新方法
}
圖 9-2 為Resizable
接口添加新方法改進API。再次編譯應用時會遭遇錯誤,因為它依賴的Resizable
接口發生了變化
用戶面臨的窘境
對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
類),他會遭遇下面的編譯錯誤:
lambdasinaction/chap9/Ellipse.java:6: error: Ellipse is not abstract and does
not override abstract method setRelativeSize(int,int) in Resizable
最後,更新已發佈API會導致後向兼容性問題。這就是為什麼對現存API的演進,比如官方發佈的Java Collection API,會給用戶帶來麻煩。當然,還有其他方式能夠實現對API的改進,但是都不是明智的選擇。比如,你可以為你的API創建不同的發佈版本,同時維護老版本和新版本,但這是非常費時費力的,原因如下。其一,這增加了你作為類庫的設計者維護類庫的複雜度。其次,類庫的用戶不得不同時使用一套代碼的兩個版本,而這會增大內存的消耗,延長程序的載入時間,因為這種方式下項目使用的類文件數量更多了。
這就是默認方法試圖解決的問題。它讓類庫的設計者放心地改進應用程序接口,無需擔憂對遺留代碼的影響,這是因為實現更新接口的類現在會自動繼承一個默認的方法實現。
不同類型的兼容性:二進制、源代碼和函數行為
變更對Java程序的影響大體可以分成三種類型的兼容性,分別是:二進制級的兼容、源代碼級的兼容,以及函數行為的兼容。1剛才我們看到,向接口添加新方法是二進制級的兼容,但最終編譯實現接口的類時卻會發生編譯錯誤。瞭解不同類型兼容性的特性是非常有益的,下面我們會深入介紹這部分的內容。
二進制級的兼容性表示現有的二進制執行文件能無縫持續鏈接(包括驗證、準備和解析)和運行。比如,為接口添加一個方法就是二進制級的兼容,這種方式下,如果新添加的方法不被調用,接口已經實現的方法可以繼續運行,不會出現錯誤。
簡單地說,源代碼級的兼容性表示引入變化之後,現有的程序依然能成功編譯通過。比如,向接口添加新的方法就不是源碼級的兼容,因為遺留代碼並沒有實現新引入的方法,所以它們無法順利通過編譯。
最後,函數行為的兼容性表示變更發生之後,程序接受同樣的輸入能得到同樣的結果。比如,為接口添加新的方法就是函數行為兼容的,因為新添加的方法在程序中並未被調用(抑或該接口在實現中被覆蓋了)。
1參見https://blogs.oracle.com/darcy/entry/kinds_of_compatibility。
9.2 概述默認方法
經過前述的介紹,我們已經瞭解了向已發佈的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中已經定義了一些規則和機制來處理這些問題。詳細的內容,我們會在9.5節進行介紹。
你可能已經猜到,默認方法在Java 8的API中已經大量地使用了。本章已經介紹過我們前一章中大量使用的Collection
接口的stream
方法就是默認方法。List
接口的sort
方法也是默認方法。第3章介紹的很多函數式接口,比如Predicate
、Function
以及Comparator
也引入了新的默認方法,比如Predicate.and
或者Function.andThen
(記住,函數式接口只包含一個抽像方法,默認方法是種非抽像方法)。
Java 8中的抽像類和抽像接口
那麼抽像類和抽像接口之間的區別是什麼呢?它們不都能包含抽像方法和包含方法體的實現嗎?
首先,一個類只能繼承一個抽像類,但是一個類可以實現多個接口。
其次,一個抽像類可以通過實例變量(字段)保存一個通用狀態,而接口是不能有實例變量的。
請應用你掌握的默認方法的知識,回答一下測驗9.1的問題。
測驗9.1:
removeIf
這個測驗裡,假設你是Java語言和API的一個負責人。你收到了關於
removeIf
方法的很多請求,希望能為ArrayList
、TreeSet
、LinkedList
以及其他集合類型添加removeIf
方法。removeIf
方法的功能是刪除滿足給定謂詞的所有元素。你的任務是找到添加這個新方法、優化Collection API的最佳途徑。答案:改進Collection API破壞性最大的方式是什麼?你可以把
removeIf
的實現直接複製到Collection API的每個實體類中,但這種做法實際是在對Java界的犯罪。還有其他的方式嗎?你知道嗎,所有的Collection
類都實現了一個名為java.util.Collection
的接口。太好了,那麼我們可以在這裡添加一個方法?是的!你只需要牢記,默認方法是一種以源碼兼容方式向接口內添加實現的方法。這樣實現Collction
的所有類(包括並不隸屬Collection API的用戶擴展類)都能使用removeIf
的默認實現。removeIf
的代碼實現如下(它實際就是Java 8 Collection API的實現)。它是Collection
接口的一個默認方法:default boolean removeIf(Predicate<? super E> filter) { boolean removed = false; Iterator<E> each = iterator; while(each.hasNext) { if(filter.test(each.next)) { each.remove; removed = true; } } return removed; }
9.3 默認方法的使用模式
現在你已經瞭解了默認方法怎樣以兼容的方式演進庫函數了。除了這種用例,還有其他場景也能利用這個新特性嗎?當然有,你可以創建自己的接口,並為其提供默認方法。這一節中,我們會介紹使用默認方法的兩種用例:可選方法和行為的多繼承。
9.3.1 可選方法
你很可能也碰到過這種情況,類實現了接口,不過卻刻意地將一些方法的實現留白。我們以Iterator
接口為例來說。Iterator
接口定義了hasNext
、next
,還定義了remove
方法。Java 8之前,由於用戶通常不會使用該方法,remove
方法常被忽略。因此,實現Interator
接口的類通常會為remove
方法放置一個空的實現,這些都是些毫無用處的模板代碼。
採用默認方法之後,你可以為這種類型的方法提供一個默認的實現,這樣實體類就無需在自己的實現中顯式地提供一個空方法。比如,在Java 8中,Iterator
接口就為remove
方法提供了一個默認實現,如下所示:
interface Iterator<T> {
boolean hasNext;
T next;
default void remove {
throw new UnsupportedOperationException;
}
}
通過這種方式,你可以減少無效的模板代碼。實現Iterator
接口的每一個類都不需要再聲明一個空的remove
方法了,因為它現在已經有一個默認的實現。
9.3.2 行為的多繼承
默認方法讓之前無法想像的事兒以一種優雅的方式得以實現,即行為的多繼承。這是一種讓類從多個來源重用代碼的能力,如圖9-3所示。
圖 9-3 單繼承和多繼承的比較
Java的類只能繼承單一的類,但是一個類可以實現多接口。要確認也很簡單,下面是Java API中對ArrayList
類的定義:
public class ArrayList<E> extends AbstractList<E> ←─繼承唯一一個類
implements List<E>, RandomAccess, Cloneable,
Serializable, Iterable<E>, Collection<E> { ←─但是實現了六個接口
}
1. 類型的多繼承
這個例子中ArrayList
繼承了一個類,實現了六個接口。因此ArrayList
實際是七個類型的直接子類,分別是:AbstractList
、List
、RandomAccess
、Cloneable
、Serializable
、Iterable
和Collection
。所以,在某種程度上,我們早就有了類型的多繼承。
由於Java 8中接口方法可以包含實現,類可以從多個接口中繼承它們的行為(即實現的代碼)。讓我們從一個例子入手,看看如何充分利用這種能力來為我們服務。保持接口的精緻性和正交性能幫助你在現有的代碼基上最大程度地實現代碼復用和行為組合。
2. 利用正交方法的精簡接口
假設你需要為你正在創建的遊戲定義多個具有不同特質的形狀。有的形狀需要調整大小,但是不需要有旋轉的功能;有的需要能旋轉和移動,但是不需要調整大小。這種情況下,你怎麼設計才能盡可能地重用代碼?
你可以定義一個單獨的Rotatable
接口,並提供兩個抽像方法setRotationAngle
和getRotationAngle
,如下所示:
public interface Rotatable {
void setRotationAngle(int angleInDegrees);
int getRotationAngle;
default void rotateBy(int angleInDegrees){ ←─retateBy方法的一個默認實現
setRotationAngle((getRotationAngle + angle) % 360);
}
}
這種方式和模板設計模式有些相似,都是以其他方法需要實現的方法定義好框架算法。
現在,實現了Rotatable
的所有類都需要提供setRotationAngle
和getRotationAngle
的實現,但與此同時它們也會天然地繼承rotateBy
的默認實現。
類似地,你可以定義之前看到的兩個接口Moveable
和Resizable
。它們都包含了默認實現。下面是Moveable
的代碼:
public interface Moveable {
int getX;
int getY;
void setX(int x);
void setY(int y);
default void moveHorizontally(int distance){
setX(getX + distance);
}
default void moveVertically(int distance){
setY(getY + distance);
}
}
下面是Resizable
的代碼:
public interface Resizable {
int getWidth;
int getHeight;
void setWidth(int width);
void setHeight(int height);
void setAbsoluteSize(int width, int height);
default void setRelativeSize(int wFactor, int hFactor){
setAbsoluteSize(getWidth / wFactor, getHeight / hFactor);
}
}
3. 組合接口
通過組合這些接口,你現在可以為你的遊戲創建不同的實體類。比如,Monster
可以移動、旋轉和縮放。
public class Monster implements Rotatable, Moveable, Resizable {
… ←─需要給出所有抽像方法的實現,但無需重複實現默認方法
}
Monster
類會自動繼承Rotatable
、Moveable
和Resizable
接口的默認方法。這個例子中,Monster
繼承了rotateBy
、moveHorizontally
、moveVertically
和setRelativeSize
的實現。
你現在可以直接調用不同的方法:
Monster m = new Monster; ←─構造函數會設置Monster的坐標、高度、寬度及默認仰角
m.rotateBy(180); ←─調用由Rotatable 中繼承而來的rotateBy
m.moveVertically(10); ←─調用由Moveable中繼承而來的moveVertically
假設你現在需要聲明另一個類,它要能移動和旋轉,但是不能縮放,比如說Sun。這時也無需複製粘貼代碼,你可以像下面這樣復用Moveable
和Rotatable
接口的默認實現。圖9-4是這一場景的UML圖表。
public class Sun implements Moveable, Rotatable {
… ←─需要給出所有抽像方法的實現,但無需重複實現默認方法
}
圖 9-4 多種行為的組合
像你的遊戲代碼那樣使用默認實現來定義簡單的接口還有另一個好處。假設你需要修改moveVertically
的實現,讓它更高效地運行。你可以在Moveable
接口內直接修改它的實現,所有實現該接口的類會自動繼承新的代碼(這裡我們假設用戶並未定義自己的方法實現)。
關於繼承的一些錯誤觀點
繼承不應該成為你一談到代碼復用就試圖倚靠的萬精油。比如,從一個擁有100個方法及字段的類進行繼承就不是個好主意,因為這其實會引入不必要的複雜性。你完全可以使用代理有效地規避這種窘境,即創建一個方法通過該類的成員變量直接調用該類的方法。這就是為什麼有的時候我們發現有些類被刻意地聲明為
final
類型:聲明為final
的類不能被其他的類繼承,避免發生這樣的反模式,防止核心代碼的功能被污染。注意,有的時候聲明為final
的類都會有其不同的原因,比如,String
類被聲明為final
,因為我們不希望有人對這樣的核心功能產生干擾。這種思想同樣也適用於使用默認方法的接口。通過精簡的接口,你能獲得最有效的組合,因為你可以只選擇你需要的實現。
通過前面的介紹,你已經瞭解了默認方法多種強大的使用模式。不過也可能還有一些疑惑:如果一個類同時實現了兩個接口,這兩個接口恰巧又提供了同樣的默認方法簽名,這時會發生什麼情況?類會選擇使用哪一個方法?這些問題,我們會在接下來的一節進行討論。
9.4 解決衝突的規則
我們知道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 B, A {
public static void main(String... args) {
new C.hello; ←─猜猜打印輸出的是什麼?
}
}
此外,你可能早就對C++語言中著名的菱形繼承問題有所瞭解,菱形繼承問題中一個類同時繼承了具有相同函數簽名的兩個方法。到底該選擇哪一個實現呢? Java 8也提供了解決這個問題的方案。請接著閱讀下面的內容。
9.4.1 解決問題的三條規則
如果一個類使用相同的函數簽名從多個地方(比如另一個類或接口)繼承了方法,通過三條規則可以進行判斷。
(1) 類中的方法優先級最高。類或父類中聲明的方法的優先級高於任何聲明為默認方法的優先級。
(2) 如果無法依據第一條進行判斷,那麼子接口的優先級更高:函數簽名相同時,優先選擇擁有最具體實現的默認方法的接口,即如果B
繼承了A
,那麼B
就比A
更加具體。
(3) 最後,如果還是無法判斷,繼承了多個接口的類必須通過顯式覆蓋和調用期望的方法,顯式地選擇使用哪一個默認方法的實現。
我們保證,這些就是你需要知道的全部!讓我們一起看幾個例子。
9.4.2 選擇提供了最具體實現的默認方法的接口
讓我們回顧一下本節開頭的例子,這個例子中C
類同時實現了B
接口和A
接口,而這兩個接口恰巧又都定義了名為hello
的默認方法。另外,B
繼承自A
。圖9-5是這個場景的UML圖。
圖 9-5 提供最具體的默認方法實現的接口,其優先級更高
編譯器會使用聲明的哪一個hello
方法呢?按照規則(2),應該選擇的是提供了最具體實現的默認方法的接口。由於B
比A
更具體,所以應該選擇B
的hello
方法。所以,程序會打印輸出“Hello from B”。
現在,我們看看如果C
像下面這樣(如圖9-6所示)繼承自D
,會發生什麼情況:
public class D implements A{ }
public class C extends D implements B, A {
public static void main(String... args) {
new C.hello;
}
}
圖 9-6 繼承一個類,實現兩個接口的情況
依據規則(1),類中聲明的方法具有更高的優先級。D
並未覆蓋hello
方法,可是它實現了接口A
。所以它就擁有了接口A
的默認方法。規則(2)說如果類或者父類沒有對應的方法,那麼就應該選擇提供了最具體實現的接口中的方法。因此,編譯器會在接口A
和接口B
的hello
方法之間做選擇。由於B
更加具體,所以程序會再次打印輸出“Hello from B”。你可以繼續嘗試測驗9.2,考察一下你對這些規則的理解。
測驗9.2:牢記這些判斷的規則
我們在這個測驗中繼續復用之前的例子,唯一的不同在於
D
現在顯式地覆蓋了從A
接口中繼承的hello
方法。你認為現在的輸出會是什麼呢?public class D implements A{ void hello{ System.out.println("Hello from D"); } } public class C extends D implements B, A { public static void main(String... args) { new C.hello; } }
答案:由於依據規則(1),父類中聲明的方法具有更高的優先級,所以程序會打印輸出“Hello from D”。
注意,
D
的聲明如下:public abstract class D implements A { public abstract void hello; }
這樣的結果是,雖然在結構上,其他的地方已經聲明了默認方法的實現,
C
還是必須提供自己的hello
方法。
9.4.3 衝突及如何顯式地消除歧義
到目前為止,你看到的這些例子都能夠應用前兩條判斷規則解決。讓我們更進一步,假設B
不再繼承A
(如圖9-7所示):
public interface A {
void hello {
System.out.println("Hello from A");
}
}
public interface B {
void hello {
System.out.println("Hello from B");
}
}
public class C implements B, A { }
這時規則(2)就無法進行判斷了,因為從編譯器的角度看沒有哪一個接口的實現更加具體,兩個都差不多。A
接口和B
接口的hello
方法都是有效的選項。所以,Java編譯器這時就會拋出一個編譯錯誤,因為它無法判斷哪一個方法更合適:“Error: class C
inherits unrelated defaults for hello
from types B
and A
.”
圖 9-7 同時實現具有相同函數聲明的兩個接口
衝突的解決
解決這種兩個可能的有效方法之間的衝突,沒有太多方案;你只能顯式地決定你希望在C
中使用哪一個方法。為了達到這個目的,你可以覆蓋類C
中的hello
方法,在它的方法體內顯式地調用你希望調用的方法。Java 8中引入了一種新的語法X.super.m(…)
,其中X
是你希望調用的m
方法所在的父接口。舉例來說,如果你希望C
使用來自於B
的默認方法,它的調用方式看起來就如下所示:
public class C implements B, A {
void hello{
B.super.hello; ←─顯式地選擇調用接口B中的方法
}
}
讓我們繼續看看測驗9.3,這是一個相關但更加複雜的例子。
測驗9.3:幾乎完全一樣的函數簽名
這個測試中,我們假設接口
A
和B
的聲明如下所示:public interface A{ default Number getNumber{ return 10; } } public interface B{ default Integer getNumber{ return 42; } }
類
C
的聲明如下:public class C implements B, A { public static void main(String... args) { System.out.println(new C.getNumber); } }
這個程序的會打印輸出什麼呢?
答案:類
C
無法判斷A
或者B
到底哪一個更加具體。這就是類C
無法通過編譯的原因。
9.4.4 菱形繼承問題
讓我們考慮最後一種場景,它亦是C++裡中最令人頭痛的難題。
public interface A{
default void hello{
System.out.println("Hello from A");
}
}
public interface B extends A { }
public interface C extends A { }
public class D implements B, C {
public static void main(String... args) {
new D.hello; ←─猜猜打印輸出的是什麼?
}
}
圖9-8以UML圖的方式描述了出現這種問題的場景。這種問題叫“菱形問題”,因為類的繼承關係圖形狀像菱形。這種情況下類D
中的默認方法到底繼承自什麼地方 ——源自B
的默認方法,還是源自C
的默認方法?實際上只有一個方法聲明可以選擇。只有A
聲明了一個默認方法。由於這個接口是D
的父接口,代碼會打印輸出“Hello from A”。
圖 9-8 菱形問題
現在,我們看看另一種情況,如果B
中也提供了一個默認的hello
方法,並且函數簽名跟A
中的方法也完全一致,這時會發生什麼情況呢?根據規則(2),編譯器會選擇提供了更具體實現的接口中的方法。由於B
比A
更加具體,所以編譯器會選擇B
中聲明的默認方法。如果B
和C
都使用相同的函數簽名聲明了hello
方法,就會出現衝突,正如我們之前所介紹的,你需要顯式地指定使用哪個方法。
順便提一句,如果你在C
接口中添加一個抽像的hello
方法(這次添加的不是一個默認方法),會發生什麼情況呢?你可能也想知道答案。
public interface C extends A {
void hello;
}
這個新添加到C
接口中的抽像方法hello
比由接口A
繼承而來的hello
方法擁有更高的優先級,因為C
接口更加具體。因此,類D
現在需要為hello
顯式地添加實現,否則該程序無法通過編譯。
C++語言中的菱形問題
C++語言中的菱形問題要複雜得多。首先,C++允許類的多繼承。默認情況下,如果類
D
繼承了類B
和類C
,而類B
和類C
又都繼承自類A
,類D
實際直接訪問的是B
對像和C
對象的副本。最後的結果是,要使用A
中的方法必須顯式地聲明:這些方法來自於B
接口,還是來自於C
接口。此外,類也有狀態,所以修改B
的成員變量不會在C
對象的副本中反映出來。
現在你應該已經瞭解了,如果一個類的默認方法使用相同的函數簽名繼承自多個接口,解決衝突的機制其實相當簡單。你只需要遵守下面這三條準則就能解決所有可能的衝突。
-
首先,類或父類中顯式聲明的方法,其優先級高於所有的默認方法。
-
如果用第一條無法判斷,方法簽名又沒有區別,那麼選擇提供最具體實現的默認方法的接口。
-
最後,如果衝突依舊無法解決,你就只能在你的類中覆蓋該默認方法,顯式地指定在你的類中使用哪一個接口中的方法。
9.5 小結
下面是本章你應該掌握的關鍵概念。
-
Java 8中的接口可以通過默認方法和靜態方法提供方法的代碼實現。
-
默認方法的開頭以關鍵字
default
修飾,方法體與常規的類方法相同。 -
向發佈的接口添加抽像方法不是源碼兼容的。
-
默認方法的出現能幫助庫的設計者以後向兼容的方式演進API。
-
默認方法可以用於創建可選方法和行為的多繼承。
-
我們有辦法解決由於一個類從多個接口中繼承了擁有相同函數簽名的方法而導致的衝突。
-
類或者父類中聲明的方法的優先級高於任何默認方法。如果前一條無法解決衝突,那就選擇同函數簽名的方法中實現得最具體的那個接口的方法。
-
兩個默認方法都同樣具體時,你需要在類中覆蓋該方法,顯式地選擇使用哪個接口中提供的默認方法。