08 | 结构体与接口:掌握Go语言组合优于继承的设计哲学

你好!我是 Tony Bai。

在之前的课程中,我们已经接触了 Go 的基础类型和函数/方法。今天,我们要深入探讨 Go 语言构建复杂程序的两大基石:结构体(struct)和接口(interface)

如果你有其他面向对象语言(如Java, C++,Python)的背景,你可能习惯于使用继承(inheritance)来实现代码复用和构建类型层次(比如“is-a”关系)。但你会发现,Go 语言走了一条不同的路——它不支持传统意义上的继承。

这不禁让人疑问:

  • 没有继承,Go 如何实现代码复用和多态?
  • 为什么 Go 的设计者选择了“组合优于继承”的哲学?组合到底好在哪里?
  • 结构体和接口在 Go 的“组合之道”中扮演了怎样的角色?我们又该如何运用它们来构建灵活、可维护的系统?

不理解 Go 的组合哲学,不掌握结构体和接口的精髓,你就很难真正领会 Go 设计的优雅之处,也难以写出符合 Go 语言习惯、易于扩展和维护的代码。

这节课,我们将一起:

  1. 快速回顾结构体和接口的核心概念与关键特性。
  2. 探讨 Go 为何“抛弃”继承,以及组合的核心优势。
  3. 深入学习如何利用结构体组合、接口组合,以及两者结合的方式,在 Go 中实践组合。
  4. 明确 Go 如何通过组合模拟继承的部分效果,以及何时选择不同的组合方式。

掌握了这些,你将能更深刻地理解 Go 的设计思想,并运用组合这一利器构建出更优秀的 Go 程序。

快速回顾核心特性

在我们深入探讨组合之前,先快速回顾一下结构体和接口这两个核心概念。

结构体(struct):数据的聚合与载体

结构体是 Go 中用于将不同类型的字段(数据成员)聚合在一起,形成一个自定义数据类型的主要方式。它让我们能够根据现实世界的模型来组织数据。下面是一个典型的结构体定义和初始化方式的示例:

// 定义一个名为Person的结构体
type Person struct {
    Name    string
    Age     int
    Address string 
}

// 1. 按字段顺序初始化
p1 := Person{"Alice", 30, "123 Main St"}

// 2. 使用字段名初始化(推荐,更清晰)
p2 := Person{Age: 25, Name: "Bob", Address: "456 Elm St"}

// 3. 零值初始化 + 逐字段赋值
var p3 Person
p3.Name = "Charlie"
p3.Age = 40

在上面示例中展示了三种初始化结构体的方式,Go 更推荐使用第二种方式:使用字段名进行初始化。这种方式不需要遵循字段定义的顺序,可以随意排列字段,方便在需要时只初始化部分字段,可以有效避免方式一因字段顺序错误导致的赋值错误。

同时,如果将来结构体添加了新字段,使用字段名初始化的代码无需修改已有的赋值顺序,减少了代码维护的复杂性。并且,未显式初始化的字段会自动使用零值,使用字段名初始化可以更清楚地看到哪些字段被赋值,哪些字段使用了默认值。

Go 结构体的字段在内存中是连续排列的。Go 编译器会进行内存对齐,以提高访问效率。这意味着字段之间可能会有填充(padding)。我们可以通过 unsafe.Sizeof 和 unsafe.Offsetof 函数来查看结构体的大小和字段偏移量:

var p Person
fmt.Println("sizeof Person =", unsafe.Sizeof(p)) // sizeof Person = 40
fmt.Println("Name's offset=", unsafe.Offsetof(p.Name)) // Name's offset= 0
fmt.Println("Age's offset=", unsafe.Offsetof(p.Age)) // Age's offset= 16
fmt.Println("Address's offset=", unsafe.Offsetof(p.Address)) // Address's offset= 24

关于 Go 结构体字段的对齐方法,我在专栏《Go语言第一课》中有更为全面的说明,感兴趣的小伙伴可以去阅读一下。

结构体还有一个独特的特性:匿名字段。匿名字段是指在结构体中只声明类型而不声明字段名。通过匿名字段,我们可以实现结构体的嵌入(embedding),这是 Go 语言实现组合(composition)的关键。我们看下面示例:

type Contact struct {
    Phone string
    Email string
}

