設計模式之美學習(六):接口vs抽象類的區別?如何用普通的類模擬抽象類和接口?

在面向對象編程中,抽象類和接口是兩個常常被用到的語法概念,是面向對象四大特性,以及不少設計模式、設計思想、設計原則編程實現的基礎。好比,咱們可使用接口來實現面向對象的抽象特性、多態特性和基於接口而非實現的設計原則,使用抽象類來實現面向對象的繼承特性和模板設計模式等等。面試

不過,並非全部的面向對象編程語言都支持這兩個語法概念,好比,C++ 這種編程語言只支持抽象類,不支持接口;而像 Python 這樣的動態編程語言,既不支持抽象類,也不支持接口。儘管有些編程語言沒有提供現成的語法來支持接口和抽象類,咱們仍然能夠經過一些手段來模擬實現這兩個語法概念。編程

這兩個語法概念不只在工做中常常會被用到,在面試中也常常被說起。好比,「接口和抽象類的區別是什麼?何時用接口?何時用抽象類?抽象類和接口存在的意義是什麼?能解決哪些編程問題?」等等。設計模式

什麼是抽象類和接口?區別在哪裏?

不一樣的編程語言對接口和抽象類的定義方式可能有些差異,但差異並不會很大。編程語言

首先來看一下,在 Java 這種編程語言中,咱們是如何定義抽象類的。ide

下面這段代碼是一個比較典型的抽象類的使用場景(模板設計模式)。Logger 是一個記錄日誌的抽象類,FileLoggerMessageQueueLogger 繼承 Logger,分別實現兩種不一樣的日誌記錄方式:記錄日誌到文件中和記錄日誌到消息隊列中。FileLoggerMessageQueueLogger 兩個子類複用了父類 Logger 中的 nameenabledminPermittedLevel 屬性和 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.writer = new FileWriter(filepath); 
  }
  
  @Override
  public void doLog(Level level, String mesage) {
    // 格式化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;
  }
  
  @Override
  protected void doLog(Level level, String mesage) {
    // 格式化level和message,輸出到消息中間件
    msgQueueClient.send(...);
  }
}複製代碼

經過上面的這個例子來看一下,抽象類具備哪些特性。this

  • 抽象類不容許被實例化,只能被繼承。也就是說,你不能 new 一個抽象類的對象出來(Logger logger = new Logger(…); 會報編譯錯誤)。
  • 抽象類能夠包含屬性和方法。方法既能夠包含代碼實現(好比 Logger 中的 log() 方法),也能夠不包含代碼實現(好比 Logger 中的 doLog() 方法)。不包含代碼實現的方法叫做抽象方法。
  • 子類繼承抽象類,必須實現抽象類中的全部抽象方法。對應到例子代碼中就是,全部繼承 Logger 抽象類的子類,都必須重寫 doLog() 方法。

再來看一下,在 Java 這種編程語言中,咱們如何定義接口。spa

// 接口
public interface Filter {
  void doFilter(RpcRequest req) throws RpcException;
}
// 接口實現類:鑑權過濾器
public class AuthencationFilter implements Filter {
  @Override
  public void doFilter(RpcRequest req) throws RpcException {
    //...鑑權邏輯..
  }
}
// 接口實現類:限流過濾器
public class RateLimitFilter implements Filter {
  @Override
  public void doFilter(RpcRequest req) throws RpcException {
    //...限流邏輯...
  }
}
// 過濾器使用demo
public class Application {
  // filters.add(new AuthencationFilter());
  // filters.add(new RateLimitFilter());
  private List<Filter> filters = new ArrayList<>();
  
  public void handleRpcRequest(RpcRequest req) {
    try {
      for (Filter filter : fitlers) {
        filter.doFilter(req);
      }
    } catch(RpcException e) {
      // ...處理過濾結果...
    }
    // ...省略其餘處理邏輯...
  }
}複製代碼

上面這段代碼是一個比較典型的接口的使用場景。經過 Java 中的 interface 關鍵字定義了一個 Filter 接口。AuthencationFilterRateLimitFilter 是接口的兩個實現類,分別實現了對 RPC 請求鑑權和限流的過濾功能。設計

代碼很是簡潔。結合代碼再來看一下,接口都有哪些特性。日誌

  • 接口不能包含屬性(也就是成員變量)。
  • 接口只能聲明方法,方法不能包含代碼實現。
  • 類實現接口的時候,必須實現接口中聲明的全部方法。

從語法特性上對比,這二者有比較大的區別,好比抽象類中能夠定義屬性、方法的實現,而接口中不能定義屬性,方法也不能包含代碼實現等等。除了語法特性,從設計的角度,二者也有比較大的區別。

