為何 Rust 與 Go 選擇組合而非繼承

注意:此文章由AI生成

第一節:繼承的遺產:對傳統物件導向的批判性重估

在軟體工程的演進歷程中,物件導向程式設計 (Object-Oriented Programming, OOP) 無疑是一座重要的里程碑。在其核心概念中,「繼承」(Inheritance) 長期以來被視為實現程式碼重用與多型 (Polymorphism) 的基石。然而,隨著系統規模與複雜度的急劇增長,開發者社群開始重新審視這個曾經被奉為圭臬的設計模式。現代系統級程式語言,如 Rust 與 Go,在設計之初便做出了大膽的決定——摒棄傳統的類別繼承機制,轉而擁抱「組合」(Composition)。這個決策並非偶然,而是基於對繼承內在缺陷的深刻反思與對軟體設計原則演進的洞察。本報告旨在深入剖析此一典範轉移背後的技術與哲學考量,探討古典繼承的根本問題,並闡述 Rust 與 Go 如何透過組合與其各自的獨特機制,開創出一條更為穩健、靈活的軟體建構之路。

1.1 「是一種」(Is-A) 關係:程式碼重用與多型的承諾

繼承的核心思想是建立一種「是一種」(Is-A) 的關係 。它允許一個新的類別(稱為子類別或衍生類別)基於一個已有的類別(稱為父類別或基底類別)來定義,從而繼承其公開 (public) 和受保護 (protected) 的屬性與方法 。例如,在一個動物分類系統中,我們可以定義一個  

Dog 類別,它繼承自 Animal 類別,這便直觀地表達了「狗是一種動物」的概念。

這種設計模式最初帶來了兩大顯著的承諾:

  1. 程式碼重用 (Code Reuse):子類別可以直接使用父類別中已實現的功能,無需重新撰寫相同的程式碼。這在早期被認為是提高開發效率、減少程式碼冗餘的有效手段 。  
  2. 多型 (Polymorphism):繼承是實現多型的關鍵機制之一。它允許程式碼將子類別的實例視為父類別的實例來處理。例如,一個期望接收 Animal 物件的函式,同樣可以接收 DogCat 的實例,並在執行期間調用它們各自特定的行為(如 makeSound() 方法)。  

由於其直觀性和強大的表達能力,繼承迅速成為 OOP 教學的核心。許多開發者的入門第一課便是學習如何透過 extends 或類似的關鍵字來擴展類別,導致他們在潛意識中將繼承視為擴展系統功能的首選甚至唯一途徑 。然而,正是這種看似美好的承諾,在長期的實踐中逐漸顯露出其脆弱的本質。  

1.2 基礎的裂痕:古典繼承的內在問題

隨著軟體專案變得日益龐大和複雜,開發者們發現,過度或不當地使用繼承會引入一系列難以管理的問題。這些問題並非無關痛癢的小瑕疵,而是足以動搖整個軟體架構穩定性的深層次裂痕。

1.2.1 脆弱基底類別問題 (The Fragile Base Class Problem – FBCP)

脆弱基底類別問題(FBCP)是繼承機制最廣為人知的弊病之一。它描述了一種現象:對基底類別進行看似無害的修改,卻可能意外地破壞其所有衍生類別的正常運作 。這種修改可能僅僅是內部實作的重構,而非公開介面的變更,但其連鎖反應卻是災難性的。  

例如,假設一個基底類別 Base 的作者為了優化程式碼,將一個公開方法 publicMethod() 的部分邏輯重構到一個新的私有方法 privateHelper() 中。後來,某個子類別 Sub 的開發者覆寫了 publicMethod(),並在其內部邏輯中對 Base 的狀態做出了某些假設。如果 Base 的作者未來再次修改 publicMethod() 的實作,比如不再呼叫 privateHelper(),或者改變了呼叫順序,這就可能違反 Sub 開發者所做的隱含假設,導致 Sub 的行為出現非預期的錯誤 。更經典的例子是,若基底類別的一個方法  

n() 內部開始呼叫另一個方法 m(),而子類別恰好覆寫了 m() 並在其中呼叫 n(),這將引發無限遞迴 。  

這個問題的根源在於,繼承本質上破壞了物件導向設計中最重要的原則之一——封裝 (Encapsulation)。在權威的《設計模式:可複用物件導向軟體的基礎》一書中,作者們明確指出「繼承常常會破壞封裝性」。這句話一針見血地揭示了 FBCP 的成因。  

封裝的核心理念是將物件的狀態(資料)和行為(方法)捆綁在一起,並對外部世界隱藏其內部實作細節。外部程式碼應該只透過物件的公開介面 (API) 與之互動,而不應關心其內部是如何運作的。然而,繼承創造了一種「白箱式」的程式碼重用關係 。子類別不僅僅是存取父類別的公開介面(  

what it does),它還常常會不自覺地依賴於父類別的實作細節(how it does it)。子類別的正確性,可能建立在父類別某個未被文檔化的內部行為之上。  

當父類別的維護者(他理應有權自由地重構內部實作)進行修改時,他無法預知這些修改會對散佈在各處的無數子類別產生何種影響。這種緊密的、跨越封裝邊界的依賴關係,使得基底類別變得「脆弱」——任何改動都如履薄冰。

相比之下,組合提供的是一種「黑箱式」的重用。一個物件包含另一個物件作為其一部分,並只透過該物件的公開介面與之互動。只要這個公開介面保持穩定,內部實作的任何變化都不會影響到包含它的物件。這種對封裝的尊重,正是現代語言設計者們傾向於組合而非繼承的根本原因之一。

1.2.2 菱形問題:多重繼承的歧義性

