介面本質上的進化

Translated from “The Evolving Nature of Interfaces” by Michael Kölling, Java Magazine September/October 2016, page 47–52. Copyright Oracle Corporation.

了解 Java 的多重繼承

在 “New to Java” 系列,我試著精選些能引起更深入瞭解語言概念 [1] (language construct) 背後理念的議題,通常,新手對一個概念有能動手開始做的知識,能將這概念用在多數情境下,但他們缺乏對底層原則的了解,這些原則能讓她們寫出更好的程式、建立更好的組織以及能在選擇何時用某個概念時作出更好的決策。Java 的介面正好一個合適的議題。

在本文中,我假設您對繼承已有基礎的認識,如同 extends 與 implements 關鍵字,Java 的介面與繼承息息相關,所以我將討論為什麼 Java 有兩種迥異的繼承機制 (用關鍵字區別)、什麼是抽象類別以及介面能用在什麼任務上。

通常都是這樣,這些特性的故事都從某些相當簡單且優雅的想法出發,這些想法定義先前 Java 版本中的眾多概念,然後當 Java 進一步開始處理現實世界中複雜的問題,故事變複雜了,這導致 Java 8 引入預設函式 (default methods),讓水變混濁了 [2]。

一些關於繼承的背景

基本上,繼承是相當容易理解:一個類別可以被指定成另一個類別的擴充,在這情況下,當下的類別稱作 subclass,被擴充的類別稱作 superclass [3],subclass 的物件擁有 superclass 和 subclass 的所有屬性 (properties),它們有 subclass 或 superclass 定義的所有欄位 [4] (fields) 及函式,到目前為止一切順利。

然而,繼承就像是程式設計中的瑞士小刀:它能用來達成一些相異的目標。我可以用繼承來重複利用我以前寫過的程式、我可以用它來做分類與動態派遣、我可以用它將規格與實作分開、我可以用它規範一個系統中不同部分之間的合約、我可以用它做很多不同的事。這些都很重要,但都是相當不同的想法。因此有必要了解這些差異才能對繼承與介面有好的體認。

型別繼承 vs. 程式繼承

繼承提供的兩個主要能力是繼承程式碼與繼承型別,從概念上將兩個想法分開是很有用的,特別是因為 Java 的繼承將兩者混在一起,在 Java 中,定義類別也同樣定義型別,例如,在擁有一個類別的當下,我可以建立該型別的變形。

當用 extends 關鍵字建立一個 subclass,這個 subclass 繼承了 superclass 的型別與程式,繼承得到的函式可以被呼叫 (我之後稱之為程式),subclass 的物件可被放在預期是 superclass 物件的地方 (因此,subclass 建立 subtype)。

現在看個例子,若 StudentPerson 的 subclass,則 Student 的物件既是 Student 型別,同時也是 Person 型別,一個學生是 (is a) 一個人,程式與型別都被繼承。

Java 決定將型別繼承與程式繼承連結在一起是一種語言設計選擇:因為它通常很有用,但這不是設計語言時唯一的方式,其他程式語言允許繼承程式但不繼承型別 (例如 C++ 的 private inheritance),或是繼承型別但不繼承程式 (Java 同樣支援,稍後會解釋)。

多重繼承

下個開始混淆的概念是多重繼承 (multiple inheritance),一個類別可以有一個以上的 superclass,給您個例子:PhD 學生在我的大學中同時也是講師,某種意義上,他們是職員 (他們是某門課的講師,有研究室號碼,有薪資帳號等),但他們同時是學生,要修課,有學號,我可以將這塑模成多重繼承 (如 Figure 1)

Figure 1. An example of multiple inheritance

PhDStudent 同時是 FacultyStudent 的 subclass,如此,一個 PhD 學生將擁有學生與職員的屬性,概念上這很直覺,然而,實務上,若允選多重繼承會讓語言變複雜,因為會引發新問題:如果兩個 superclass 都有相同名字的欄位該怎麼辦呢?如果有相同的函式特徵 (signature) [5]但實作不同怎麼辦?為此,需要額外的語言概念指定模稜兩可問題的解決方式與名稱多載,然後,這只會更糟 [6]。