type Employee struct {
    Name    string
    Age     int
    Contact // 匿名嵌入Contact 类型
}

在这个例子中,Employee 结构体通过匿名嵌入 Contact 类型,获得了 Contact 的所有字段(PhoneEmail)。我们可以直接通过 Employee 类型的实例访问这些字段,就好像它们是 Employee 自己的字段一样:

e := Employee{Name: "Eve", Age: 28,
    Contact: Contact{Phone: "123-456", Email: "eve@example.com"}}
fmt.Println(e.Phone) // 输出: 123-456

这种“字段提升”的特性,使得我们可以非常方便地将一个类型的属性和行为“组合”到另一个类型中。在本讲后面,我们还会详细说明如何利用这一特性实现灵活的组合。

Go 语言支持一种特殊的结构体:空结构体,即 struct{}。空结构体不包含任何字段,因此不占用任何内存空间(unsafe.Sizeof(struct{}{}) 的结果为 0),并且所有空结构体变量的地址都相同:

var es1 = struct{}{}
var es2 = struct{}{}
fmt.Printf("0x%p, 0x%p\n", &es1, &es2) // 0x0x57ffc0, 0x0x57ffc0

空结构体的主要用途包括:

  • 实现集合(Set):利用 map 的键不能重复的特性,可以将 map 的值类型设置为空结构体。
  • 通道信号:作为通道(channel)的元素类型,用于传递信号,而不传递任何数据。
  • 仅包含方法的类型:如果一个类型只需要方法而不需要字段,可以使用空结构体作为接收者。

回顾完结构体类型,我们再看看接口类型。

接口(interface):行为的抽象与契约

接口定义了一组方法的集合(方法签名)。它描述了对象应该具有什么行为,但不关心这些行为是如何实现的,也不关心对象本身是什么类型。

接口是 Go 实现多态和解耦的关键。 接口(interface)是 Go 语言实现多态性(polymorphism)的关键。下面是在 Go中使用最为频繁的两个接口,来自 io 包的 Reader 和 Writer:

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

Go 语言的接口实现是隐式的。只要一个类型实现了接口中定义的所有方法,它就被认为是实现了该接口,而不需要显式声明。这种方式在业界被称为 Duck Typing:“如果它走起来像鸭子,叫起来像鸭子,那么它就是鸭子”。

下面示例中的 File 类型隐式地实现了上面的 Reader 和 Writer 接口:

type File struct {
    // ...
}

func (f *File) Read(p []byte) (n int, err error) { /* ... */ return }
func (f *File) Write(p []byte) (n int, err error) { /* ... */ return }

隐式实现接口带来了极大的灵活性和解耦性:

  • 定义与实现分离:接口的定义和实现可以位于不同的包中。
  • 类型无需修改:我们可以为一个已有的类型实现新的接口,而无需修改该类型的代码。
  • 多重实现:一个类型可以实现多个接口,一个接口也可以被多个类型实现。

那么,这么灵活的接口类型在运行时是如何表示和实现的呢?我们来看看接口的底层表示

Go 语言的接口类型在底层有两种表示形式:iface(用于非空接口)和 eface(用于空接口)。下面是这两种形式在 Go 运行时的表示,简单来说,它们都包含两个指针。

  • iface(非空接口)
type iface struct {
   tab  *itab // 接口类型和具体类型信息
   data unsafe.Pointer // 数据指针
}

其中,tab 是指向一个 itab 结构体的指针,itab 中包含了接口类型和具体类型的信息,以及实现接口的方法的函数指针。data 也是一个指针,指向的是实际存储数据的内存地址。

  • eface(空接口)
type eface struct {
   _type *_type // 具体类型信息
   data  unsafe.Pointer // 数据指针
}

其中,_type 指向具体类型的类型信息。data 指向的是实际存储数据的内存地址。

Go 接口在底层通过 ifaceeface 结构实现。iface 用于包含类型信息的接口,eface 用于空接口。它们的核心在于 iface.tabeface._type 分别存储了接口的具体类型和方法信息(对于 iface)以及数据的类型信息。这使得 Go 能够实现动态分派,即在运行时根据实际类型调用相应的方法,从而实现多态。

类型断言也依赖于检查这些类型信息是否与目标类型匹配。 虽然底层实现细节(如 itab 生成、缓存、内存分配)较为复杂。