當一個語言試圖允許一個類別同時繼承自多個父類別時(即多重繼承),「菱形問題」(The Diamond Problem) 便會浮現。這個問題的結構如下:假設類別 D 同時繼承自類別 B 和類別 C,而 BC 又都繼承自同一個基底類別 A 。  

    A
   / \
  B   C
   \ /
    D

如果類別 A 中定義了一個方法 method(),並且 BC 可能都對這個方法進行了覆寫 (override)。那麼,當我們在 D 的實例上呼叫 method() 時,編譯器就陷入了困境:它應該使用來自 B 的版本,還是來自 C 的版本?這種模稜兩可的狀態導致了編譯錯誤 。像 C#、Java 等語言為了從根本上避免這個問題,直接禁止了類別的多重繼承 。  

雖然存在一些技術性的解決方案,例如 C++ 允許程式設計師透過作用域解析運算子 (D_instance.B::method()) 來明確指定使用哪個版本,或者像 Python 那樣使用方法解析順序 (Method Resolution Order, MRO) 的演算法來確定一個線性的繼承鏈。但這些方案都增加了語言的複雜性,並且其選擇往往帶有一定的人為武斷性 。  

然而,菱形問題的深層次癥結並非純粹的技術難題,而是一個概念模型上的根本缺陷。它暴露了類別繼承在模擬現實世界中多重身份或多重能力時的無力。現實世界中的物件往往擁有多個正交(獨立)的屬性或行為。例如,一個人既可以是「藝術家」(Artist),也可以是「槍手」(Gunfighter),這兩者可能都繼承自「人」(Person)。如果 ArtistGunfighter 都有一個名為 draw() 的方法,其語意卻截然不同——一個是「繪畫」,另一個是「拔槍」。試圖透過繼承將這兩種行為合併到一個  

ArtistGunfighter 類別中,在邏輯上是荒謬且無法解決的。

這說明,單一、僵化的「是一種」階層結構,不足以描述一個物件「擁有多種能力」的場景 。一個物件的身份(它  

什麼)和它的能力(它什麼)是兩個不同的維度。傳統 OOP 語言如 Java 和 C# 透過允許「多重介面實作」但只允許「單一類別繼承」來部分解決這個問題 。這實際上是一種妥協,承認了類別繼承應用於專精化核心身份,而介面(一種行為契約)更適合用來添加各種能力。Rust 和 Go 則將這一邏輯推向了極致:它們徹底移除了類別繼承,完全依賴於更靈活的組合模型來賦予物件行為。  

1.2.3 緊密耦合與階層僵化

除了上述兩個核心問題,繼承還帶來了其他顯著的缺點:

  • 緊密耦合 (Tight Coupling):繼承在父類別和子類別之間建立了程式碼中最緊密的一種耦合關係 。父類別的任何變更,即使只是增加一個新方法,都可能與子類別中已有的方法產生衝突,導致子類別需要修改甚至無法編譯 。這種依賴性使得系統變得非常僵硬,難以適應需求變更。  
  • 階層僵化 (Hierarchical Rigidity):一旦繼承體系建立起來,特別是當階層很深時,重構的成本會變得極其高昂 。想要在繼承鏈的中間插入一個新的基底類別,或者改變一個類別的父類別,都可能引發影響整個子樹的連鎖反應。此外,在大多數語言中,一個物件的類別在實例化後便是固定的,無法在執行期間動態地改變其行為,例如將一個   CheckingAccount 物件變更為 SavingsAccount 物件,即使它們都繼承自 Account 。這種靜態的、編譯時期就鎖定的關係,大大限制了程式的靈活性。  

總結而言,繼承雖然提供了一種直觀的程式碼重用方式,但其代價是犧牲了封裝性、靈活性和可維護性。脆弱基底類別問題、菱形繼承的困境以及緊密耦合的階層,共同構成了一幅脆弱且僵化的軟體架構圖景。正是對這些問題的深刻體認,促使新一代程式語言的設計者們尋求一種更優越的替代方案。

第二節:組合的興起:設計原則的演進

面對繼承所帶來的種種挑戰,軟體設計領域逐漸轉向一個更為靈活且穩健的典範——組合。組合併非一個全新的概念,但它在現代軟體工程中的地位日益提升,從一個可選的設計技巧,演變成為一項被廣泛推崇的核心設計原則。

2.1 「有一個」(Has-A) 關係:由小見大的建構哲學

與繼承的「是一種」(Is-A) 關係相對,組合模型化的是一種「有一個」(Has-A) 或「是…的一部分」(Part-of) 的關係 。在這種模式下,一個複雜的物件是透過包含或「組合」其他較簡單的物件來建構的。例如,一輛  

Car 物件「有一個」Engine 物件,一個 Person 物件「有一個」Job 物件 。  

這種設計哲學的核心在於,將大型、複雜的系統分解為一系列小型的、專注於單一職責的、可獨立開發和測試的元件。然後,像搭積木一樣,將這些元件組裝起來,形成功能更為強大的聚合體 。這種從部分到整體的建構方式,被認為比試圖為所有物件尋找一個共同的祖先並建立一個龐大的家族樹,更能自然地對映許多現實世界的業務領域 。  

2.2 「多用組合,少用繼承」:來自設計模式的指導方針

「多用組合,少用繼承」(Favor Composition Over Inheritance) 這一設計原則的普及,極大地推動了組合模式的應用。這條原則最早由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(合稱「四人幫」,Gang of Four, GoF)在他們於 1994 年出版的經典著作《設計模式:可複用物件導向軟體的基礎》中正式提出 。  

