JAVA中的抽象類與接口
在Java中什么時候應該選擇抽象類而不是接口?接受挑戰吧!了解這些Java語言元素之間的區別以及如何在你的程序中使用它們。
在Java代碼中,甚至在Java開發工具包(JDK)本身中,都有大量的抽象類和接口。每個代碼元素都有一個基本的目的:
- 接口是一種代碼契約,必須由一個具體的類來實現。
- 抽象類與普通類相似,不同的是它們可以包括抽象方法,也就是沒有主體的方法。抽象類不能被實例化。
許多開發者認為接口和抽象類是相似的,但它們實際上是完全不同的。讓我們來探討一下它們之間的主要區別。
接口的本質
從本質上講,接口是一個契約,所以它依賴于一個實現來達到其目的。一個接口永遠不可能有狀態,所以它不能使用可變的實例變量。一個接口只能使用最終變量。
何時使用接口
接口對于解耦代碼和實現多態性非常有用。我們可以在JDK中看到一個例子,就是List 接口:
public interface List<E> extends Collection<E> { int size(); boolean isEmpty(); boolean add(E e); E remove(int index); void clear();}復制代碼
正如你可能注意到的,這段代碼很短,而且描述性很強。我們可以很容易地看到方法的簽名,我們將用一個具體的類來實現接口中的方法。
List 接口包含一個契約,可以由ArrayList,Vector,LinkedList, 和其他類來實現。
為了使用多態性,我們可以簡單地用List 來聲明我們的變量類型,然后選擇任何一個可用的實例化。這里有一個例子:
List list = new ArrayList();System.out.println(list.getClass()); List list = new LinkedList(); System.out.println(list.getClass());復制代碼
下面是這段代碼的輸出:
class java.util.ArrayListclass java.util.LinkedList復制代碼
在這種情況下,ArrayList,LinkedList, 和Vector 的實現方法都是不同的,這就是使用接口的一個很好的場景。如果你注意到許多類都屬于一個父類,其方法動作相同,但行為不同,那么使用接口是個好主意。
接下來,讓我們來看看我們可以用接口做的幾件事。
重寫一個接口方法
記住,接口是一種必須由具體類來實現的契約。接口方法是隱含的抽象的,也需要一個具體類的實現。
這里有一個例子:
public class OverridingDemo { public static void main(String[] args) { Challenger challenger = new JavaChallenger(); challenger.doChallenge(); }}interface Challenger { void doChallenge();}class JavaChallenger implements Challenger { @Override public void doChallenge() { System.out.println("Challenge done!"); }}復制代碼
下面是這段代碼的輸出:
Challenge done!復制代碼
注意這個細節,接口方法是隱式抽象的。這意味著我們不需要明確地將它們聲明為抽象的。
常量變量
另一條要記住的規則是,一個接口只能包含常量變量。因此,下面的代碼是可以的:
public class Challenger { int number = 7; String name = "Java Challenger";}復制代碼
注意,這兩個變量都是隱含的final 和static 。這意味著它們是常量,不依賴于一個實例,而且不能被改變。
如果我們試圖改變Challenger 接口中的變量,例如,像這樣:
Challenger.number = 8;Challenger.name = "Another Challenger";復制代碼
我們會觸發一個編譯錯誤,像這樣:
Cannot assign a value to final variable 'number'Cannot assign a value to final variable 'name'復制代碼
缺省方法
當默認方法在Java 8中被引入時,一些開發者認為它們會和抽象類一樣。然而這并不正確,因為接口不能有狀態。
默認方法可以有一個實現,而抽象方法則不能。默認方法是lambdas和流的偉大創新的結果,但我們應該謹慎使用它們。
JDK中使用默認方法的一個方法是forEach() ,它是Iterable 接口的一部分。我們可以簡單地重用forEach 方法,而不是將代碼復制到每個Iterable 的實現中:
default void forEach(Consumer<? super T> action) { // Code implementation here…復制代碼
任何Iterable 實現都可以使用forEach() 方法,而不需要新的方法實現。然后,我們可以用一個默認方法來重用代碼。
讓我們來創建我們自己的默認方法:
public class DefaultMethodExample { public static void main(String[] args) { Challenger challenger = new JavaChallenger(); challenger.doChallenge(); }}class JavaChallenger implements Challenger { }interface Challenger { default void doChallenge() { System.out.println("Challenger doing a challenge!"); }}復制代碼
下面是輸出結果:
Challenger doing a challenge!復制代碼
關于默認方法,需要注意的是,每個默認方法都需要一個實現。默認方法不能是靜態的。
現在,讓我們繼續討論抽象類。
抽象類的本質
抽象類可以有實例變量的狀態。這意味著一個實例變量可以被使用和變異。這里有一個例子:
public abstract class AbstractClassMutation { private String name = "challenger"; public static void main(String[] args) { AbstractClassMutation abstractClassMutation = new AbstractClassImpl(); abstractClassMutation.name = "mutated challenger"; System.out.println(abstractClassMutation.name); }}class AbstractClassImpl extends AbstractClassMutation { }復制代碼
下面是輸出結果:
mutated challenger復制代碼
抽象類中的抽象方法
就像接口一樣,抽象類可以有抽象方法。抽象方法是一個沒有主體的方法。與接口不同,抽象類中的抽象方法必須明確地聲明為抽象的。這里有一個例子:
public abstract class AbstractMethods { abstract void doSomething();}復制代碼
試圖聲明一個沒有實現的方法,而且沒有abstract 關鍵字,像這樣:
public abstract class AbstractMethods { void doSomethingElse();}復制代碼
導致了一個編譯錯誤,像這樣:
Missing method body, or declare abstract復制代碼
什么時候使用抽象類
當你需要實現可改變狀態時,使用抽象類是一個好主意。作為一個例子,Java集合框架包括AbstractList類,它使用變量的狀態。
在你不需要維護類的狀態的情況下,通常使用一個接口更好。
實踐中的抽象類
設計模式中的模板方法是使用抽象類的好例子。模板方法模式在具體方法中操作實例變量。
抽象類和接口的區別
從面向對象編程的角度來看,接口和抽象類的主要區別是,接口不能有狀態,而抽象類可以用實例變量來有狀態。
另一個關鍵區別是,類可以實現一個以上的接口,但它們只能擴展一個抽象類。這是一個基于多重繼承(擴展一個以上的類)會導致代碼死鎖的設計決定。Java的工程師們決定要避免這種情況。
另一個區別是,接口可以被類實現,也可以被接口擴展,但類只能被擴展。
還需要注意的是,lambda表達式只能用于功能接口(指只有一個方法的接口),而只有一個抽象方法的抽象類不能使用lambdas。
接受Java代碼挑戰吧!
讓我們通過一個Java代碼挑戰來探索接口和抽象類的主要區別。我們在下面提供了代碼挑戰,你也可以用視頻的形式觀看抽象類與接口的挑戰。
在下面的代碼中,同時聲明了一個接口和一個抽象類,而且代碼中還使用了lambdas:
public class AbstractResidentEvilInterfaceChallenge { static int nemesisRaids = 0; public static void main(String[] args) { Zombie zombie = () -> System.out.println("Graw!!! " + nemesisRaids++); System.out.println("Nemesis raids: " + nemesisRaids); Nemesis nemesis = new Nemesis() { public void shoot() { shoots = 23; }}; Zombie.zombie.shoot(); zombie.shoot(); nemesis.shoot(); System.out.println("Nemesis shoots: " + nemesis.shoots + " and raids: " + nemesisRaids); }}interface Zombie { Zombie zombie = () -> System.out.println("Stars!!!"); void shoot();}abstract class Nemesis implements Zombie { public int shoots = 5;}復制代碼
你認為當我們運行這段代碼時,會發生什么?請從下列選項中選擇一個。
選項A
Compilation error at line 4復制代碼
選項B
Graw!!! 0 Nemesis raids: 23 Stars!!! Nemesis shoots: 23 and raids:1復制代碼
選項C
Nemesis raids: 0 Stars!!! Graw!!! 0 Nemesis shoots: 23 and raids: 1復制代碼
選項D
Nemesis raids: 0 Stars!!! Graw!!! 1 Nemesis shoots: 23 and raids:1復制代碼
選項E
Compilation error at line 6復制代碼
Java代碼挑戰視頻
你為這個挑戰選擇了正確的輸出嗎?請觀看視頻或繼續閱讀以了解答案。
了解接口和抽象類及方法
這個Java代碼挑戰展示了許多關于接口、抽象方法等的重要概念。逐行瀏覽代碼會讓我們了解到輸出中發生的很多事情。
代碼挑戰的第一行包括Zombie 接口的lambda表達式。請注意,在這個lambda中,我們正在增加一個靜態字段。實例字段在這里也可以使用,但在lambda之外聲明的局部變量就不行了。因此,到目前為止,這段代碼可以正常編譯。還要注意的是,lambda表達式還沒有執行,所以nemesisRaids 字段還不會被遞增。
在這一點上,我們將打印nemesisRaids 字段,它沒有被增加,因為λ表達式還沒有被調用,只是被聲明。因此,這一行的輸出將是:
Nemesis raids: 0復制代碼
這個Java代碼挑戰中另一個有趣的概念是,我們正在使用一個匿名的內層類。這基本上意味著任何將實現Nemesis 抽象類的方法的類。我們并沒有真正實例化Nemesis 抽象類,因為它實際上是一個匿名的類。還要注意的是,第一個具體的類在擴展它們的時候總是有義務實現抽象的方法。
在Zombie 接口里面,我們用一個lambda表達式聲明了zombie static Zombie 接口。因此,當我們調用zombie shoot 方法時,我們會打印以下內容:
Stars!!!復制代碼
下一行代碼調用了我們在開始時創建的lambda表達式。因此,nemesisRaids 這個變量將被遞增。然而,由于我們使用的是后增量運算符,它將只在這條代碼語句之后被增量。接下來的輸出將是:
Graw!!! 0 復制代碼
現在,我們將從nemesis 中調用shoot 方法,這將改變其shoots 實例變量為23 。 注意,這部分代碼展示了接口和抽象類之間的最大區別。
最后,我們打印nemesis.shoots 和nemesisRaids 的值。因此,輸出結果將是:
Nemesis shoots: 23 and raids: 1復制代碼
綜上所述,正確的輸出是選項C:
Nemesis raids: 0 Stars!!! Graw!!! 0 Nemesis shoots: 23 and raids: 1