目前,我们只需理解接口是如何利用这些内部结构来存储类型和方法信息,知道这种存储方式支持动态派发和类型断言功能,就足以满足你进阶之用了。

通过对结构体和接口核心概念及高级特性的回顾,我们已经了解到它们各自的强大之处:结构体提供了数据聚合与组合的能力,而接口则实现了行为的抽象与多态。

特别是结构体的匿名字段和嵌入机制,为 Go 语言的组合奠定了基础。同时,接口的隐式实现和底层表示,又赋予了 Go 语言极大的灵活性和动态性。正是这些特性,使得 Go 语言能够以一种独特的方式处理代码复用和类型关系的问题。

那么,在拥有了结构体和接口这两大利器之后,Go 语言又是如何看待并处理传统面向对象编程中“继承”这一核心概念的呢?接下来,我们就将深入探讨 Go 语言为何不支持继承,以及它所推崇的组合机制是如何运作的。

Go 语言为何摒弃继承?

在传统的面向对象语言中,继承通常用于表达一种 “is-a” 的关系,即子类“是一个”父类。例如在 Java 中,我们可以定义一个 Animal 类,然后定义一个 Dog 类继承自 Animal 类,这样我们就说 Dog “是一个” Animal

// Java 代码示例
class Animal {
    public void eat() {
        System.out.println("Animal is eating");
    }
}

class Dog extends Animal {
    public void bark() {
        System.out.println("Dog is barking");
    }
}

在这个例子中,Dog 类继承了 Animal 类的 eat 方法,同时又定义了自己的 bark 方法。这样,Dog 类的实例就同时具有了 eatbark 两种行为。

但 Go 没有 extends 关键字,没有父类子类的概念,也没有 is-a 这样的类型关系。那么,Go 是如何处理类型间关系的呢?

Go 的设计者们认为,传统的类继承机制虽然提供了一种代码复用方式,但也带来了诸多问题,这些问题在大型项目中尤为突出。

  1. 强耦合:子类与父类的实现细节紧密耦合。父类的内部实现变化可能无意中破坏子类的行为(脆弱基类问题)。子类也可能需要了解父类的实现细节才能正确地覆盖方法。
  2. 层次僵化:继承关系在编译时就固定下来,形成一个树状结构。现实世界的关系往往更复杂,单一继承难以模拟,而多重继承又会引入“菱形问题”(一个类继承自两个具有共同祖先的类时产生的歧义)。
  3. 封装性破坏:子类可以访问父类的受保护成员(protected),破坏了父类的封装。
  4. 臃肿的基类:为了适应各种子类的需求,基类可能变得越来越庞大和复杂。

Go 的设计哲学推崇简洁、清晰、显式。设计者们希望避免继承带来的复杂性和潜在问题,转而寻求一种更灵活、更松耦合的方式来组织代码和实现复用。这个方式就是组合。

那么组合究竟有哪些优势呢?我们下来就来看一下。

组合带来了哪些核心优势?

“组合优于继承(Composition over Inheritance)”是软件设计中一个广为人知的原则,Go 语言将其奉为圭臬。组合的核心思想是:通过将简单的、功能单一的对象组装在一起来构建更复杂的对象,而不是扩展一个已有的复杂对象

组合相比继承,主要有以下优势:

  1. 松耦合:对象之间的关系是通过接口或持有其他对象的实例来建立的,而不是通过继承关系。修改一个对象的内部实现通常不会影响到使用它的其他对象(只要接口不变)。
  2. 高灵活性:可以在运行时动态地改变对象的组成部分(例如,通过接口注入不同的实现),而继承关系是静态的。组合还可以更容易地混合和匹配来自不同“谱系”的功能。比如下面这个示例就通过接口轻松地将 Walker 和Swimmer 两个“谱系”的动物以及它们的行为混合到一起:
package main
 

import (
    "fmt"
)

// 定义行为接口
type Walker interface {
    Walk() string
}

type Swimmer interface {
    Swim() string
}

// 定义具体的行为
type Dog struct{}


func (d Dog) Walk() string {
    return "Dog is walking."
}

type Fish struct{}

func (f Fish) Swim() string {
    return "Fish is swimming."
}