這條原則並非要完全禁止使用繼承,而是建議開發者在面臨程式碼重用和擴展功能的需求時,應優先考慮使用組合 。其背後的邏輯是,許多開發者,特別是初學者,會濫用繼承。他們僅僅為了重用基底類別中的幾個輔助方法,就輕率地建立繼承關係,而忽略了這兩個類別之間可能根本不存在真正的「是一種」多型關係 。  

繼承的正確使用場景應該是嚴格遵守「里氏替換原則」(Liskov Substitution Principle, LSP) 的多型化設計。LSP 指出,任何基底類別可以出現的地方,子類別一定可以出現,並且替換後不會產生任何錯誤或異常。當一個繼承關係僅僅是為了程式碼共享而建立,卻不滿足 LSP 時,它就很容易演變成脆弱基底類別問題的溫床。因此,GoF 的這條指導方針旨在提醒開發者:將繼承保留給真正需要建立子類型多型的場景,而在其他所有情況下,都應優先選擇組合 。  

2.3 組合優先的優勢:彈性、封裝與可測試性

當開發者遵循「多用組合,少用繼承」的原則時,他們會發現組合帶來了多方面的顯著優勢,直接解決了繼承的諸多痛點。

  • 彈性 (Flexibility):組合提供了無與倫比的彈性。
    • 鬆散耦合 (Loose Coupling):組合中的物件之間是鬆散耦合的。容器物件只依賴於元件物件的公開介面,不關心其內部實作。這意味著元件的內部修改不會影響到容器 。  
    • 執行期變更行為:與繼承的靜態關係不同,組合允許在程式執行期間動態地改變物件的行為。只需將物件內部的一個元件替換為另一個具有相同介面的不同實作,物件的行為就會隨之改變,而無需改變物件本身的類別 。  
    • 避免「子類別爆炸」:當需要為物件添加多個獨立的功能時,繼承會導致「子類別爆炸」——即需要為每種功能的組合都創建一個新的子類別。例如,一個 Logger 可能需要 FileLoggerSocketLogger,如果再引入過濾功能,就需要 FilteredFileLoggerFilteredSocketLogger 。而使用組合,只需將   FilterOutputDestination 作為可配置的元件注入到 Logger 中即可,極大地提高了設計的靈活性。
  • 封裝 (Encapsulation):組合更好地尊重和保護了封裝。
    • 黑箱重用:如前所述,組合是一種「黑箱」重用。容器物件無法也無需存取元件的內部狀態或私有方法,只能透過其定義良好的公開 API 進行互動,這維護了元件的封裝完整性 。  
    • 更精細的存取控制:使用繼承時,子類別會自動獲得父類別所有 publicprotected 的成員。這可能暴露一些子類別本身並不需要或不應該對外提供的功能,甚至可能引入安全漏洞 。而使用組合,容器物件可以作為一個「門面」(Facade),選擇性地只暴露元件的一部分功能,從而實現更嚴格的存取控制 。  
  • 可測試性 (Testability):組合顯著地簡化了單元測試。
    • 易於隔離和模擬:在測試一個使用組合的物件時,其依賴的元件是明確的、可替換的。測試框架可以輕易地用「模擬物件」(Mock Object) 來取代真實的元件,從而將被測物件與其依賴項完全隔離,專注於測試其自身邏輯 。  
    • 簡化測試範圍:相比之下,測試一個繼承體系中的子類別要困難得多。由於子類別與父類別緊密耦合,測試子類別時很難不牽涉到父類別的行為,有時甚至需要測試整個繼承鏈上的所有方法,這大大增加了測試的複雜度和工作量 。  

綜上所述,組合透過其鬆散耦合、尊重封裝和易於測試的特性,提供了一種比繼承更為靈活、穩健和可維護的軟體建構方式。正是這些壓倒性的優勢,使得 Go 和 Rust 等現代語言的設計者們毅然決然地選擇了組合作為其物件導向設計的核心範式。

第三節:Go 的實現:透過組合與介面達成的務實主義

Go 語言(又稱 Golang)的設計者們在創造這門語言時,明確地選擇了一條與傳統 OOP 語言截然不同的道路。他們摒棄了類別繼承,轉而採用一種基於組合和介面的獨特方法來實現程式碼重用和多型。這個決策並非標新立異,而是其核心設計哲學——務實主義與簡潔主義——的直接體現。

3.1 核心設計哲學:簡潔、高效與務實

要理解 Go 為何放棄繼承,首先必須理解其誕生的背景和設計目標。Go 於 2007 年在 Google 內部構思,旨在解決大型軟體基礎設施開發中遇到的問題,特別是 C++ 等語言帶來的「緩慢和笨拙」。其設計哲學可以概括為以下幾點:  

  • 簡潔至上 (Simplicity):Go 的設計者們刻意追求語言的簡潔性。它只有 25 個關鍵字,語法清晰明瞭,旨在讓程式設計師能夠將語言規範輕鬆地記在腦中 。語言的目標是提供一種「做某件事只有一種明顯方法」的體驗,從而讓開發者能專注於解決實際問題,而不是在繁複的語言特性中糾結 。  
  • 高效開發 (Developer Productivity):快速的編譯速度、內建的垃圾回收、強大的併發模型(Goroutines 和 Channels)以及簡潔的語法,共同構成了一個高效的開發環境 。  
  • 務實主義 (Pragmatism):Go 是一門為軟體工程而生的語言,而非為了程式語言理論研究。它的每一個特性都旨在解決大規模、多人協作專案中的實際痛點,如依賴管理、程式碼可讀性和可維護性 。  