菱形繼承

更複雜的情節是菱形繼承 (diamond inheritance,見 Figure 2),一個類別 (PhDStudent) 有兩個 superclass (FacultyStudent),而各自有一個共同的 superclass,繼承圖形成一個菱形。

Figure 2. An example of diamond inheritance

現在,思考一個問題:如果最上層的 superclass (在這例子中,Person) 有一個欄位,最底層的類別 (PhDStudent) 該欄位應該有一份還是兩份?它繼承這欄位兩次,畢竟,從繼承分支中各繼承一次。

答案是看情況,如果問題中該欄位是是一個 ID 編號,例如 PhD 學生應該有兩個編號:一個是學號及一個職員或薪資帳戶編號,兩者可能不同,然而,如果該欄位是該人的家族姓,則您會希望只有一個 (PhD 學生只有一個家族姓,即使從兩個 superclass 繼承該欄位)。

簡而言之,事情變得很混亂,允許完整多重繼承的語言需要規則與概念去處理這些情況,而這些規則是複雜的。

型別繼承來解救

當您認真思考這些問題,您會理解這些多重繼承的問題都和繼承程式 (函式實作及欄位) 有關,多重程式繼承是混亂的,但多重型別繼承則沒問題,另一個觀察的事實是,多重程式繼承並沒有非常重要,因為可以用委派 (delegation,使用另一個物件的參考) 取代,但多重型別繼承則是非常有用,且沒有優雅的方式能輕易取代。

這是為什麼 Java 設計者做出決定:只允許程式單一繼承,但允許多重型別繼承。

介面

為了讓型別與程式有不同的規則,Java 需要可以指定型別但不需要指定程式內容,這正好就是 Java 介面。

介面指定型別 (型別名稱與其函式的特徵) 但不用提供任何實作,欄位與函式實體都是不需要的,介面可以有常數,您可以不寫修飾詞 (常數的 public static final 與函式的 public),它們在暗地中就被假設是如此。

在 Java 中,這安排提供我兩種繼承:我可以繼承 (用 extends) 一個類別,同時繼承型別與程式,或透過繼承介面,我可以只繼承 (用 implements) 型別,我可以用不同的規則思考多重繼承:Java 允許型別 (介面) 多重繼承,但有程式的類別只允許單一繼承。

多重型別繼承的好處

顯而易見,允許多重型別繼承的好處是可以在不同時間以不同的型別看待一個物件,假設您在寫交通模擬的軟體,有許多 Car 物件,除了汽車,還有其他活躍物件在您的模擬之中,例如行人、卡車、紅綠燈等,您可能在程式中有一個集中的容器 (一個 List) 來保存這些參與者 (actor):

Actor 在這例子中,可以是一個介面並有一個 act 函式:

接著,您的 Car 可以實作這介面:

注意,由於 Car 只繼承型別,包含 act 函式的特徵,但不含任何程式,因此自身先需提供型別的實作 (act 函式的實作) 才能建立物件。

到目前為止,這只是單一繼承,也可以用繼承類別的方式完成,但想像一下,現在要在螢幕上畫出一連串的物件 (和 actors 不完全相同,因為有些 actor 不用畫在螢幕上,有些畫在螢幕上的物件不是 actor):

在某個時間點,您可能想將模擬的情況儲存到永久的儲存空間裡,而這些物件可能又是另一個不同的容器,要被儲存,他們需要是 Serializable 型別:

在這情況中,假設 Car 物件都在三個容器中 (他可以做動作、要可以畫在螢幕上、可以被儲存),Car 可以實作三個介面:

像這樣的情境非常普遍,有多種 supertypes 可以讓你用不同的觀點看待單一一個物件 (在這例子中是汽車),專注在不同面向,將相似的物件群組在一起,或是根據特定可能的行為來對待它們。

Java 的事件處理模型就是環繞在相同理念下建造出來:事件處理由事件聆聽者 (例如 ActionListener) 完成,通常只需要實作一個函式,需要時,實作的物件就可以被視為聆聽者的型別。

抽象類別