// 组合动物
type Animal struct {
    Walker
    Swimmer
}

func main() {
    // 创建一个动物实例,组合狗和鱼的行为
    dog := Dog{}
    fish := Fish{}

    animal := Animal{
        Walker:  &dog,
        Swimmer: &fish,
    }

    fmt.Println(animal.Walk()) // 输出: Dog is walking.
    fmt.Println(animal.Swim()) // 输出: Fish is swimming.
}
  1. 更好的封装性:每个对象只负责自己的功能,其内部实现对外部是隐藏的。外部对象只能通过其公开的接口进行交互。
  2. 避免继承层次问题:没有复杂的继承树,没有多重继承的烦恼。代码结构更扁平、更清晰。
  3. 更清晰的关系表达(“has-a” vs “is-a”):组合通常表达的是 “has-a”(有一个)或 “uses-a”(使用一个)的关系,这在很多情况下比继承表达的 “is-a”(是一个)关系更贴切、更灵活。

Go 语言通过其类型系统(特别是结构体嵌入和接口)为组合提供了强大的原生支持。下面我们再来看看在 Go 中实践组合的几种方式。

在 Go 中实践组合:结构体嵌入与接口的威力

在 Go 语言中,结构体和接口是实现组合的两种主要方式,我们逐一来看一下。

结构体的组合

结构体的组合是指在一个结构体中嵌入其他类型的字段,从而将这些类型的字段和方法组合到新的结构体中。

type Engine struct {
    Type  string
    Power int
}

// 为 Engine 类型添加一个方法
func (e Engine) Start() {
    fmt.Println("Engine", e.Type, "started with power", e.Power)
}

type Car struct {
    Engine // 匿名嵌入Engine类型
    Brand  string
    Model  string
}

func main() {
    c := Car{
        Engine: Engine{
            Type:  "V8",
            Power: 300,
        },
        Brand: "BMW",
        Model: "M3",
    }
    fmt.Println(c.Type)  // 输出 V8,直接访问 Engine 的 Type 字段
    fmt.Println(c.Power) // 输出 300,直接访问 Engine 的 Power 字段
    c.Start()             // 输出 Engine V8 started with power 300, 直接调用 Engine 的 Start 方法
}

在这个例子中,我们不仅定义了 EngineCar 两个结构体,并在 Car 中嵌入了 Engine 类型,还为 Engine 类型添加了一个 Start 方法。

通过匿名嵌入 Engine 类型,Car 类型的实例不仅获得了 EngineTypePower 字段,还获得了 EngineStart 方法。我们可以直接通过 c.Start() 来调用这个方法,就像它是 Car 自身的方法一样。这就是方法提升(Method Promotion),即将 Engine 自身的方法提升到外层类型 Car 中。

通过组合,我们可以将不同的类型组合在一起,创建出更复杂的类型。这种方式比继承更加灵活,因为我们可以自由地选择需要组合的类型,而不是受限于单一的继承链。而且,被嵌入类型的字段和方法也被提升到外层类型,实现了数据与行为的复用。

但是,我们也需要明确一点:这并不是真正的继承,而只是字段和方法的提升

接口的组合

接口的组合是指在一个接口中嵌入其他接口,从而将这些接口的方法组合到新的接口中。下面例子中的 ReadWriter 就是一个典型的组合接口(示例代码仅是代码片段):

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

type ReadWriter interface {
    Reader // 匿名嵌入 Reader 接口
    Writer // 匿名嵌入 Writer 接口
}

type File struct {
    // ...
}

func (f *File) Read(p []byte) (n int, err error) {
    // ...
    return
}

func (f *File) Write(p []byte) (n int, err error) {
    // ...
    return
}

func main() {
    var rw ReadWriter = &File{}
    var r Reader = rw     // ReadWriter 接口可以赋值给 Reader 接口
    var w Writer = rw     // ReadWriter 接口可以赋值给 Writer 接口
    r.Read(...)
    w.Write(...)
}

在这个例子中,我们定义了 ReaderWriterReadWriter 三个接口。ReadWriter 接口通过匿名嵌入 ReaderWriter 接口,将它们的方法组合在一起。这样,ReadWriter 接口就同时具有了 ReadWrite 两个方法。