在這樣的設計哲學指導下,傳統的類別繼承機制顯得格格不入。繼承所帶來的複雜性——如脆弱基底類別問題、菱形繼承的困境、複雜的建構函式鏈、方法覆寫規則以及深層次的階層結構——完全違背了 Go 對簡潔和可預測性的追求。因此,Go 的設計者們做出了一個理性的選擇:徹底移除這個複雜且問題叢生的特性,並尋找更簡單、更直接的替代方案 。  

3.2 透過「結構體嵌入」實現程式碼重用

Go 語言實現組合式程式碼重用的主要機制是結構體嵌入 (Struct Embedding) 。這是一種語法上的便利,允許將一個結構體類型直接聲明在另一個結構體中,而無需為其指定欄位名稱。  

例如,假設我們有一個 base 結構體和一個 container 結構體:

Go

import "fmt"

type base struct {
    num int
}

func (b base) describe() string {
    return fmt.Sprintf("base with num=%v", b.num)
}

type container struct {
    base  // 嵌入 base 結構體
    str string
}

在這個例子中,container 結構體嵌入了 base 結構體。這樣做的結果是,base 結構體的所有欄位和方法都被「提升」(promoted) 到了 container 結構體中 。這意味著我們可以像操作  

container 自己的成員一樣,直接存取 base 的成員:

Go

func main() {
    co := container{
        base: base{num: 1},
        str:  "some name",
    }

    // 直接存取嵌入結構體的欄位
    fmt.Printf("co.num = %v\n", co.num) // 輸出: co.num = 1

    // 直接呼叫嵌入結構體的方法
    fmt.Println("describe:", co.describe()) // 輸出: describe: base with num=1
}

如程式碼所示,我們可以透過 co.numco.describe() 直接存取,而不需要寫成更為冗長的 co.base.numco.base.describe()(儘管後者也是合法的)。這種機制極大地減少了實現組合時所需的樣板程式碼 (boilerplate),使得程式碼重用變得非常直接和方便 。  

然而,必須強調的是:結構體嵌入不是繼承 。  

containerbase 仍然是兩個完全不同的類型。你不能將一個 container 的實例傳遞給一個期望 base 類型參數的函式。它僅僅是一種編譯器層面的語法糖,用於自動轉發對嵌入類型欄位和方法的呼叫,其本質仍然是組合(「有一個」關係),而非繼承(「是一種」關係)。

3.3 透過「隱式介面」實現多型

Go 語言的多型機制完全建立在其獨特的介面 (Interface) 系統之上 。Go 的介面是一種抽象類型,它定義了一組方法的簽名(方法名稱、參數和返回值),但不包含任何實作 。  

Go 介面最與眾不同的特性是其隱式實現 (Implicit Implementation)。一個具體類型(通常是結構體)如果實作了某個介面所要求的所有方法,那麼它就自動地、隱式地滿足了該介面,無需像 Java 或 C# 那樣使用 implements 關鍵字進行顯式聲明 。這種基於行為(方法集)而非名稱的類型匹配方式,被稱為「結構化類型」(Structural Typing),它在編譯時期進行檢查,因此兼具了動態語言(如 Python)「鴨子類型」(Duck Typing) 的靈活性和靜態語言的類型安全。  

讓我們透過一個經典的例子來理解這一點:

Go

import (
    "fmt"
    "math"
)

// 定義一個 Shape 介面
type Shape interface {
    Area() float64
}

// 定義 Circle 結構體
type Circle struct {
    Radius float64
}

// 為 Circle 實作 Area 方法
func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

// 定義 Square 結構體
type Square struct {
    Length float64
}

// 為 Square 實作 Area 方法
func (s Square) Area() float64 {
    return s.Length * s.Length
}

// 一個接受 Shape 介面類型參數的函式
func PrintArea(s Shape) {
    fmt.Printf("Area of shape is: %f\n", s.Area())
}

func main() {
    c := Circle{Radius: 5}
    s := Square{Length: 4}

    // Circle 和 Square 的實例都可以傳遞給 PrintArea
    PrintArea(c) // 輸出: Area of shape is: 78.539816
    PrintArea(s) // 輸出: Area of shape is: 16.000000
}

在這個例子中,CircleSquare 都定義了簽名為 Area() float64 的方法,因此它們都隱式地滿足了 Shape 介面。PrintArea 函式接受一個 Shape 介面類型的參數,這使得它可以處理任何滿足該介面的具體類型,實現了多型 。  

Go 的隱式介面帶來了一種極其強大的能力,可以稱之為**「後設多型」或「追溯性多型」(Post-hoc or Retroactive Polymorphism)**。在 Java 或 C# 這樣的名義類型系統 (Nominal Typing) 中,一個類別必須在定義時就聲明它要實作哪些介面。這意味著你無法讓一個來自第三方函式庫的、你無法修改其原始碼的類別,去實作你自己定義的新介面。你唯一的選擇是創建一個包裝類 (Wrapper/Adapter) 來間接實現。

但在 Go 中,你完全可以在你的程式碼中定義一個新的介面,而一個來自第三方函式庫的類型,只要它恰好擁有該介面要求的所有方法,它就自動滿足了你的新介面,無需對其原始碼做任何改動 。這種能力極大地增強了程式碼的解耦性和適應性,是 Go 介面設計中最深刻和最具影響力的一點。  

3.4 綜合分析:Go 模式的優勢與權衡