抽象類實際上就是類,只不過是一種特殊的類,這種類不能被實例化爲對象,只能被子類繼承。咱們知道,繼承關係是一種 is-a 的關係,那抽象類既然屬於類,也表示一種 is-a 的關係。相對於抽象類的 is-a 關係來講,接口表示一種 has-a 關係,表示具備某些功能。對於接口,有一個更加形象的叫法,那就是協議(contract)。

抽象類和接口能解決什麼編程問題?

爲何須要抽象類?它可以解決什麼編程問題?

抽象類也是爲代碼複用而生的。多個子類能夠繼承抽象類中定義的屬性和方法,避免在子類中,重複編寫相同的代碼。

不過,既然繼承自己就能達到代碼複用的目的,而繼承也並不要求父類必定是抽象類,那咱們不使用抽象類,照樣也能夠實現繼承和複用。從這個角度上來說,咱們貌似並不須要抽象類這種語法呀。那抽象類除了解決代碼複用的問題,還有什麼其餘存在的意義嗎?

仍是拿以前那個打印日誌的例子。咱們先對上面的代碼作下改造。在改造以後的代碼中,Logger 再也不是抽象類,只是一個普通的父類,刪除了 Loggerlog()doLog() 方法,新增了 isLoggable() 方法。FileLoggerMessageQueueLogger 仍是繼承 Logger 父類,以達到代碼複用的目的。具體的代碼以下:

// 父類:非抽象類,就是普通的類. 刪除了log(),doLog(),新增了isLoggable().
public class Logger {
  private String name;
  private boolean enabled;
  private Level minPermittedLevel;

  public Logger(String name, boolean enabled, Level minPermittedLevel) {
    //...構造函數不變,代碼省略...
  }

  protected boolean isLoggable() {
    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 mesage) {
    if (!isLoggable()) 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 mesage) {
    if (!isLoggable()) return;
    // 格式化level和message,輸出到消息中間件
    msgQueueClient.send(...);
  }
}複製代碼

這個設計思路雖然達到了代碼複用的目的,可是沒法使用多態特性了。像下面這樣編寫代碼,就會出現編譯錯誤,由於 Logger 中並無定義 log() 方法。

Logger logger = new FileLogger("access-log", true, Level.WARN, "/users/muchen/access.log");
logger.log(Level.ERROR, "This is a test log message.");複製代碼

你可能會說,這個問題解決起來很簡單啊。在 Logger 父類中,定義一個空的 log() 方法,讓子類重寫父類的 log() 方法,實現本身的記錄日誌的邏輯,不就能夠了嗎?

public class Logger {
  // ...省略部分代碼...
  public void log(Level level, String mesage) { // do nothing... }
}
public class FileLogger extends Logger {
  // ...省略部分代碼...
  @Override
  public void log(Level level, String mesage) {
    if (!isLoggable()) return;
    // 格式化level和message,輸出到日誌文件
    fileWriter.write(...);
  }
}
public class MessageQueueLogger extends Logger {
  // ...省略部分代碼...
  @Override
  public void log(Level level, String mesage) {
    if (!isLoggable()) return;
    // 格式化level和message,輸出到消息中間件
    msgQueueClient.send(...);
  }
}複製代碼

這個設計思路能用,可是,它顯然沒有以前經過抽象類的實現思路優雅。爲何這麼說呢?主要有如下幾點緣由。

