簡述
在面向對象編程中,抽象類和接口是常被用到的語法概念,是面向對象四大特性,以及很多設計模式、設計思想、設計原則實現的基礎。它們之間的區別是什么?什么時候用接口?什么時候用抽象類?抽象類和接口存在的意義是什么?等等
1.什么是抽象類和接口?它們有什么區別?
我們來看下 Java
語言中是如何定義抽象類的。下面這段代碼是一個比較典型的抽象類的使用場景(模板方法模式)。Logger
是一個記錄日志的抽象類,FileLogger
和 MessageQueueLogger
繼承 Logger
,分別實現兩種不同的日志記錄方式:記錄日志到文件和記錄日志到消息隊列。FileLogger
和 MessageQueueLogger
兩個子類復用了父類 Logger
中的 name
、enabled
、minPermittedLevel
屬性和 log()
方法,但是因為這兩個子類的寫日志方式不同,它們又各種重寫了父類中的 doLog()
方法。
// 抽象類
public abstract class Logger {private String name;private boolean enabled;private Level minPermittedLevel;public Logger(String name, boolean enabled, Level minPermittedLevel) {this.name = name;this.enabled = enabled;this.minPermittedLevel = minPermittedLevel;}public void log(Level level, String message) {boolean loggable = enabled && (minPermittedLevel.intValue() <= level.intValue());if (!loggable) return;doLog(level, message);}protected abstract void doLog(Level level,String message);
}// 抽象的子類:輸出日志到文件
public class FileLogger extends Logger {private Writer fileWriter;public FileLogger(String name, boolean enabled,Level minPermittedLevel, String filepath) {super(name, enabled, minPermittedLevel);this.fileWriter = new FileWriter(filepath);}@Overrideprotected void doLog(Level level, String message) {// 格式化level和message,輸出日志文件fileWriter.write(...);}
}// 抽象的子類:輸出日志到消息中間件(比如Kafka)
public class MessageQueueLogger extends Logger {private MessageQueueClient msgQueueClient;public MessageQueueLogger(String name, boolean enabled,Level minPermittedLevel, MessageQueueClient msgQueueClient) {super(name, enabled, minPermittedLevel);this.msgQueueClient = msgQueueClient;}@Overrideprotected void doLog(Level level, String message) {// 格式化level和message,輸出到消息中間件msgQueueClient.send(...);}
}
通過上面這個例子,我們來總結下抽象類的特性:
- 抽象類不允許被實例化,只能被繼承。
- 抽象類可以包含屬性和方法。方法既可以包含代碼實現(比如
Logger
中的log()
),也可以不包含代碼實現(比如Logger
中的doLog()
)。不包含代碼實現的方法叫做抽象方法。 - 子類繼承抽象類,必須實現抽象類中的所有抽象方法,對應到例子代碼中,所有繼承
Logger
的子類,都必須重寫doLog()
。
再來看下,在 Java
語言中,如何定義接口
// 接口
public interface Filter {void doFilter(RpcRequest req) throws RpcException;
}// 接口實現類:鑒權過濾器
public class AuthFilter implements Filter {@Overridepublic void doFilter(RpcRequest req) throws RpcException {// 鑒權邏輯...}
}// 接口實現類:限流過濾器
public class RateLimitFilter implements Filter {@Overridepublic void doFilter(RpcRequest req) throws RpcException {// 限流邏輯...}
}// 過濾器使用Demo
public class Application {private List<Filter> filters = new ArrayList<>();// filters.add(new AuthFilter());// filters.add(new RateLimitFilter());public void handleRpcRequest(RpcRequest req) {try {for (Filter filter : filters) {filter.doFilter(req);}} catch (RpcException e) {// 處理過濾異常...}// 省略其他邏輯...}
}
上面這段代碼是一個比較典型的接口的使用場景。我們通過 Java
語言中的 interface
關鍵字定義了一個 Filter
接口。AuthFilter
和 RateLimitFilter
是接口的兩個實現類,分別實現了對 RPC
請求鑒權和限流的過濾功能。
總結下接口的三個特性:
- 接口不能包含屬性
- 接口只能申明方法,方法不能包含代碼實現。
- 類實現接口的時候,必須實現接口類中聲明的所有方法。
從語法特性上對比,兩種有比較大的差別,比如抽象類中可以定義屬性、實現方法,而接口中不能定義屬性,方法也不能實現。除了語法特性,從設計角度,兩種也是由較大的區別的。
抽象類實際上是類,只不過是一種特殊的類,這種類不能被實例化為對象,只能被子類繼承。我們知道繼承是一種 is-a
的的關系,那抽象類既然屬于類,也表示一種 is-a
的關系。相對于抽象類的 is-a
來說,接口表示一種 has-a
關系,表示具有某些功能。對于接口,有一種更加形象的叫法,那就是協議(contract
)。
2.抽象類和接口能解決什么問題?
首先,看一下為什么需要抽象類?它能夠解決什么編程問題?
剛剛講過抽象類不能實例化,只能被繼承。而繼承能解決代付復用問題,所以抽象類也是為代碼復用而生的。多個子類可以繼承抽象類中定義的熟悉和方法,避免在子類在編寫重復的代碼。
既然,繼承本身能到到代碼復用的目的,那不用抽象類也能實現繼承和復用。那抽象類除了解決代碼復用的問題,還有什么其他的意義嗎?
還是之前日志的例子,來講解。Logger
不再是抽象類,而是一個普通的父類,刪除了 log()
、doLog()
方法,新增了 isLoggable()
方法。FileLogger
和 MessageQueueLogger
還是繼承 Logger
,以達到代碼復用的目的。
// 父類:非抽象類,就是普通的類. 刪除了log(),doLog(),新增了isLoggable().
public class Logger {private String name;private boolean enabled;private Level minPermittedLevel;public Logger(String name, boolean enabled, Level minPermittedLevel) {//...構造函數不變,代碼省略...}public boolean isLoggable(Level level) {boolean loggable = enabled && (minPermittedLevel.intValue() <= level.intValue());return loggable;}
}// 子類:輸出日志到文件
public class FileLogger extends Logger {private Writer fileWriter;public FileLogger(String name, boolean enabled,Level minPermittedLevel, String filepath) {//...構造函數不變,代碼省略...}public void log(Level level, String message) {if (!isLoggable(level)) return;// 格式化level和message,輸出到日志文件fileWriter.write(...);}
}
// 子類: 輸出日志到消息中間件(比如kafka)
public class MessageQueueLogger extends Logger {private MessageQueueClient msgQueueClient;public MessageQueueLogger(String name, boolean enabled,Level minPermittedLevel, MessageQueueClient msgQueueClient) {//...構造函數不變,代碼省略...}public void log(Level level, String message) {if (!isLoggable(level)) return;// 格式化level和message,輸出到消息中間件msgQueueClient.send(...);}
}
這個涉及思路雖然達到了代碼復用的目的,但是無法使用多態特性了。像下面這樣編寫代碼,就會出現編譯報錯。
Logger logger = new FileLogger("access-log", true, Level.WARN, "/file/access.log");
logger.log(Level.ERROR, "This is a test log message.");
你可以能會說,這個問題解決起來很簡單啊,在 Logger
中定義一個空的 log()
方法,讓子類重寫父類的 log()
方法,實現自己的記錄日志的邏輯,不就可以了嗎?
public class Logger {// 省略其他代碼...public void log(Level level, String message) { //do nothing... }
}public class FileLogger extends Logger {// 省略其他代碼...@Overridepublic void log(Level level, String message) {if (!isLoggable(level)) return;// 格式化level和message,輸出到日志文件fileWriter.write(...);}
}
public class MessageQueueLogger extends Logger {// 省略其他代碼...@Overridepublic void log(Level level, String message) {if (!isLoggable(level)) return;// 格式化level和message,輸出到消息中間件msgQueueClient.send(...);}
}
這個設計思路雖然可以用,但是,它顯然沒有之前通過抽象類的實現思路優雅。主要有以下幾點原因:
- 在
Logger
中定義一個空的log()
方法,會影響代碼的可讀性。如果我們不熟悉Logger
背后的設計思想,代碼注釋有不太好,我們在閱讀Logger
代碼的時候,就可能對為什么定義一個空的log()
方法感到疑惑,需要查看Logger
、FileLogger
、MessageQueueLogger
之間的繼承關系,才能弄明白其設計意圖。 - 當創建一個新的子類繼承
Logger
的時候,我們可能會忘記重新實現log()
方法。不像抽象類,編譯器會強制要求子類重寫log()
方法。你可能會說,怎么可能會忘記重新實現呢? 我們舉個簡單的例子,如果Logger
代碼有幾百行,有 n 個方法,這個時候你很有可能會忘記重寫log()
方法。 Logger
可以被實例化,換句話說,我們可以 new 一個Logger
出來,并調用空的log()
方法。這增加了類被誤用的風險。當然,這個問題可以通過設置私有的構造函數的方式來解決。不過顯然沒有通過抽象類來的優雅。
其次,我們再來看一下,我們為什么需要接口?它能解決什么問題?
抽象類更多的是為了代碼復用,而接口就側重于解耦。接口是對行為的一種抽象,相當于一組協議或契約,你可以類比 API
接口。調用者只需要關注抽象的接口,不需要了解具體的實現。接口實現了約定和實現分離,可以降低代碼間的耦合性,提供代碼的可擴展性。
實際上,接口是一個比較抽象類更加廣發、更加重要的知識點。比如,我們經常提到的“基于接口而非實現編程”,就是一條幾乎天天會被用到,并且能極大地提高代碼的靈活性、擴展性的設計思想。
3. 如何決定該用抽象類還是接口?
實際上判斷標準很簡單。如果我們要表示一種 is-a
的關系,并且是為了解決代碼復用的問題,我們就用抽象類;如果我們要表示一種 has-a
的關系,并且是為了解決抽象而非代碼復用的問題,我們就用接口。
從類層次上來看,抽象類是一種自下而上的設計思路,現有子類的代付重復,然后再抽象成上層的父類(也就是抽象類)。而接口正好相反,它是一種自上而下的設計思路。我們在編程的時候,一般都是先設計接口,再去考慮具體實現。
總結
1.抽象類和接口的語法特性
抽象類不允許實例化,只能被繼承。它可以包含屬性和方法。方法既可以包含代碼實現,也可以不包含代碼實現。不包含代碼實現的方法叫做抽象方法,必須由子類實現。
接口不能包含屬性,只能聲明方法,方法不能包含代碼實現。類實現接口的時候,必須實現接口中申明的所有方法。
2.抽象類和接口的意義
抽象類是對成員變量和方法的抽象,是一種 is-a
的關系,是為了解決代付復用的問題。
接口僅是對方法的抽象,是一種 has-a
的關系,表示某一組行為特性,是為了解決解耦問題,隔離接口和具體的實現,提高帶代碼的可擴展性。
3.抽象類和接口的應用場景
判斷標準很簡單。
如果要表示一種 is-a
的關系,并且是為了解決代付復用的問題,我們就用抽象類。
如果要表示一種 has-a
的關系,并且是為了解決抽象而非代碼復用問題,我們就用接口。