File 类型实现了 ReadWrite 方法,因此它同时实现了 ReaderWriterReadWriter 三个接口。我们可以将 File 类型的实例赋值给这三个接口类型的变量。

通过接口的组合,我们可以创建出具有更丰富功能的接口,而不需要修改已有的接口。这种方式符合“开闭原则”,即对扩展开放,对修改关闭。在后面讲“接口设计”的课程中,我们还会对此进行详细的说明。

结构体与接口的组合

结构体和接口的组合是 Go 语言中最常用的组合方式。通过在结构体中嵌入接口类型的字段,我们可以实现对不同类型的抽象和解耦。

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

type ReadWriter struct {
    Reader // 匿名嵌入 Reader 接口
    Writer // 匿名嵌入 Writer 接口
}

type File struct {
    // ...
}

func (f *File) Read(p []byte) (n int, err error) {
    // ...
    return
}

func (f *File) Write(p []byte) (n int, err error) {
    // ...
    return
}

func main() {
    rw := ReadWriter{
        Reader: &File{}, // 嵌入 Reader 接口的实现类型
        Writer: &File{}, // 嵌入 Writer 接口的实现类型
    }
    rw.Read(...)
    rw.Write(...)
}

在这个例子中,ReadWriter 结构体通过匿名嵌入 ReaderWriter 接口,将它们组合在一起。在创建 ReadWriter 类型的实例时,我们可以传入任何实现了 ReaderWriter 接口的类型。

这种组合方式的优势在于,它将接口的定义和实现解耦了。ReadWriter 结构体只依赖于 ReaderWriter 接口,而不依赖于具体的实现类型。

设计抉择:何时以及如何运用组合?

既然组合是 Go 的核心,我们应该如何以及何时使用它?

  • 需要复用代码(行为或数据)时:

    • 如果想复用另一个类型的“数据字段”和/或“方法”,优先考虑结构体匿名嵌入。这是最接近“继承”效果的方式,但本质是组合。
    • 如果只想使用另一个类型的功能,但不希望其字段和方法被“提升”,那么将另一个类型的实例作为结构体的成员字段即可。
  • 需要实现多态或解耦时:

    • 定义接口来抽象行为。
    • 让具体的类型实现这些接口(隐式)。
    • 在代码中依赖接口而不是具体类型(如结构体持有接口字段,函数参数使用接口类型)。这是实现依赖注入和策略模式等设计模式的基础。
  • 构建更复杂的行为契约时

    • 使用接口嵌入来组合多个简单的接口,形成一个功能更丰富的接口。

总原则:优先考虑组合。思考类型之间的关系是 “has-a” 还是 “uses-a”,并选择合适的组合方式(结构体嵌入、持有接口字段)。避免试图在 Go 中模拟复杂的类继承层次(“is-a”)。

小结

这一讲,我们深入探讨了 Go 语言独特的“组合之道”,以及结构体和接口在其中的核心作用。

  1. 回顾基础:结构体用于聚合数据(字段),接口用于抽象行为(方法集)。结构体的匿名字段嵌入和接口的隐式实现是关键特性。

  2. Go 不选择继承:避免了传统继承带来的强耦合、层次僵化、封装破坏等问题。

  3. 组合的优势:松耦合、高灵活性、更好的封装性、避免继承层次问题,更清晰地表达 “has-a” 或 “uses-a” 关系。

  4. 组合实践
    a. 结构体嵌入(匿名/非匿名):实现数据和行为的复用,方法提升模拟了部分继承效果。
    b. 接口嵌入:组合行为契约,构建更丰富的接口。
    c. 结构体持有接口字段:实现依赖注入,是解耦和实现多态的核心手段。

  5. 选择指南:根据代码复用需求、解耦需求、多态需求以及关系表达的清晰度,选择合适的组合方式。

掌握组合是理解和精通 Go 语言设计的关键。通过灵活运用结构体和接口进行组合,我们可以构建出简洁、清晰、可维护、易于扩展的 Go 应用程序,充分发挥 Go 语言的设计优势。

思考题

假设你正在设计一个系统,需要处理不同类型的通知发送,例如邮件通知(EmailNotifier)、短信通知(SMSNotifier)和应用内推送通知(PushNotifier)。这些通知器都需要一个 Send(message string) 方法。

