Web

Go 的 Struct、Interface 与指针

Go 没有 class 和基于类的继承。程序通常使用 Struct 组织数据,通过方法为类型定义行为,再借助组合与 Interface 复用能力。

本文从 Struct 开始,依次介绍方法、指针、组合、Interface 和类型断言,并说明 typed nil 与 new 的常见用法。

使用 Struct 组织数据

Struct 是一组具有名称和类型的字段,适合描述一份相关的数据。下面定义了一个保存名称和计数的 Counter

go
type Counter struct {
	Name  string
	Value int
}

定义类型之后,可以使用结构体字面量创建值:

go
counter := Counter{
	Name:  "download",
	Value: 10,
}

fmt.Println(counter.Name)  // download
fmt.Println(counter.Value) // 10

初始化 Struct 时,通常显式写出字段名。这样不会依赖字段的声明顺序,后续增加字段时也更容易维护。

未赋值的字段会获得对应类型的零值:

go
counter := Counter{Name: "download"}

fmt.Println(counter.Name)  // download
fmt.Println(counter.Value) // 0

Struct 是值类型。将它赋给另一个变量时,会复制一份独立的值:

go
first := Counter{Name: "download", Value: 10}
second := first
second.Value = 20

fmt.Println(first.Value)  // 10
fmt.Println(second.Value) // 20

为类型定义方法

方法是在函数名前增加接收者的函数。接收者决定了这个方法属于哪个类型:

go
func (c Counter) Label() string {
	return fmt.Sprintf("%s: %d", c.Name, c.Value)
}

这里的 (c Counter) 是值接收者。调用方法时,Go 会把 Counter 的副本传给 Label

go
counter := Counter{Name: "download", Value: 10}
fmt.Println(counter.Label()) // download: 10

值接收者适合只读取数据、不需要修改原值的方法。即使在方法内部修改接收者,变化也只发生在副本上:

go
func (c Counter) ResetCopy() {
	c.Value = 0
}

counter := Counter{Name: "download", Value: 10}
counter.ResetCopy()

fmt.Println(counter.Value) // 10

使用指针修改原值

Go 的函数参数按值传递。需要让函数修改调用方持有的值时,可以传入指针。

& 用于取得变量的地址,*T 表示指向 T 类型值的指针:

go
func reset(c *Counter) {
	c.Value = 0
}

counter := Counter{Name: "download", Value: 10}
reset(&counter)

fmt.Println(counter.Value) // 0

访问 Struct 字段时,Go 会自动处理常见的指针解引用,因此通常写 c.Value,不需要写 (*c).Value

指针接收者

方法也可以使用指针接收者。下面的 Increment 会直接修改调用方持有的 Counter

go
func (c *Counter) Increment() {
	c.Value++
}

counter := Counter{Name: "download"}
counter.Increment()

fmt.Println(counter.Value) // 1

虽然 Increment 的接收者类型是 *Counter,但对于可取地址的变量,Go 允许直接写 counter.Increment(),等价于 (&counter).Increment()

选择接收者时,可以遵循两个基础原则:

  • 方法需要修改原值时,使用指针接收者;
  • Struct 较大、希望避免每次调用都复制时,也可以使用指针接收者。

同一类型的方法通常保持一致的接收者形式。这样更容易判断方法会不会修改原值,也能避免类型在配合 Interface 使用时出现不直观的差异。

通过组合复用数据与方法

Go 不使用继承扩展类型,而是通过组合把已有类型放进新的 Struct。

最直接的方式是将一个类型声明为普通字段:

go
type Metadata struct {
	CreatedBy string
}

type Task struct {
	Info    Metadata
	Counter Counter
}

访问嵌套字段时,需要经过对应的字段名:

go
task := Task{
	Info: Metadata{CreatedBy: "Gopher"},
	Counter: Counter{
		Name: "download",
	},
}

fmt.Println(task.Info.CreatedBy)
task.Counter.Increment()

如果省略字段名,只写类型,就形成嵌入字段。嵌入类型的字段和方法会被提升,可以直接从外层值访问:

go
type Task struct {
	Metadata
	Counter
}

task := Task{
	Metadata: Metadata{CreatedBy: "Gopher"},
	Counter:  Counter{Name: "download"},
}

fmt.Println(task.CreatedBy)
task.Increment()

方法提升只是简化了访问方式。Task 仍然拥有一个 Counter,两者之间不存在父类和子类关系。

使用 Interface 描述能力

Interface 是一组方法签名,用来描述调用方需要的能力。下面的 Labeler 只要求类型拥有一个 Label() string 方法:

go
type Labeler interface {
	Label() string
}

Go 使用隐式实现。类型只要拥有 Interface 要求的全部方法,就自动满足该 Interface,不需要额外声明 implements

go
func printLabel(value Labeler) {
	fmt.Println(value.Label())
}

counter := Counter{Name: "download", Value: 10}
printLabel(counter)

Counter 已经定义了 Label() string,所以能够传给 printLabel。如果删除这个方法,编译器会在传值的位置报告错误。

需要在实现附近明确检查这种关系时,可以添加编译期断言:

go
var _ Labeler = Counter{}

这条语句不会创建可供程序使用的变量,也不是实现 Interface 的必要步骤。它只是要求编译器验证 Counter 是否满足 Labeler

先认识接收者与方法集

理解类型如何实现 Interface 前,需要先回顾几个概念:

  • 方法:属于某个类型的函数,例如 Label()Increment()
  • 值接收者:接收者类型是 Counter,方法得到的是值的副本;
  • 指针接收者:接收者类型是 *Counter,方法可以通过指针修改原值;
  • 方法集:一个类型可以用于满足 Interface 的方法集合;

前文定义了两个方法:

go
func (c Counter) Label() string {
	return fmt.Sprintf("%s: %d", c.Name, c.Value)
}

func (c *Counter) Increment() {
	c.Value++
}

Label 使用值接收者,Increment 使用指针接收者。它们会形成下面的方法集:

类型可以用来满足 Interface 的方法
CounterLabel()
*CounterLabel()Increment()

也就是说,值类型 Counter 只包含值接收者的方法;指针类型 *Counter 则同时包含值接收者和指针接收者的方法。

直接调用不等于实现 Interface

下面的代码可以直接调用 Increment()

go
counter := Counter{Name: "download"}
counter.Increment()

这是因为 counter 是一个可以取得地址的变量。编译器会提供语法上的便利,把这次调用理解为:

go
(&counter).Increment()

但判断一个类型是否实现 Interface 时,Go 只检查这个类型的方法集,不会自动把 Counter 改成 *Counter

例如,下面的 Interface 要求一个 Increment() 方法:

go
type Incrementer interface {
	Increment()
}

只有 *Counter 的方法集包含 Increment(),因此必须传入指针:

go
func increase(value Incrementer) {
	value.Increment()
}

counter := Counter{Name: "download"}

increase(&counter) // 正确:传入 *Counter
// increase(counter) // 编译错误:Counter 没有实现 Incrementer

把一个值传给 Interface 时,Interface 会保存这个值。Go 不会在这里偷偷取得值的地址,否则指针方法修改的可能只是 Interface 内部保存的副本,而不是调用方原来的变量。要求显式传入 &counter,可以清楚表达这个方法需要操作原值。

相反,Label() 使用值接收者,所以 Counter*Counter 都可以满足只要求 Label()Labeler

go
var first Labeler = Counter{Name: "download"}
var second Labeler = &Counter{Name: "upload"}

Interface 一般由使用者在需要的地方定义,并尽量只包含当前逻辑依赖的方法。小型 Interface 更容易由不同类型实现,也更容易测试。

取出 Interface 中的具体值

Interface 变量只能直接使用 Interface 声明的方法,但它在运行时仍然保存着一个具体类型和值。需要检查这个具体类型,并把其中的值取出来时,可以使用类型断言。

类型断言只能用于 Interface 类型的值。anyinterface{} 的别名,本身也是一种 Interface,可以保存任意类型的值:

go
var value any = Counter{Name: "download", Value: 10}

这里的 value 声明为 any,实际保存的具体类型是 Counter。类型断言使用 value.(Counter) 检查并取出这个具体值:

go
counter, ok := value.(Counter)
if !ok {
	fmt.Println("value is not a Counter")
	return
}

fmt.Println(counter.Value)

类型断言可以返回两个结果:

  • counter 是取出的 Counter 值;
  • ok 表示 Interface 中保存的具体类型是否真的是 Counter

如果类型不匹配,okfalsecounter 则是 Counter 的零值。无法确定具体类型时,应优先使用这种安全写法。

也可以只接收断言得到的值:

go
counter := value.(Counter)

这种写法在类型不匹配时会触发 panic,因此只适合已经确定具体类型的情况。

使用类型选择处理多种类型

如果一个 Interface 可能保存多种具体类型,可以使用类型选择:

go
func describe(value any) {
	switch current := value.(type) {
	case Counter:
		fmt.Println(current.Label())
	case string:
		fmt.Println(current)
	default:
		fmt.Println("unknown value")
	}
}

每个 case 表示一种具体类型,变量 current 会在对应分支中成为该类型。类型选择同样只能用于 Interface。

any 虽然可以保存任意类型的值,但也会暂时隐藏具体类型信息,因此不应为了省略类型设计而在所有地方使用 any

理解 typed nil

一个 Interface 值由动态类型和动态值两部分组成。只有这两部分都为空时,Interface 才等于 nil

go
var value any
fmt.Println(value == nil) // true

如果把 nil 指针放入 Interface,Interface 已经记录了具体的指针类型,所以它本身不等于 nil

go
var counter *Counter = nil
var value any = counter

fmt.Println(counter == nil) // true
fmt.Println(value == nil)   // false

这种值常被称为 typed nil。它的动态类型是 *Counter,动态值才是 nil

typed nil 在返回 error 等 Interface 时尤其容易造成误判。下面的函数看似返回了 nil,实际返回的 Interface 并不为空:

go
type CounterError struct {
	Message string
}

func (e *CounterError) Error() string {
	return e.Message
}

func validate() error {
	var err *CounterError
	return err
}

调用 validate() 后,err != nil 会成立。没有错误时,应直接返回不带具体类型的 nil

go
func validate() error {
	return nil
}

使用 new 创建指针

new(T) 会为 T 的零值分配空间,并返回一个 *T

go
counter := new(Counter)

fmt.Println(counter.Name)  // ""
fmt.Println(counter.Value) // 0

得到的指针可以像其他 Struct 指针一样使用:

go
counter := new(Counter)
counter.Name = "download"
counter.Increment()

fmt.Println(counter.Label()) // download: 1

如果创建值时已经知道字段内容,结构体字面量通常更清楚:

go
counter := &Counter{
	Name:  "download",
	Value: 1,
}

newmake 的用途不同。new(T) 返回指向 T 零值的指针;make 是另一个内置初始化函数,它在集合和并发代码中的用法会在后续文章中介绍。

总结

Struct 负责组织数据,方法负责为类型定义行为。需要修改原值时,可以使用指针和指针接收者;需要复用数据与方法时,可以通过字段或嵌入进行组合。

Interface 由方法集定义,并由具体类型隐式实现。使用 Interface 时,还需要理解类型断言、指针方法集和 typed nil,避免在类型转换或 nil 判断中得到意外结果。