在計算的初期,程式設計師以「陳述句」思考程式;到了七八十年代,轉為以「常式」為單位思考;而在二十一世紀,**類別(Class)**成為了程式設計的核心。類別是一組資料與操作這些資料的常式之集合,共同承擔一項具有內聚性的職責。成為高效程式設計師的關鍵,在於最大化你可以安全忽略的程式部分,而類別正是達成這個目標的首要工具。

6.1 類別的基礎:抽象資料類型(ADTs)#

**抽象資料類型(Abstract Data Type, ADT)**是一組資料及其操作的集合。操作既對外描述了資料的意義,也提供了改變資料的途徑。理解 ADT 是理解物件導向程式設計的根本。

以控制字體為例,若不使用 ADT,你會散布 currentFont.size = 16currentFont.attribute or 0x02 這類直接操作資料的程式碼。使用 ADT 的好處包括:

好處說明
隱藏實作細節資料型態改變時只需修改一處
介面更具資訊性避免 16 究竟是像素還是點數的歧義
正確性更明顯呼叫 SetBoldOn()attribute or 0x02 容易驗證
自我文件化常式呼叫比位元運算更具可讀性
無須到處傳遞資料ADT 的結構自行持有資料
以真實世界實體操作能以真實世界實體而非底層結構進行操作

設計 ADT 時,用問題領域的語言思考。若堆疊代表一組員工,就以「員工」操作它。命名應獨立於儲存媒介——用 rateTable.Read() 而非 rateFile.Read()。即使是只有開和關的燈,封裝後也更易讀、易改。

ADT 構成了類別概念的基礎。類別可以被理解為 ADT 加上繼承(Inheritance)和多型(Polymorphism)。

6.2 良好的類別介面#

建立高品質類別最重要的第一步,就是設計一個好的介面——為介面建立好的抽象,並確保細節被隱藏在抽象之後。

好的抽象#

準則說明
呈現一致的抽象層級每個類別應實作一個且僅一個 ADT。若 EmployeeCensus 同時暴露「員工」和「清單容器」的操作,就是混合了兩種 ADT
確認要實作哪一種抽象只需格線控制項的 15 個常式時,不要暴露試算表控制項的全部 150 個
成對提供互補操作TurnLightOn() 就應有 TurnLightOff()
將無關資訊移到其他類別若一半常式操作一半資料,其實是兩個類別偽裝成一個
盡量以程式化方式約束介面編譯器能強制的約束比文件描述更可靠
維護介面抽象的完整性不要添加不一致的成員,如在 Employee 中加入 IsZipCodeValid() 或 SQL 方法

同時考慮抽象內聚性:呈現良好抽象的介面通常有強內聚性。當內聚性弱時,反問「這個類別是否呈現了一致的抽象?」

好的封裝#

**封裝(Encapsulation)**比抽象更強——抽象讓你忽略實作細節,封裝則阻止你去看。

封裝的核心準則
  • 最小化可存取性:偏好最嚴格的存取層級(private > protected > public)
  • 不要將成員資料設為 public:使用 getter/setter 維持封裝
  • 避免暴露私有實作細節:C++ 可用 Pimpl 慣用法隱藏 private 區段
  • 不要對類別使用者做假設避免友元類別
  • 偏好讀取時的便利性勝過撰寫時的便利性

語意層面的封裝破壞比語法層面更難防範。例如因為「知道」Retrieve() 會自動連線而不呼叫 Connect(),就是透過實作而非介面在程式設計。正確做法是改善介面文件,而非翻看原始碼。

6.3 有關設計和實作的議題#

包含(Containment,「has a」關係)#

包含是物件導向程式設計中的主力技術。透過將物件作為成員資料來實作「has a」關係。只有萬不得已時才透過 private 繼承實作包含。類別的資料成員數以 7 個左右為上限(7±2 原則)。

繼承(Inheritance,「is a」關係)#

繼承旨在透過基底類別定義共用元素來簡化程式碼,但也增加複雜度。

繼承的設計準則
  • 以 public 繼承實作「is a」:不遵守基底類別介面契約就不該繼承
  • 設計並記錄可繼承性,否則就禁止繼承(C++ 用 non-virtual、Java 用 final)
  • 遵守 Liskov 替換原則(LSP):子類別必須能透過基底類別介面使用,使用者不需知道差異
  • 只繼承你需要的部分:常式分三種——抽象可覆寫、可覆寫(有預設實作)、不可覆寫
  • 不要「覆寫」不可覆寫的成員函式將共用元素盡量往繼承樹上層移動
  • 對只有一個實例、只有一個衍生類別、或覆寫後什麼都不做的類別保持懷疑
  • 避免過深的繼承樹(二到三層是實務上限);偏好多型而非大量 type checking
  • 所有資料成員都應設為 private,不要用 protected:若衍生類別需存取,請提供 protected 的存取函式
  • **多重繼承(Multiple Inheritance)**主要適用於 mixin——如 DisplayableSerializable 等簡單的屬性混入類別。應謹慎評估其對系統複雜度的影響

