Go 的 Struct、Interface 与指针
Go 没有 class 和基于类的继承。程序通常使用 Struct 组织数据,通过方法为类型定义行为,再借助组合与 Interface 复用能力。
本文从 Struct 开始,依次介绍方法、指针、组合、Interface 和类型断言,并说明 typed nil 与 new 的常见用法。
使用 Struct 组织数据
Struct 是一组具有名称和类型的字段,适合描述一份相关的数据。下面定义了一个保存名称和计数的 Counter:
type Counter struct {
Name string
Value int
}定义类型之后,可以使用结构体字面量创建值:
counter := Counter{
Name: "download",
Value: 10,
}
fmt.Println(counter.Name) // download
fmt.Println(counter.Value) // 10初始化 Struct 时,通常显式写出字段名。这样不会依赖字段的声明顺序,后续增加字段时也更容易维护。
未赋值的字段会获得对应类型的零值:
counter := Counter{Name: "download"}
fmt.Println(counter.Name) // download
fmt.Println(counter.Value) // 0Struct 是值类型。将它赋给另一个变量时,会复制一份独立的值:
first := Counter{Name: "download", Value: 10}
second := first
second.Value = 20
fmt.Println(first.Value) // 10
fmt.Println(second.Value) // 20为类型定义方法
方法是在函数名前增加接收者的函数。接收者决定了这个方法属于哪个类型:
func (c Counter) Label() string {
return fmt.Sprintf("%s: %d", c.Name, c.Value)
}这里的 (c Counter) 是值接收者。调用方法时,Go 会把 Counter 的副本传给 Label:
counter := Counter{Name: "download", Value: 10}
fmt.Println(counter.Label()) // download: 10值接收者适合只读取数据、不需要修改原值的方法。即使在方法内部修改接收者,变化也只发生在副本上:
func (c Counter) ResetCopy() {
c.Value = 0
}
counter := Counter{Name: "download", Value: 10}
counter.ResetCopy()
fmt.Println(counter.Value) // 10使用指针修改原值
Go 的函数参数按值传递。需要让函数修改调用方持有的值时,可以传入指针。
& 用于取得变量的地址,*T 表示指向 T 类型值的指针:
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:
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。
最直接的方式是将一个类型声明为普通字段:
type Metadata struct {
CreatedBy string
}
type Task struct {
Info Metadata
Counter Counter
}访问嵌套字段时,需要经过对应的字段名:
task := Task{
Info: Metadata{CreatedBy: "Gopher"},
Counter: Counter{
Name: "download",
},
}
fmt.Println(task.Info.CreatedBy)
task.Counter.Increment()如果省略字段名,只写类型,就形成嵌入字段。嵌入类型的字段和方法会被提升,可以直接从外层值访问:
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 方法:
type Labeler interface {
Label() string
}Go 使用隐式实现。类型只要拥有 Interface 要求的全部方法,就自动满足该 Interface,不需要额外声明 implements:
func printLabel(value Labeler) {
fmt.Println(value.Label())
}
counter := Counter{Name: "download", Value: 10}
printLabel(counter)Counter 已经定义了 Label() string,所以能够传给 printLabel。如果删除这个方法,编译器会在传值的位置报告错误。
需要在实现附近明确检查这种关系时,可以添加编译期断言:
var _ Labeler = Counter{}这条语句不会创建可供程序使用的变量,也不是实现 Interface 的必要步骤。它只是要求编译器验证 Counter 是否满足 Labeler。
先认识接收者与方法集
理解类型如何实现 Interface 前,需要先回顾几个概念:
- 方法:属于某个类型的函数,例如
Label()和Increment(); - 值接收者:接收者类型是
Counter,方法得到的是值的副本; - 指针接收者:接收者类型是
*Counter,方法可以通过指针修改原值; - 方法集:一个类型可以用于满足 Interface 的方法集合;
前文定义了两个方法:
func (c Counter) Label() string {
return fmt.Sprintf("%s: %d", c.Name, c.Value)
}
func (c *Counter) Increment() {
c.Value++
}Label 使用值接收者,Increment 使用指针接收者。它们会形成下面的方法集:
| 类型 | 可以用来满足 Interface 的方法 |
|---|---|
Counter | Label() |
*Counter | Label()、Increment() |
也就是说,值类型 Counter 只包含值接收者的方法;指针类型 *Counter 则同时包含值接收者和指针接收者的方法。
直接调用不等于实现 Interface
下面的代码可以直接调用 Increment():
counter := Counter{Name: "download"}
counter.Increment()这是因为 counter 是一个可以取得地址的变量。编译器会提供语法上的便利,把这次调用理解为:
(&counter).Increment()但判断一个类型是否实现 Interface 时,Go 只检查这个类型的方法集,不会自动把 Counter 改成 *Counter。
例如,下面的 Interface 要求一个 Increment() 方法:
type Incrementer interface {
Increment()
}只有 *Counter 的方法集包含 Increment(),因此必须传入指针:
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:
var first Labeler = Counter{Name: "download"}
var second Labeler = &Counter{Name: "upload"}Interface 一般由使用者在需要的地方定义,并尽量只包含当前逻辑依赖的方法。小型 Interface 更容易由不同类型实现,也更容易测试。
取出 Interface 中的具体值
Interface 变量只能直接使用 Interface 声明的方法,但它在运行时仍然保存着一个具体类型和值。需要检查这个具体类型,并把其中的值取出来时,可以使用类型断言。
类型断言只能用于 Interface 类型的值。any 是 interface{} 的别名,本身也是一种 Interface,可以保存任意类型的值:
var value any = Counter{Name: "download", Value: 10}这里的 value 声明为 any,实际保存的具体类型是 Counter。类型断言使用 value.(Counter) 检查并取出这个具体值:
counter, ok := value.(Counter)
if !ok {
fmt.Println("value is not a Counter")
return
}
fmt.Println(counter.Value)类型断言可以返回两个结果:
counter是取出的Counter值;ok表示 Interface 中保存的具体类型是否真的是Counter。
如果类型不匹配,ok 为 false,counter 则是 Counter 的零值。无法确定具体类型时,应优先使用这种安全写法。
也可以只接收断言得到的值:
counter := value.(Counter)这种写法在类型不匹配时会触发 panic,因此只适合已经确定具体类型的情况。
使用类型选择处理多种类型
如果一个 Interface 可能保存多种具体类型,可以使用类型选择:
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:
var value any
fmt.Println(value == nil) // true如果把 nil 指针放入 Interface,Interface 已经记录了具体的指针类型,所以它本身不等于 nil:
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 并不为空:
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:
func validate() error {
return nil
}使用 new 创建指针
new(T) 会为 T 的零值分配空间,并返回一个 *T:
counter := new(Counter)
fmt.Println(counter.Name) // ""
fmt.Println(counter.Value) // 0得到的指针可以像其他 Struct 指针一样使用:
counter := new(Counter)
counter.Name = "download"
counter.Increment()
fmt.Println(counter.Label()) // download: 1如果创建值时已经知道字段内容,结构体字面量通常更清楚:
counter := &Counter{
Name: "download",
Value: 1,
}new 与 make 的用途不同。new(T) 返回指向 T 零值的指针;make 是另一个内置初始化函数,它在集合和并发代码中的用法会在后续文章中介绍。
总结
Struct 负责组织数据,方法负责为类型定义行为。需要修改原值时,可以使用指针和指针接收者;需要复用数据与方法时,可以通过字段或嵌入进行组合。
Interface 由方法集定义,并由具体类型隐式实现。使用 Interface 时,还需要理解类型断言、指针方法集和 typed nil,避免在类型转换或 nil 判断中得到意外结果。