Go 語言選擇的「結構體嵌入 + 隱式介面」的組合式設計道路,展現了其鮮明的優勢和一些權衡。

  • 優勢
    • 簡潔與低認知負擔:整個模型非常簡單直觀,避免了繼承的複雜性,降低了開發者的學習和使用成本 。  
    • 高度靈活性與解耦:隱式介面提供了極高的靈活性,允許在不修改既有程式碼的情況下建立新的抽象,促進了模組間的鬆散耦合 。  
    • 務實的程式碼重用:結構體嵌入是一種非常務實的解決方案,它以最小的語法開銷解決了 90% 的程式碼重用需求 。  
  • 權衡
    • 類型系統表達力有限:與 Rust 相比,Go 的類型系統較為簡單,無法在編譯時期對複雜的業務邏輯或狀態進行精細的約束 。  
    • 執行期分派開銷:Go 的介面呼叫總是透過動態分派(類似虛擬函式表)來實現,這在效能極端敏感的場景下,相較於 Rust 可選的靜態分派,會存在一定的執行期開銷 。  
    • 缺乏泛型(歷史問題):在 Go 1.18 引入泛型之前,處理不同類型的集合或編寫泛用演算法通常需要依賴 interface{}(空介面)和執行期類型斷言,這不僅程式碼冗長,也犧牲了編譯時期的類型安全。雖然泛型的加入已在很大程度上解決了這個問題,但它反映了 Go 在追求簡潔時所做的一些早期權衡。

總體而言,Go 的設計選擇是其務實哲學的完美體現。它放棄了傳統繼承的理論包袱,提供了一套簡單、高效且極其靈活的工具,讓開發者能夠快速、穩健地構建現代化的網路服務和分散式系統。

第四節:Rust 的實現:透過 Trait 與組合達成的正確性

與 Go 追求簡潔和開發效率的務實主義不同,Rust 的設計哲學將正確性 (Correctness)、控制力 (Control) 和效能 (Performance) 置於最高優先順序。它同樣摒棄了傳統的類別繼承,但其替代方案——以 Trait 為核心的組合模式——展現了一種截然不同的設計取向,旨在提供強大的編譯期保證和對底層實現的精確控制。

4.1 核心設計哲學:零成本抽象與記憶體安全

Rust 的設計理念可以透過兩個核心原則來理解:

  1. 記憶體安全 (Memory Safety):Rust 最著名的特性是其所有權 (Ownership) 系統、借用檢查器 (Borrow Checker) 和生命週期 (Lifetimes)。這一套機制在編譯時期就嚴格地保證了記憶體安全(無懸掛指標、無資料競爭等),而無需依賴執行期的垃圾回收器 (Garbage Collector) 。這使得 Rust 能夠兼具 C/C++ 的底層控制力和高階語言的安全性。  
  2. 零成本抽象 (Zero-Cost Abstractions):這是 Rust 效能承諾的基石。該原則指出,程式設計師不應該為他們未使用的東西付出代價;更重要的是,當你使用高階的語言抽象時,其產生的程式碼效能不應該比你手動編寫的、對應的低階程式碼更差 。  

Rust 對繼承的摒棄,正是其「零成本抽象」哲學的直接產物。傳統 OOP 的多型通常依賴於動態分派(Dynamic Dispatch),例如 C++ 的虛擬函式(v-table)。這種機制在執行期進行方法查找,會帶來一定的效能開銷。對於一門旨在成為 C++ 競爭者的系統程式語言來說,如果無法讓開發者選擇性地規避這種開銷,是不可接受的 。  

因此,Rust 的設計者們創造了一套以 Trait 為中心的系統。這個系統不僅提供了強大的抽象能力,還將效能的選擇權明確地交還給了開發者,允許他們在編譯期靜態分派和執行期動態分派之間做出權衡。這正是 Rust 設計哲學的精髓所在:提供高階的、安全的抽象,同時不犧牲對底層效能的極致控制。

4.2 透過「結構體」聚合資料

與 Go 類似,Rust 使用結構體 (Struct) 來定義自訂的資料類型,透過聚合不同的欄位來實現資料的組合 。這是一種直接的「有一個」(Has-A) 關係。  

Rust

struct User {
    username: String,
    email: String,
    active: bool,
}

struct Rectangle {
    width: u32,
    height: u32,
}

Rust 強烈鼓勵使用組合模式來構建複雜的物件。例如,在實現「複合模式」(Composite Pattern) 時,一個 Folder 結構體可以包含一個 Vec(向量),裡面存放著許多實作了同一個 Component Trait 的物件(可以是 File 或其他 Folder。  

值得注意的是,與 Go 的結構體嵌入不同,Rust 沒有自動的方法提升或欄位提升。如果一個結構體 A 包含了一個結構體 B 作為其欄位(struct A { b_field: B }),那麼你必須透過 a.b_field.method() 來呼叫 B 的方法。這種設計雖然比 Go 的嵌入更為冗長,但它也更為明確 (explicit)。程式碼的讀者可以清楚地看到方法的呼叫鏈,知道哪個方法屬於哪個物件,這完全符合 Rust 追求清晰和無歧義的設計風格 。  

4.3 透過「Trait」定義共享行為:Rust 的多型引擎

在 Rust 中,Trait 是定義共享行為的核心機制 。一個 Trait 類似於其他語言中的介面,它是一組方法簽名的集合,任何類型都可以去實現這個 Trait,從而表明自己具備了該 Trait 所描述的行為。  

  • 定義與實現:使用 trait 關鍵字定義一個 Trait,然後使用 impl Trait for Type 語法為一個具體的類型(如 structenum)實現該 Trait 。這是一個名義類型系統 (Nominal System),實現關係必須被顯式聲明。  

Rust

pub trait Summary {
    fn summarize_author(&self) -> String;

    // Trait 可以有預設實作
    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
}

// 為 Tweet 類型實現 Summary Trait
impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
    // 此處可以選擇覆寫 summarize(),若不覆寫則使用預設實作
}
  • 強大特性
    • 預設實作 (Default Implementations):Trait 可以為其部分或全部方法提供預設實作,這可以減少實現 Trait 時的樣板程式碼 。  
    • 孤兒規則 (Orphan Rule):Rust 允許你為外部的類型實現你本地的 Trait,或者為你本地的類型實現外部的 Trait。唯一的限制是,impl Trait for Type 這個實現必須至少有一個(TraitType)是在當前 crate(Rust 的編譯單元)中定義的。這條「孤兒規則」在保證全域一致性的同時,提供了巨大的靈活性,例如你可以為標準庫中的 Vec<T> 實現你自己定義的 MySerializable Trait 。  
    • 關聯類型與泛型:Trait 比傳統介面更為強大,它們可以包含關聯類型 (Associated Types) 和泛型參數,使其能夠表達更複雜的類型級別的關係,甚至被用來進行「類型級別的程式設計」。  

