当创建一个结构体时,Go 提供了嵌入类型的选项。但如果我们不理解类型嵌入的所有含义,这有时会导致意想不到的行为。在本节中,我们将探讨如何嵌入类型,这些操作带来了什么,以及可能出现的问题。
在 Go 中,如果一个结构体字段在声明时没有名称,它就被称为嵌入的。例如:
type Foo struct {
Bar // 嵌入字段
}
type Bar struct {
Baz int
}
在 Foo 结构体中,Bar 类型是在没有关联名称的情况下声明的;因此,它是一个嵌入字段。
我们使用嵌入来提升嵌入类型的字段和方法。因为 Bar 包含一个 Baz 字段,这个字段被提升到 Foo(见图 2.8)。因此,Baz 可以从 Foo 中使用:
foo := Foo{}
foo.Baz = 42
请注意,Baz 可以通过两个不同的路径访问:要么通过提升的路径使用 Foo.Baz,要么通过名义上的路径通过 Bar,即 Foo.Bar.Baz。这两个都关联到同一个字段。
接口和嵌入
嵌入也用于接口中,以组合接口。在以下示例中,io.ReadWriter 由一个 io.Reader 和一个 io.Writer 组合而成:
type ReadWriter interface {
Reader
Writer
}
但本节的范围仅与结构体中的嵌入字段相关。
现在我们已经回顾了什么是嵌入类型,让我们来看一个错误使用的例子。在下面,我们实现了一个持有内存数据的结构体,我们想使用互斥锁来保护它免受并发访问的影响:
type InMem struct {
sync.Mutex // Embedded field
m map[string]int
}
func New() *InMem {
return &InMem{m: make(map[string]int)}
}
我们决定使 map 字段不可导出,这样客户端就不能直接与之交互,而只能通过导出的方法进行交互。与此同时,mutex 字段是嵌入的。因此,我们可以这样实现一个 Get 方法:
func (i *InMem) Get(key string) (int, bool) {
i.Lock() // Accesses the Lock method directly
v, contains := i.m[key]
i.Unlock() // The same goes for the Unlock method.
return v, contains
}
由于互斥锁是嵌入的,我们可以直接从 i 接收器访问 Lock 和 Unlock 方法。
我们提到这样的例子是错误的类型嵌入使用方式。为什么会这样呢?由于 sync.Mutex 是一个嵌入类型,Lock 和 Unlock 方法将会被提升。因此,这两种方法都会变得对使用 InMem 的外部客户端可见。
m := inmem.New()
m.Lock() // ??
这种提升可能并不是我们想要的。互斥锁在大多数情况下是我们想要封装在结构体内部并对外客户端不可见的。因此,在这种情况下,我们不应该将它作为一个嵌入字段:
type InMem struct {
mu sync.Mutex // 指明 sync.Mutex 字段不是嵌入的
m map[string]int
}
由于互斥锁没有被嵌入并且没有被导出,它不能被外部客户端访问。现在让我们来看看另一个例子,但这次嵌入可以被认为是一种正确的方法。
我们想编写一个自定义的日志记录器,它包含一个 io.WriteCloser
并公开两个方法,Write
和 Close
。如果 io.WriteCloser
没有被嵌入,我们将不得不这样编写它:
type Logger struct {
writeCloser io.WriteCloser
}
func (l Logger) Write(p []byte) (int, error) {
return l.writeCloser.Write(p) // Forwards the call to writeCloser
}
func (l Logger) Close() error {
return l.writeCloser.Close() // Forwards the call to writeCloser
}
func main() {
l := Logger{writeCloser: os.Stdout}
_, _ = l.Write([]byte("foo"))
_ = l.Close()
}
Logger 将不得不提供 Write
和 Close
方法,这两个方法将仅转发调用到 io.WriteCloser
。然而,如果该字段现在变为嵌入的,我们可以移除这些转发方法:
type Logger struct {
io.WriteCloser // Makes io.Writer embedded
}
func main() {
l := Logger{WriteCloser: os.Stdout}
_, _ = l.Write([]byte("foo"))
_ = l.Close()
}
对于客户端来说,仍然有两个导出的 Write
和 Close
方法。但是,这个例子防止了仅仅为了转发调用而实现这些额外的方法。同时,由于 Write
和 Close
被提升,这意味着 Logger
满足了 io.WriteCloser
接口。
嵌入与面向对象编程中的继承
区分嵌入与面向对象编程中的继承有时可能会令人困惑。主要区别与方法接收者的身份有关。让我们看看下面的图表。左侧表示类型 X 被嵌入到 Y 中,而右侧,Y 扩展了 X。
Embedding: Foo()
成为 Y 的一个方法; X 仍然是 Foo()
的接收者。
Subclassing: Foo()
成为 Y 的一个方法; Y 成为 Foo()
的接收者。
使用嵌入时,嵌入类型仍然是方法的接收者。相反,使用子类化时,子类成为方法的接收者。
使用嵌入时,Foo
的接收者仍然是 X。然而,使用子类化时,Foo
的接收者变成了子类 Y。嵌入是关于组合,而不是继承。
关于类型嵌入,我们应该得出什么结论呢?首先,让我们指出,它很少是必需的,这意味着无论用例是什么,我们可能也可以在没有类型嵌入的情况下解决它。类型嵌入主要用于方便:在大多数情况下,提升行为。
如果我们决定使用类型嵌入,我们需要记住两个主要的约束条件:
- 它不应该仅作为简化访问字段的语法糖使用(例如,使用
Foo.Baz()
而不是Foo.Bar.Baz()
)。如果这是唯一的理由,让我们不要嵌入内部类型,而是使用字段。 - 它不应用于提升我们想要从外部隐藏的数据(字段)或行为(方法):例如,如果它允许客户端访问应该保持结构体私有的锁定行为。
注:一些人也可能认为,在导出结构体的上下文中使用类型嵌入可能会导致维护方面的额外工作。的确,将一个类型嵌入到导出的结构体中意味着在该类型发展时要保持谨慎。例如,如果我们向内部类型添加了新方法,我们应该确保它不会破坏后面的约束。因此,为了避免这种额外的努力,团队也可以在公共结构体中禁止类型嵌入。
有意识地使用类型嵌入,并记住这些约束,可以帮助我们避免带有额外转发方法的样板代码。然而,让我们确保我们这样做不仅仅是为了美观,而不是提升应该保持隐藏的元素。