  • Logger 中定義一個空的方法,會影響代碼的可讀性。若是咱們不熟悉 Logger 背後的設計思想,代碼註釋又不怎麼給力,咱們在閱讀 Logger 代碼的時候,就可能對爲何定義一個空的 log() 方法而感到疑惑,須要查看 LoggerFileLoggerMessageQueueLogger 之間的繼承關係,才能弄明白其設計意圖。
  • 當建立一個新的子類繼承 Logger 父類的時候,咱們有可能會忘記從新實現 log() 方法。以前基於抽象類的設計思路,編譯器會強制要求子類重寫 log() 方法,不然會報編譯錯誤。你可能會說,我既然要定義一個新的 Logger 子類,怎麼會忘記從新實現 log() 方法呢?咱們舉的例子比較簡單,Logger 中的方法很少,代碼行數也不多。可是,若是 Logger 有幾百行,有 n 多方法,除非你對 Logger 的設計很是熟悉,不然忘記從新實現 log() 方法,也不是不可能的。
  • Logger 能夠被實例化,換句話說,咱們能夠 new 一個 Logger 出來,而且調用空的 log() 方法。這也增長了類被誤用的風險。固然,這個問題能夠經過設置私有的構造函數的方式來解決。不過,顯然沒有經過抽象類來的優雅。

爲何須要接口?它可以解決什麼編程問題?

抽象類更多的是爲了代碼複用,而接口就更側重於解耦。接口是對行爲的一種抽象,至關於一組協議或者契約,你能夠聯想類比一下 API 接口。調用者只須要關注抽象的接口,不須要了解具體的實現,具體的實現代碼對調用者透明。接口實現了約定和實現相分離,能夠下降代碼間的耦合性,提升代碼的可擴展性。

實際上,接口是一個比抽象類應用更加普遍、更加劇要的知識點。好比,常常提到的「基於接口而非實現編程」,就是一條几乎每天會用到,而且能極大地提升代碼的靈活性、擴展性的設計思想。

如何模擬抽象類和接口兩個語法概念?

在前面舉的例子中,使用 Java 的接口語法實現了一個 Filter 過濾器。不過,若是你熟悉的是 C++ 這種編程語言,你可能會說,C++ 只有抽象類,並無接口,那從代碼實現的角度上來講,是否是就沒法實現 Filter 的設計思路了呢?

先來回憶一下接口的定義:接口中沒有成員變量,只有方法聲明,沒有方法實現,實現接口的類必須實現接口中的全部方法。只要知足這樣幾點,從設計的角度上來講,咱們就能夠把它叫做接口。實際上,要知足接口的這些語法特性並不難。在下面這段 C++ 代碼中,就用抽象類模擬了一個接口(下面這段代碼其實是策略模式中的一段代碼)。

class Strategy { // 用抽象類模擬接口
  public:
    ~Strategy();
    virtual void algorithm()=0;
  protected:
    Strategy();
};複製代碼

抽象類 Strategy 沒有定義任何屬性,而且全部的方法都聲明爲 virtual 類型(等同於 Java 中的 abstract 關鍵字),這樣,全部的方法都不能有代碼實現,而且全部繼承這個抽象類的子類,都要實現這些方法。從語法特性上來看,這個抽象類就至關於一個接口。

PythonRuby 這些動態語言中,不只沒有接口的概念,也沒有相似 abstractvirtual 這樣的關鍵字來定義抽象類,那該如何實現上面的講到的 FilterLogger 的設計思路呢?實際上,除了用抽象類來模擬接口以外,還能夠用普通類來模擬接口。具體的 Java 代碼實現以下所示。

public class MockInteface {
  protected MockInteface() {}
  public void funcA() {
    throw new MethodUnSupportedException();
  }
}複製代碼

類中的方法必須包含實現,這個不符合接口的定義。可是,咱們可讓類中的方法拋出 MethodUnSupportedException 異常,來模擬不包含實現的接口,而且能強迫子類在繼承這個父類的時候,都去主動實現父類的方法,不然就會在運行時拋出異常。那又如何避免這個類被實例化呢?實際上很簡單,咱們只須要將這個類的構造函數聲明爲 protected 訪問權限就能夠了。

如何決定該用抽象類仍是接口?

實際上,判斷的標準很簡單。若是咱們要表示一種 is-a 的關係,而且是爲了解決代碼複用的問題,咱們就用抽象類;若是咱們要表示一種 has-a 關係,而且是爲了解決抽象而非代碼複用的問題,那咱們就可使用接口。

從類的繼承層次上來看,抽象類是一種自下而上的設計思路,先有子類的代碼重複,而後再抽象成上層的父類(也就是抽象類)。而接口正好相反,它是一種自上而下的設計思路。咱們在編程的時候,通常都是先設計接口,再去考慮具體的實現。

重點回顧

1. 抽象類和接口的語法特性

抽象類不容許被實例化,只能被繼承。它能夠包含屬性和方法。方法既能夠包含代碼實現,也能夠不包含代碼實現。不包含代碼實現的方法叫做抽象方法。子類繼承抽象類,必須實現抽象類中的全部抽象方法。接口不能包含屬性,只能聲明方法,方法不能包含代碼實現。類實現接口的時候,必須實現接口中聲明的全部方法。

2. 抽象類和接口存在的意義

抽象類是對成員變量和方法的抽象,是一種 is-a 關係,是爲了解決代碼複用問題。接口僅僅是對方法的抽象,是一種 has-a 關係,表示具備某一組行爲特性,是爲了解決解耦問題,隔離接口和具體的實現,提升代碼的擴展性。

3. 抽象類和接口的應用場景區別

何時該用抽象類?何時該用接口?實際上,判斷的標準很簡單。若是要表示一種 is-a 的關係,而且是爲了解決代碼複用問題,咱們就用抽象類;若是要表示一種 has-a 關係,而且是爲了解決抽象而非代碼複用問題,那咱們就用接口。

思考

  • 接口和抽象類是兩個常常在面試中被問到的概念。若是面試官再讓你聊聊接口和抽象類,你會如何回答呢?

參考:接口vs抽象類的區別?如何用普通的類模擬抽象類和接口?

本文由博客一文多發平臺 OpenWrite 發佈!

更多內容請點擊個人博客沐晨

相關文章
相關標籤/搜索