4.4 靜態分派 vs. 動態分派:impl Traitdyn Trait 的選擇

這是 Rust Trait 系統最關鍵、也是與 Go 介面最本質的區別所在,完美體現了其「零成本抽象」的哲學。

  • 靜態分派 (Static Dispatch): 當你使用泛型和 Trait 約束 (Trait Bounds) 來編寫函式時,Rust 會在編譯時期進行單態化 (Monomorphization) 。這意味著編譯器會為你傳入的每一個具體類型,都生成一份該函式的特化版本。   Rust// 語法 1: impl Trait pub fn notify(item: &impl Summary) { /*... */ } // 語法 2: Trait Bound pub fn notify<T: Summary>(item: &T) { /*... */ } 在這兩種寫法中,當你用 Tweet 的實例呼叫 notify 時,編譯器會生成一個專門處理 &Tweetnotify 函式版本。所有的 item.summarize() 呼叫都會被直接編譯成對 Tweet::summarize 的靜態函式呼叫。這個過程沒有任何執行期開銷,其效能與直接呼叫具體類型的函式完全相同 。這是 Rust 的預設行為,確保了極致的效能。  
  • 動態分派 (Dynamic Dispatch): 然而,在某些場景下,我們需要在執行期處理一個包含多種不同類型物件的集合,只要它們都實作了同一個 Trait。例如,一個繪圖應用程式可能需要一個列表來存放所有可繪製的物件,如 CircleSquareTriangle 等。在這種情況下,由於編譯器在編譯時期無法知道容器中具體存放的是哪些類型,靜態分派便不再適用。為此,Rust 提供了Trait 物件 (Trait Objects),使用 dyn 關鍵字來表示。Rustpub trait Drawable { fn draw(&self); } // 一個存放不同形狀的向量,它們都實作了 Drawable let shapes: Vec<Box<dyn Drawable>> = vec!; for shape in shapes.iter() { shape.draw(); // 此處發生動態分派 } Box<dyn Drawable> 是一個 Trait 物件。它是一個「胖指標」(fat pointer),內部包含兩部分:一個指向實際資料(如 Circle 實例)的指標,和一個指向該類型對 Drawable Trait 實作的虛擬函式表 (v-table) 的指標 。當   shape.draw() 被呼叫時,程式會在執行期透過 v-table 查找到對應的 draw 函式並執行。這提供了執行期的靈活性,但代價是輕微的效能開銷(一次間接呼叫)和無法進行內聯優化 。  

這種讓開發者明確選擇分派方式的設計,是 Rust 系統程式設計能力的核心。它允許開發者在效能關鍵路徑上使用零成本的靜態分派,而在需要異質集合等靈活性的地方,則可以選擇性地「支付」動態分派的成本。

4.5 綜合分析:Rust 模式的威力與精確性

Rust 的「結構體組合 + Trait」模型,提供了一個極其強大且精確的系統。

  • 優勢
    • 極致的效能與控制:透過對分派方式的選擇,開發者可以對程式的效能進行細粒度的控制,實現真正的零成本抽象 。  
    • 無與倫比的安全性:強大的類型系統和 Trait 約束,可以在編譯時期捕捉到大量的邏輯錯誤,甚至可以將複雜的業務規則編碼到類型中,使得「無效的狀態根本無法被表示」。  
    • 高度表達力:Trait 系統(特別是其泛型和關聯類型)的表達能力遠超傳統介面,能夠構建出高度通用且類型安全的抽象。
  • 權衡
    • 陡峭的學習曲線:所有權、借用檢查器和生命週期的概念對於來自其他語言的開發者來說,是一個巨大的挑戰 。  
    • 較高的冗長度:與 Go 相比,Rust 的語法通常更為冗長。編譯器的嚴格性也可能讓初期的原型開發和探索感覺更慢 。  
    • 複雜性:為了提供極致的控制力和安全性,語言本身引入了較高的複雜度。開發者需要思考更多關於記憶體佈局、生命週期和 Trait 約束的細節。

總結來說,Rust 的設計選擇反映了其對「正確性優先」的承諾。它提供了一套工具,用於構建那些對效能、可靠性和安全性有著最嚴苛要求的系統,即使這意味著需要開發者投入更多的學習成本和編寫更為精確的程式碼。

第五節:深度比較分析:Go vs. Rust

雖然 Go 和 Rust 都摒棄了傳統的類別繼承,但它們所採用的基於組合的替代方案在哲學、機制和實踐上存在著深刻的差異。這種差異根植於它們截然不同的設計目標:Go 優先考慮開發者的生產力和簡潔性,而 Rust 則將效能、控制力和編譯期正確性放在首位。

