Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

结构体与接口:掌握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日零点恢复更新,感谢理解,敬请期待解锁更多新内容。