站在新語言平臺上再談"組合"與"繼承"

長久以來,OO編程思想的一個重要信條是:多用組合,少用繼承,這被廣爲接受和認可。Scala引入Trait(特質)之後,這一點“似乎”受到了衝擊,你可以看到,在很多Scala代碼裏出現了通過繼承多個Trait爲一個Class混入(追加)新功能的案例,而其中有不少案例是過去我們在傳統OO語言(例如Java)中不會或不建議的做法,因爲看上去那確實是在濫用繼承。

舉個簡單的例子,日誌功能是非常普遍的需求,傳統的Java程序裏是以“組合”的方式爲一個類添加這一功能的,也就是在類裏聲明一個logger實例作爲類的字段,然後就可以在類的各處使用它。這在Java這種單繼承的編程語言裏幾乎是唯一的選擇,很顯然,你不可能讓你的類僅僅爲了一個日誌打印功能就佔用了唯一一個父類名額,即使你的類沒任何父類,讓一個類去繼承一個Logging父類也是一件很怪異的事。

在Scala裏,事情發生了一些變化,由於Trait的多繼承特性讓一個類去繼承多個特質變得自然而普遍,這爲程序員提供了一個新的“爲一個類混入新功能”的途徑。於是很多在Java裏使用組合的地方都被基於特質的“繼承”替代了。同樣是上面的日誌功能,在Scala裏的普遍實現方式是這樣的:

trait Logger {
  def log(msg: String): Unit = {
    println(msg)
  }
}

class DataAccess extends Logger{
    def query(in: String) = {
      log(in)
  }
}

new DataAccess().query("Test")

這裏要首先解釋一點的是:讓DataAccess繼承一個Logger看上去是有些突兀和怪異,如果DataAccess有其他的父類或特質,我們在with語句的最後append上Logger或許看上去會自然很多。

好的,回到Scala的這版實現上,一個從Java剛剛轉到Scala的程序員看到這樣的代碼會感到很不舒服,他過去接受的傳統教條告訴他這個地方應該使用組合而不是繼承!但是Scala社區普遍這樣去寫是需要我們深思的。首先,我們需要搞清楚一個問題,在傳統的OO語言裏爲什麼要提倡多用組合少用繼承,濫用繼承的危害是什麼?

一個普遍的認同是“濫用”繼承會破環封裝,請記住我們的限定條件是“濫用”。在支持多繼承的語言裏,繼承一個類的成本極低,低到可以忽略不計的程度,這是造成繼承被濫用的一個主要原因,濫用繼承最直接的後果就是破壞封裝,原因很簡單,當子類繼承父類之後就會“承襲”父類所有的類和方法,如果一個類從核心用意和設計初衷上天然是另一個類的子類時,並不存在破壞封裝這種說法,真正出問題的場景是:一個類從用意和設計初衷上不應是某個類的一個子類,但又需要用到或依賴到這個類的一部分功能,此時使用繼承就會將父類全部的字段和方法暴露給子類。如果使用組合則會在一定程度上限制這種情形的發生。

上述我們分析到了兩個判定是否是濫用繼承的重要依據:

  1. 如果一個類從核心用意和設計初衷上天然是另一個類的子類,這種繼承是天經地義的,並不存在破壞封裝的說法。
  2. 在允許多繼承的語言裏,如果一個類需要使用到來自另一個父類(特質)的“全體”字段和方法,或都反過來說,把某個父類(特質)的全體成員賦予另一個類時,如果從這兩個類的設計用意和代表的概念上沒有任何的違和感,那麼,這時候使用繼承也是正當的,沒有破壞封裝的嫌疑。

對於第二點實際上還有很多的潛臺詞,我們強調了“全體”字段和方法,這是體現“is-a”關係的一個重要標誌,若不需要全體,那這種繼承就值得懷疑,通常,這有兩種可能:

  1. 你根本不應去繼承
  2. 你的父類(特質)職責是否不夠單一?是否需要重構成多個職責更單一的父類(特質),然後再從中選擇合適的父類(特質)進行繼承呢?

基於上述原則,我們分析兩個案例,第一個就是前面實例代碼中的Logger特質,我們可以說這是一個職責絕對單一的特質,所有想要繼承它的類目的也很單一:獲得日誌輸出的能力,在這種情況下使用繼承沒有任何副作用,儘管這對從單繼承語言剛剛轉過來的程序員而言還是會感覺有些“心裏不踏實”,但是仔細分析一翻就會發現並未觸碰到什麼紅線,所以應該可以很快適應這種寫法。

另一個則是與Logger極爲類似但在我個人看來卻是一個反面案例,就是在很多代碼裏看到的對Config類的繼承:

trait Config {
  private val config = ConfigFactory.load()

  private val httpConfig = config.getConfig("http")
  private val databaseConfig = config.getConfig("database")

  val httpHost = httpConfig.getString("interface")
  val httpPort = httpConfig.getInt("port")

  val jdbcUrl = databaseConfig.getString("url")
  val dbUser = databaseConfig.getString("user")
  val dbPassword = databaseConfig.getString("password")
}

class HttpService extends Config {
    ...
}

class DatabaseService extends Config {
    ...
}

類似上面的代碼在很多程序裏出現過,設計者寄希望通過繼承Config讓一個類能方便的獲取配置項的值,不同於Logger, Config包含了整個應用的所有配置項,沒有哪一個類需要並應該繼承它的所有字段,這與我們前文提及第2個重要原則相違背,這也嚴重的破壞了Config維護的封裝。代碼中的HttpService和DatabaseService也能從側面說明這一點,它們各自關心的是Http和Database相關的配置,對於其他的配置項沒有理由也暴露給它們。相對優雅的做法應該是把這些配置項封裝到一個object中,在需要使用某個配置項時以變量的方式獲取即可。

小結一下吧:

對於那些從傳統單繼承OO語言轉到支持多繼承語言的程序員來說,你應該“想開點”:),花開堪折直須折,如果一個類就是想要獲得某方面的“特質”,多了不要,少了不行,那就放心大膽地去繼承那個“特質”吧。除此以外,你還是要審慎地看待每一個繼承,繼承始終是一件需要警惕的事,特別是在允許多繼承的語言裏。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章