我需要簡單說一下抽象類別 (abstract classes),因為它常納悶如何與介面相處,抽象類別介於類別與介面的中間:它可以定義一個型別並和類別一樣可以擁有程式,但他同時可以有不需實作的抽象函式 (abstract methods),您可以把它們想成部分實作的類別,有些縫隙需要由 subclasses 實作程式去填滿。

在先前的例子,Actor 介面可以用抽象類別取代,act 可以是抽象函式 (因為每種 actor 都有不同的行為,沒有合理的預設行為),但可以有其他程式是所有 actors 共用的。

在這情況下,我可以將 Actor 寫成一個抽象類別,然後 Car 繼承該類別:

如果我想要多個介面包含程式,將它們都轉成抽象類別是沒用的,如同我一開始說的,Java 只允許繼承單一類別 (指在 extends 關鍵字後只能有一個類別),多重繼承只能用在介面上。

有一個方法能解決,透過預設函式 (default methods),待會在介紹 Java 8 時會提到。

空介面

有時候,您會碰見空介面,只有名稱沒有任何函式宣告,剛提到的 Serializable 就是一個空介面,Cloneable 是另一個,這些介面又被稱為標識介面 (marker interfaces),它們將類別標識為某種特殊屬性的處理,相較於定義型別或是定義元件間的合約,用途比較接近提供中介資料 (metadata),從 Java 5 開始,有 annotation 更適合用來提供中介資料,如今在 Java 中使用標識介面的原因已經很少,如果您有興趣,試著用 annotation 取代。

Java 8 的新曙光

到目前為止,我忽略部分 Java 8 所引入的新特性,這是因為 Java 8 新增的功能抵觸早先語言設計時的部分決策 (例如只許允單一程式繼承),這讓解釋某些語言概念之間的關係變得有點困難,例如,辯論介面與抽象類別的不同與存在的理由變得的微妙,如我馬上就會介紹的,Java 8 的介面已被擴充成更像是抽象類別,但還是有些微的不同。

我在這議題的說明上,先帶您走過歷史的軌跡,說明在 Java 8 之前的情境,現在加入 Java 8 的新特性,會這麼做,是因為有鑒於這些歷史,才能瞭解這些特性如今合併的正當性。

如果 Java 團隊現在重新設計 Java,且打破向下相容不是個問題,他們不會以相同的方式設計 Java,然而,Java 語言不只是最早的理論運用,也是實際使用的系統,且在現實世界中,您必須找到方法在不打破任何既有東西的前提下,去進化與擴充您的語言,介面的預設函式和靜態函式是讓 Java 8 前進變可能的兩個機制。

介面進化

開發 Java 8 時的一個問題是如何進化介面,Java 8 加入 lambda 及許多特性到 Java 語言中,也希望用這些用來改造 Java 函式庫中既有的介面,但您如何進化一個介面而不破壞已經使用這介面的程式呢?

想像一下,在您既有的函式庫中,有一個介面 MagicWand

這介面已經在許多專案中被使用與實作,但您現在想到某些超棒的新功能,且您真的想加一個非常有用的新函式:

如果您這麼做,所有實作這介面的類別將會壞掉,因為他們會被要求實作新函式,所以,乍看之下,您被困住了,要不打破既有的程式 (您不想這麼做),要不因沒任何機會改善舊函式庫,所以卡住而失敗 (實際上有幾個可能性可以嘗試 [7],例如用 subinterfaces 擴充既有介面,但仍有其他問題,我不打算在這裡討論),Java 8 想出一個聰明的妙計解決上述兩個處境:在既有介面新增功能但不破壞既有程式,這方法便是預設函式與靜態函式,我接著說明。

預設函式

預設函式是介面裡有實作內容 (預設實作) 的函式,定義它們時,會在函式特徵的開頭加上 default 修飾詞,並提供完整的函式實體:

實作該介面的類別可以選擇覆寫這函式提供它們專屬的實作或是完全忽略這函式,若忽略,則接收介面的預設實作。舊的程式碼繼續運作,同時,新的程式碼可以用新功能。

靜態函式

介面現在可以包含有實作的靜態函式,定義函式特徵時,在開頭加上 static,一如往常,在寫介面時,public 修飾詞是可以省略的,因為介面中的函式與常數永遠都是公開的。