现在你希望有一个统一的 NotificationService,它可以根据配置或上下文选择使用哪种通知器来发送通知。

请思考:你会如何使用结构体和/或接口来设计这个 NotificationService 以及相关的通知器类型,以实现良好的解耦和灵活性?画出核心类型和它们之间关系的草图(或用代码片段描述)。

欢迎在评论区分享你的设计思路!我是 Tony Bai,我们下节课见。

放假说明:提前祝各位同学端午安康!我们的课程会暂停2期,于6月4日零点恢复更新,感谢理解,敬请期待解锁更多新内容。

精选留言

  • Amosヾ

    2025-06-09 01:00:41

    思考题:

    // 通知器接口 - 所有通知类型必须实现此接口
    type Notifier interface {
    Send(message string) error
    }

    // 邮件通知器
    type EmailNotifier struct{}

    func (e *EmailNotifier) Send(message string) error {
    // 实现邮件发送逻辑
    fmt.Println("Sending email:", message)
    return nil
    }

    // 短信通知器
    type SMSNotifier struct{}

    func (s *SMSNotifier) Send(message string) error {
    // 实现短信发送逻辑
    fmt.Println("Sending SMS:", message)
    return nil
    }

    // 推送通知器
    type PushNotifier struct{}

    func (p *PushNotifier) Send(message string) error {
    // 实现推送发送逻辑
    fmt.Println("Sending push:", message)
    return nil
    }

    type NotificationService struct {
    notifier Notifier // 依赖抽象接口而非具体实现
    }

    // 创建通知服务(依赖注入)
    func NewNotificationService(notifier Notifier) *NotificationService {
    return &NotificationService{notifier: notifier}
    }

    // 统一发送接口
    func (ns *NotificationService) SendNotification(message string) error {
    return ns.notifier.Send(message)
    }
    作者回复

    👍

    2025-06-09 12:10:14

  • Geek_dbf501

    2025-07-07 15:48:33

    package main

    import "fmt"

    type SendMessage interface {
    Send()
    }

    type EmailNotifier struct {
    }
    type SMSNotifier struct {
    }

    type PushNotifier struct {
    }

    func (e EmailNotifier) Send() {
    fmt.Println("Sending email")
    }
    func (s SMSNotifier) Send() {
    fmt.Println("Sending sms")
    }
    func (p PushNotifier) Send() {
    fmt.Println("Sending push notification")
    }

    type NotificationService struct {
    SendMessage
    }

    // 创建发送器
    func NewNotificationService(sendM SendMessage) *NotificationService {
    return &NotificationService{SendMessage: sendM}
    }

    // 统一发送接口
    func (ns *NotificationService) SendNotification() {
    ns.SendMessage.Send()
    }
    func main() {
    sm := NewNotificationService(EmailNotifier{})
    sm.SendNotification()
    }
    作者回复

    2025-07-07 22:54:32

  • 我在学编程

    2025-07-01 17:07:38

    package main

    import "fmt"

    type Notifier interface {
    Notify()
    }

    type EmailNotifier struct{}

    func (en *EmailNotifier) Notify() {
    // 发送邮件的逻辑
    fmt.Println("Email Notifier")
    }

    type SmsNotifier struct{}

    func (sn *SmsNotifier) Notify() {
    // 发送短信的逻辑
    fmt.Println("SMS Notifier")
    }

    type NotificationService struct {
    notifiers Notifier
    }

    func NewNotificationService(notifier Notifier) *NotificationService {
    return &NotificationService{
    notifiers: notifier,
    }
    }

    func (ns *NotificationService) Notify() {
    ns.notifiers.Notify()
    }

    func main() {
    emailNotifier := &EmailNotifier{}
    notificationService := NewNotificationService(emailNotifier)
    notificationService.Notify()
    }

    作者回复

    👍

    2025-07-02 21:35:02

  • Fukans

    2025-06-09 10:04:02

    备注:代码中的 "implements" 可以表示继承,也可以表示“隐式实现”

    // 1. 通知器接口定义
    interface Notifier {
    Send(message: string) -> error
    }

    // 2. 具体通知器实现
    class EmailNotifier implements Notifier {
    Send(message: string) -> error {
    }
    }

    class SMSNotifier implements Notifier {
    Send(message: string) -> error {
    }
    }

    class PushNotifier implements Notifier {
    Send(message: string) -> error {
    }
    }

    // 3. 通知服务
    class NotificationService {
    // 组合关系:持有Notifier接口实例
    notifier: Notifier

    // 构造时注入具体实现
    constructor(notifier: Notifier) {
    this.notifier = notifier
    }

    // 统一发送入口
    Send(message: string) -> error {
    return notifier.Send(message)
    }

    // 切换通知器
    SetNotifier(newNotifier: Notifier) {
    this.notifier = newNotifier
    }
    }

    作者回复

    👍。你这伪代码格式不错,至少我看懂了😁

    2025-06-09 12:37:33

  • Amosヾ

    2025-06-09 00:55:19

    关于文中锁阐述的“组合带来的优势”有以下疑问:
    1.松耦合:修改一个对象的内部实现通常不会影响到使用它的其他对象(只要接口不变),java的继承应该也不会影响吧?①子类重载后,完全不影响;②子类未重载,那么java和go都会影响的吧?
    2.高灵活性:这里感觉和多继承差不多啊,像C++、Python都是支持多继承的
    3.更好的封装性:C++、Java应该都具备这个特性, 子类能访问父类的protected的属性,go中也可以
    4.避免继承层次问题:组合的扁平可以举个例子说明吗?感觉起来和继承差不多
    作者回复

    你的问题非常好,显然是经过了认真的思考。

    关于问题1: 这里有个关键点。继承下,子类和父类之间是“白盒复用”,子类可能依赖父类的内部实现细节。如果父类的内部实现变了(即使方法签名没变),有时确实会意外地破坏子类的行为,这就是所谓的“脆弱基类问题”。组合呢,可以理解是“黑盒复用”。外层对象只关心内层对象暴露的接口(或公开方法)。只要内层对象的接口不变,它内部怎么改,外层对象通常不受影响。Go 里面通过接口或者直接嵌入结构体,都是这种思路。子>类重载确实能隔离一些影响,但如果父类某个你没重载但间接依赖的方法行为变了,还是可能出问题。

    关于问题2:组合确实能达到类似多继承的效果——从多个源头获取功能。但C++和Python的多继承也带来了复杂性,比如著名的“菱形继承问题”(diamond problem)和方法解析顺序的困扰。Go 通过接口和结构体嵌入,提供了一种更简洁、争议更少的方式来组合行为,避免了多继承的那些经典难
    。你可以把多个小行为(通过小接口或小结构体实现)“拼装”到一个大结构体里,灵活性很高,但结构更清晰。

    关于问题3:protected 确实是继承体系里为了扩展性而对封装做的一点“让步”,子类能看到父类的一些内部东西。但组合强调的是,被组合的对象本身是完全封装的,它只暴露它想暴露的公共接口。外层对象不能(也不应该)直接触碰内层对象的内部状态,除非内层对象明确提供了方法。Go 的包级私有(小写字母开头)和公开(大写字母开头)提供了封装,但当一个结构体 A 嵌入另一个结构体 B 时,A 默认也只能访问 B 的公开成员,除非 B 主动暴露更多。组合通常能带来更强的封装边界。

    关于问题4:想象一下经典 GUI 库的继承:Button is a Control, Control is a Component, Component is an Object... 这就形成了一个很深的继承树。如果你想创建一个带特殊边框的按钮,可能得在继承树的某个地方插入一个 BorderedControl,或者让 Button 再去继承一个 BorderFeature(如果支持多继承)。而用组合呢,你的 MySpecialButton 可以是这样的:它有一个 StandardButtonBehavior (负责点击等),有一个 FancyBorderRenderer (负责画边框),可能还有一个 TooltipProvider。这些部件都是相对独立的,MySpecialButton 把它们“组合”起来。结构上不是 A -> B -> C 的纵向关系,更像是 X = {partA, partB, partC} 的横向“拼装”关系,这就是“扁平化”。并且修改或替换某个部件(比如换个边框样式)通常不影响其他部件,也更容易添加新的、正交的功能。

    Go 推崇组合,但并不是说继承一无是处,而是在大型、复杂项目中,过度使用继承容易导致设计僵化、耦合过紧。组合提供了另一种更灵活、更松耦合的思路来构建和复用代码。

    2025-06-09 12:24:17