繼承的規則都指向一個核心訊息:繼承傾向於對抗管理複雜度的首要技術使命。共享資料但非行為時用包含;共享行為但非資料時用繼承;兩者皆有則繼承。想控制介面時用包含,想讓基底類別控制介面時用繼承。

flowchart TD
    A["需要共享什麼?"] --> B{"共享資料但非行為?"}
    B -- 是 --> C["使用包含"]
    B -- 否 --> D{"共享行為但非資料?"}
    D -- 是 --> E["使用繼承"]
    D -- 否 --> F{"兩者皆有?"}
    F -- 是 --> G["使用繼承"]
    F -- 否 --> H{"想控制介面?"}
    H -- 是 --> I["使用包含"]
    H -- 否 --> J{"想讓基底類別控制介面?"}
    J -- 是 --> K["使用繼承"]

成員函式與資料#

  • 保持常式數量盡可能少禁止不需要的隱式生成之運算子
  • 最小化 fan out:減少類別呼叫的不同常式總數
  • 遵守迪米特法則(Law of Demeter)account.ContactPerson() 可以,但 account.ContactPerson().DaytimeContactInfo().PhoneNumber() 不行

建構子#

  • 在所有建構子中初始化所有成員資料
  • 使用 private 建構子強制實施 Singleton 模式
  • 偏好深層複製(Deep Copy),除非有驗證過的效能理由才用淺層複製

6.4 建立類別的原因#

建立類別的理由遠不止於模擬真實世界物件:

理由說明
模擬真實世界或抽象物件ShapeCircleSquare 的抽象
降低複雜度建立類別的最重要理由——隱藏資訊使你無需再想它
隔離複雜度錯誤更易定位與修復
隱藏實作細節限制變更影響範圍
隱藏全域資料透過存取常式包裝
精簡參數傳遞頻繁傳遞同一參數暗示應納入同一類別
建立集中控制點促進程式碼重用(NASA 研究顯示 OO 專案可重用超過 70% 程式碼)
為程式家族做規劃包裝相關操作、完成特定重構

應避免的類別#

  • 上帝類別(God Classes):不斷透過 Get()/Set() 操作他人資料的類別,功能應歸屬於被操作的類別
  • 無關緊要的類別:只有資料沒行為,考慮降級為其他類別的屬性
  • 以動詞命名的類別:只有行為沒資料(如 DatabaseInitialization),應成為其他類別的常式

6.5 與具體程式語言相關的問題#

不同語言對類別的處理方式差異顯著。例如:Java 中常式預設可覆寫(需用 final 防止);C++ 預設不可覆寫(需用 virtual 允許)。其他差異領域包括繼承樹中建構子/解構子行為、例外處理條件下的行為、預設建構子的重要性、覆寫內建運算子的適當性,以及物件建立與銷毀時的記憶體處理。

6.6 超越類別:套件(Package)#

類別是目前達成模組化的最佳方式,但模組化是個更大的課題。軟體開發的進步很大程度來自於不斷提高聚合的粒度——從陳述句、到副程式、到類別。

若程式語言不直接支援套件,可透過命名慣例區分公開與私有類別、以專案結構標示所屬套件、以規則定義套件間的使用關係。

更多資源#

  • Meyer, Bertrand. Object-Oriented Software Construction, 2d ed., 1997 – ADT 基礎與繼承深入討論
  • Riel, Arthur J. Object-Oriented Design Heuristics, 1996 – 類別層級設計的實用建議
  • Meyers, Scott. Effective C++ / More Effective C++ – C++ 經典參考
  • Bloch, Joshua. Effective Java Programming Language Guide, 2001 – Java 與物件導向實踐

要點#

  • 類別介面應提供一致的抽象,許多問題都源自於違反這項原則。
  • 類別介面應隱藏某些東西——系統介面、設計決策或實作細節。
  • 除非是模擬「is a」關係,否則包含通常優於繼承
  • 繼承是有用的工具,但它增加複雜度,這與軟體管理複雜度的首要技術使命相悖。
  • 類別是你管理複雜度的主要工具,應給予其設計足夠的關注。