5.1 程式碼重用機制:結構體嵌入 vs. Trait 組合模式

  • Go 的結構體嵌入 (Struct Embedding) 是一種語法糖,其核心目標是減少樣板程式碼。透過自動提升嵌入類型的方法和欄位,Go 使得組合的寫法幾乎和繼承一樣簡潔 。這是一種務實的設計,專注於解決最常見的程式碼重用場景,讓開發者能快速地將已有的功能模組聚合到新的類型中。然而,這種便利性是單向的:它只解決了「包含」關係的程式碼重用,但並未提供更深層次的抽象或多型能力。  
  • Rust 的組合模式 (Composition Patterns) 則更為明確和傳統。Rust 中沒有結構體嵌入這樣的語法糖。要重用程式碼,開發者需要明確地將一個結構體作為另一個結構體的欄位,並手動編寫委派方法 (delegation methods) 來暴露所需的功能。雖然這會導致更多的樣板程式碼,但它也使得物件之間的關係更加清晰透明 。Rust 更傾向於透過設計模式(如複合模式、裝飾器模式)和強大的 Trait 系統來組織和重用行為,而不是依賴單一的語言特性 。  

5.2 多型實現:Go 介面 vs. Rust Trait

這是兩種語言之間最核心的區別之一,直接反映了它們的類型系統哲學。

  • Go 的介面 (Interfaces)隱式的、結構化的
    • 實現方式:一個類型只要擁有介面所要求的所有方法,就自動實現了該介面,無需顯式聲明 。  
    • 分派方式:介面呼叫總是動態分派的,透過執行期的方法查找來實現 。  
    • 核心優勢:極致的靈活性和解耦。它允許對第三方庫中的類型進行「追溯性」的介面實現,這在名義類型系統中是無法做到的 。這使得 Go 在構建大型、鬆散耦合的系統時具有獨特的優勢。  
  • Rust 的 Trait顯式的、名義化的
    • 實現方式:必須使用 impl Trait for Type 語法來明確聲明一個類型實現了某個 Trait。
    • 分派方式:提供了靜態分派(預設)和動態分派(可選) 兩種選擇。開發者可以根據效能需求和設計場景做出權衡 。  
    • 核心優勢:極致的效能、控制力和類型安全。Trait 系統更為強大,支持關聯類型、泛型約束等高階特性,能夠在編譯時期表達和驗證非常複雜的類型關係,被認為是一種「類型級別的方程式」。  

5.3 設計哲學對程式設計模式的影響

兩種語言的設計哲學深刻地影響了開發者在其中編寫程式碼的思維模式和常用模式。

  • Go 的簡潔主義 導向的程式設計模式通常是直接、明瞭的。開發者傾向於編寫易於理解和推理的程式碼,避免過度抽象。Go 的併發模型(Goroutines 和 Channels)鼓勵一種「透過通訊來共享記憶體」的模式,這與傳統基於鎖和互斥體的併發模型截然不同,也是其簡潔哲學在併發領域的延伸 。Go 的目標是讓開發者能夠快速地將想法轉化為可工作的軟體 。  
  • Rust 的正確性哲學 則催生了更為嚴謹、精確的程式設計模式。開發者被鼓勵利用強大的類型系統來編碼狀態和不變性,從而在編譯時期就消除整類的錯誤。所有權和生命週期的存在,迫使開發者在設計之初就必須仔細思考資料的歸屬和生命週期,這雖然增加了前期的心智負擔,但換來的是後期的極高穩定性和可靠性。Rust 的程式設計體驗更像是與編譯器進行一場嚴格的對話,以共同構建出一個被證明是正確的系統 。  

表格 1:Go 與 Rust 組合式設計方法之特徵比較

特性 (Feature)Go 的實現方式 (Go’s Approach)Rust 的實現方式 (Rust’s Approach)影響與權衡 (Implications & Trade-offs)
程式碼重用機制結構體嵌入 (Struct Embedding):自動提升欄位和方法,語法簡潔。結構體組合 (Struct Composition):明確的欄位包含,需手動委派。Go:低樣板程式碼,快速實現重用。Rust:程式碼更冗長但關係更明確,鼓勵使用設計模式。
多型契約隱式介面 (Implicit Interfaces):基於方法集的結構化類型。顯式 Trait (Explicit Traits):基於名稱的名義化類型。Go:極度靈活,易於解耦和適配第三方程式碼。Rust:更強的編譯期保證,意圖更明確,但靈活性稍遜。
分派機制僅動態分派 (Dynamic Dispatch Only):所有介面呼叫均在執行期解析。靜態分派 (預設) + 動態分派 (可選):透過泛型 (impl Trait) 或 Trait 物件 (dyn Trait) 選擇。Go:實現簡單統一,但有固定的執行期開銷。Rust:提供極致效能的零成本抽象選項,但增加了語言複雜性。
類型系統強類型,結構化:簡潔實用,但表達力有限。極強類型,名義化:包含代數資料類型 (ADT),表達力極強,能在編譯期捕捉更多錯誤。Go:易於學習和使用。Rust:學習曲線陡峭,但能構建更安全、更可靠的系統。
樣板程式碼較少:結構體嵌入極大簡化了組合的寫法。較多:手動委派可能導致樣板程式碼,但可透過巨集 (Macros) 和預設實作緩解。Go:優先考慮開發者便利性。Rust:優先考慮明確性,並提供高階工具來管理複雜性。
核心目標開發者生產力、簡潔性、併發效能、控制力、記憶體安全、正確性兩種語言針對不同的問題領域和設計權衡進行了優化。Go 適合網路服務和雲端原生應用,Rust 適合系統底層、嵌入式和效能關鍵型應用。

匯出到試算表

總而言之,Go 和 Rust 雖然都走向了組合的道路,但它們的目的地和旅途風景卻大相徑庭。Go 提供了一條通往快速、務實開發的陽關道,而 Rust 則開闢了一條通往極致效能和可靠性的崎嶇山路。開發者應根據專案的具體需求、團隊的技術背景以及對效能和安全性的要求,來選擇最適合的工具。