所以,菱形繼承的問題呢?

如您所見,抽象類別與介面現在變得非常相似,雖然語法迥異,但兩者皆可以包含抽象函式與有實作的函式,仍然有些不同 (例如抽象類別可以有實體欄位,可是介面不行),但核心焦點是,從 Java 8 開始,透過介面,您可以有多重的程式繼承!

在文章的一開始,我指出 Java 設計者如何小心地前進避開多重程式繼承,因為多重繼承與名稱衝突可能引發問題,所以現在的情境呢?

像往常一樣,Java 設計者安排以下實用的規則處理這些問題:

  • 繼承多個同名的抽象函式不是問題,他們被視為相同的函式。
  • 困難的問題之一,欄位的菱形繼承,因為介面依然不允許有不是常數的欄位,所以避開了。
  • 繼承靜態函式與常數 (就定義上是靜態的) 不是個問題,因為使用它們時,需加上介面名稱作為前綴,所以名稱不會衝突。
  • 繼承多個同函式特徵的預設函式實作會是個問題,但 Java 和其他語言不同,選擇一個非常識做的做法:與其設計一個新的語言概念來處理這問題,不如讓編譯器直接將此視為錯誤,換句話說,這是您的問題,Java 直接告訴您:別這麼做!

結論

介面是 Java 中非常強大一個特色,在很多情境中都很有用,像是定義程式中不同部分之間的合約、定義分類與動態派遣、將型別的定義與實作分開、以及在 Java 中允許多重繼承。在您的程式中,它們通常非常有用,您應該確保您十分了解它們的行為。

在 Java 8 中,介面的新特色,像是預設函式,在您寫函式庫時非常有用,較少機會用在應用程式的程式碼中,然而,Java 函式庫現在大量地使用它們,所以確定自己熟悉它們能做什麼,小心使用繼承可以大幅改善你的程式碼品質。


譯註

  1. 研究所後期用的書很少有中文翻譯,所以 language construct 的中文是什麼,我還真的沒見過,基本上 language construct 是指組成一個程式語言的元素,像函式、類別、繼承、迴圈等都能稱作是 language construct,但我實在不想翻成語言的組成元素,因為實在是太長了。另外,本文中大多數情況下,construct 和 concept 都指相同的東西,而事實上把類別當成一個概念也不會有太大的問題,所以我把 language construct 翻成語言概念,讓在只讀中文的情況下,確保整篇語意的通順。
  2. 加一個新東西或概念,不一定是真的解決問題,只有正確地使用才會解決問題,但反之,在錯誤的想法下使用新概念,通常只是讓事情變得更糟糕。
  3. 本來想用慣用的中文翻譯,但由於 superclass 和 subclass 的慣用中文不夠「中性」,所以還是決定保留原文。
  4. 以目前 Java 的生態,為了讓 framework 提升 productivity 的決策優於 information hiding 的情況下,幾乎所有的 field 都加上 getter/setter,物件的 property 和 field 幾乎快劃上等號。
  5. 以 Java 來說,signature 包含名稱以及參數的數量、順序與型別。
  6. 雖然 C++ 支援多重繼承,但學習門檻也變超高,真正大量使用多重繼承的例子不多見。
  7. 之前開發 Comic Surfer 我就遇到這問題,而我採用的方式是抽象類別,讓第三方程式依賴在抽象類別上而不是介面上,其實也就是在沒有預設函式的變通作法,但問題是會讓第三方的類別失去唯一的類別繼承。

譯者的告白
當初選擇翻譯這一篇文章,主要是因為我想起兩年前,我也曾寫過探討關於預設函式的文章,但質量差蠻多的,我覺得這一篇寫得非常好,包含設計的抉擇與使用的時機都有很好的描述,對於想使用預設函式的人說,有很好的指引。不過,過了兩年,Java 8 的採用率似乎沒有很理想,甚至網路上不少抱怨討論 Oracle 對 Java 的發展到底是玩真的還是玩假的 (只想用專利告人),看完都覺得好可惜啊,Java 其實還有不少發展空間,往好處想,至少 Google 在 Android N 已經支援大多數 Java 8 的新特色了。
One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.