第六節:總結與展望:現代系統語言中組合模式的最終裁決

經過對傳統繼承的批判性重估,以及對 Go 和 Rust 中組合式設計的深入剖明,我們可以得出一個清晰的結論:在現代系統程式語言的設計中,組合已經全面勝出,成為構建穩健、可維護軟體的核心範式。這一轉變並非偶然的潮流,而是軟體工程歷經數十年實踐後,對複雜性管理所做出的深思熟慮的選擇。

6.1 重申優勢:為何組合勝出

組合之所以能夠取代繼承,成為現代語言設計的首選,是因為它在根本上解決了繼承的內在缺陷,並帶來了一系列關鍵優勢:

  • 更強的封裝性:組合遵循「黑箱」原則,物件之間透過穩定的公開介面互動,保護了各自的內部實現不被外部依賴所侵蝕。這從源頭上避免了「脆弱基底類別問題」。  
  • 更高的靈活性:組合的鬆散耦合特性使得系統更易於適應變化。行為可以在執行期透過更換元件來動態改變,並且可以自由地混合搭配不同的功能,而不會陷入「子類別爆炸」的困境 。  
  • 更佳的可測試性:基於組合的設計,其依賴關係清晰明確,易於在單元測試中進行隔離和模擬,從而顯著提高了程式碼的可測試性和品質保證 。  
  • 避免繼承的結構性問題:組合自然地規避了多重繼承帶來的「菱形問題」,並防止了因不當使用繼承而導致的僵化、難以理解的深層次階層結構 。  

總而言之,組合促使開發者設計出更為模組化、職責更單一的元件,最終構建出一個更穩定、更易於推理和長期維護的系統 。  

6.2 承認劣勢:樣板程式碼的權衡

當然,擁抱組合並非沒有代價。其最常被提及的缺點,便是可能導致樣板程式碼 (Boilerplate Code) 的增加 。  

在使用繼承時,子類別可以「免費」獲得父類別的數十個方法。而使用組合時,如果容器物件需要向外界暴露其內部元件的功能,就必須手動編寫大量的轉發或委派方法 。例如,一個裝飾器 (Decorator) 類別為了給被裝飾的物件增加一項功能,可能需要轉發其餘所有介面方法,這在介面龐大時會變得非常繁瑣 。  

然而,這一劣勢正在被現代語言的發展所克服。軟體設計的演進呈現出一個清晰的趨勢:業界普遍接受了組合模型在架構上的優越性,並正在積極地透過語言特性來優化其人因工程學 (ergonomics),以減少其樣板程式碼的弊端。

這個趨勢的證據隨處可見:

  1. Go 的結構體嵌入,如前文所述,正是為了解決組合中最常見的樣板程式碼問題而設計的語法糖 。  
  2. Rust 的程序化巨集 (Procedural Macros) 提供了強大的程式碼生成能力,可以自動為結構體實現 Trait 或生成委派程式碼,從而消除手動編寫樣板程式碼的需要。此外,Rust 社群也曾深入討論過引入類似 Go 嵌入的特性,這表明語言設計者們對此問題有著清醒的認識 。  
  3. 其他現代語言如 Kotlin,也提供了 by 關鍵字來實現委派 (delegation),讓編譯器自動生成轉發方法,極大地簡化了組合模式的實現。

因此,儘管「樣板程式碼」的批評在某些情況下(尤其是在較舊的語言如 Java 中)仍然成立,但它已不再是拒絕組合的決定性理由。語言本身正在進化,以彌合組合在架構優勢和編寫便利性之間的鴻溝。

6.3 最終建議:何時該用「Has-A」思維模式

對於當代的軟體開發者而言,應當將「有一個」(Has-A) 的組合思維作為預設的設計模式。當面臨程式碼重用或功能擴展的需求時,首先思考「這個物件是否擁有某種能力或資料?」,而不是「這個物件是否另一種物件的特例?」。

繼承(「是一種」關係)應被視為一種特殊的、需要審慎使用的工具。只有在以下條件同時滿足時,才應考慮使用它:

  1. 存在一個清晰、穩定且符合邏輯的「是一種」分類關係。
  2. 該關係嚴格遵守里氏替換原則。
  3. 其主要目的是為了實現多型,而非僅僅為了共享程式碼。

即便在這種情況下,也應優先考慮實現介面或 Trait,而不是繼承自一個包含具體實作的基底類別 。  

6.4 物件導向設計的未來

Go 和 Rust 等語言的設計選擇,並不意味著物件導向程式設計的終結,恰恰相反,它們代表了 OOP 的一次深刻演進與成熟。這是一場從「基於類別的繼承」到「基於元件的組合」的典範轉移。

未來的物件導向設計將更加強調:

  • 元件化 (Componentization):將大型系統分解為小型的、可獨立部署和更新的元件。
  • 明確的行為契約 (Explicit Behavioral Contracts):透過介面和 Trait 來定義元件之間互動的規則,而不是依賴於脆弱的繼承關係。
  • 靈活的組裝 (Flexible Assembly):在執行期或編譯期,根據需求動態地將這些元件組裝成最終的應用程式。

這種現代化的 OOP 思想,更加符合當今微服務、雲端原生和複雜系統的開發需求。Go 和 Rust 正是這場演進的先鋒,它們的設計不僅為我們提供了更強大的工具,更重要的是,它們教會了我們一種更穩健、更具擴展性的方式來思考和構建軟體。這場由組合引領的革命,正在為軟體工程的下一個十年奠定堅實的基礎。

分類: Uncategorized。這篇內容的永久連結

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *