Go嵌套:结构体嵌套接口
原文:https://eli.thegreenplace.net/2020/embedding-in-go-part-3-interfaces-in-structs/
翻译:literank.cn
结构体中嵌套接口
乍一看,这是 Go 中最令人困惑的嵌套。在这篇文章中,我们将逐步了解这个技术,并呈现几个标准库中的实际例子。
让我们从一个简单的例子开始:
type Fooer interface {
Foo() string
}
type Container struct {
Fooer
}
Fooer
是一个接口,而 Container
嵌入了它。回顾系列第一篇,结构体中的嵌套会将被嵌套结构的方法提升为嵌套结构的方法。对于嵌套接口,它的机制类似;我们可以将其视为 Container
具有以下转发方法:
func (cont Container) Foo() string {
return cont.Fooer.Foo()
}
但 cont.Fooer
引用的是什么呢?它是一个实现 Fooer
接口的任何对象。这个对象从哪里来?当初始化时,它被赋值给 Container
的 Fooer
字段。下面是一个例子:
// sink 接受实现 Fooer 接口的值。
func sink(f Fooer) {
fmt.Println("sink:", f.Foo())
}
// TheRealFoo 是一个实现 Fooer 接口的类型。
type TheRealFoo struct {
}
func (trf TheRealFoo) Foo() string {
return "TheRealFoo Foo"
}
现在我们可以这样做:
co := Container{Fooer: TheRealFoo{}}
sink(co)
这将打印 sink: TheRealFoo Foo
。
发生了什么?注意 Container
的初始化方式;嵌入的 Fooer
字段被赋予了一个类型为 TheRealFoo
的值。我们只能将实现了 Fooer
接口的值分配给这个字段 - 否则编译器会拒绝。
由于 Fooer
接口嵌套在 Container
中,它的方法被提升为 Container
的方法,这使得 Container
也实现了 Fooer
接口!这就是为什么我们可以将 Container
传递给 sink
;如果没有嵌套,sink(co)
将无法编译,因为 co
没有实现 Fooer
。
你可能想知道如果 Container
的嵌套 Fooer
字段没有被初始化会发生什么?好问题!该字段保留其默认值,即 nil
。因此,这段代码:
co := Container{}
sink(co)
会导致运行时错误:runtime error: invalid memory address or nil pointer dereference
(无效的内存地址或空指针解引用)。
这基本上展现了嵌套接口在结构体中的工作原理。更重要的问题是 - 我们为什么需要它呢?接下来让我们看看更多的例子。
例子:接口包装器 interface wrapper
这个例子来自 GitHub 用户 valyala,摘自这个评论。
假设我们想要一个套接字连接,并添加一些附加功能,比如计算从中读取的总字节数。我们可以定义以下结构:
type StatsConn struct {
net.Conn
BytesRead uint64
}
StatsConn
现在实现了 net.Conn
接口,可以在任何需要 net.Conn
的地方使用。
当使用实现了 net.Conn
的嵌套字段初始化 StatsConn
时,它“继承”了该值的所有方法;然后,我们可以覆盖任何我们希望的方法,同时保留所有其他方法不变。在这个例子中,我们想覆盖 Read
方法并记录读取的字节数:
func (sc *StatsConn) Read(p []byte) (int, error) {
n, err := sc.Conn.Read(p)
sc.BytesRead += uint64(n)
return n, err
}
对于 StatsConn
的用户来说,这个改变是透明的;我们仍然可以在其上调用 Read
,并且它会按预期的方式工作(因为它委托给 sc.Conn.Read
),但它还会进行额外的记录。
正如在前一部分中所示,正确初始化 StatsConn
非常重要,例如:
conn, err := net.Dial("tcp", u.Host+":80")
if err != nil {
log.Fatal(err)
}
sconn := &StatsConn{conn, 0}
在这里,net.Dial
返回一个实现了 net.Conn
的值,所以我们可以用它来初始化 StatsConn
的嵌套字段。
现在我们可以将 sconn
传递给任何期望 net.Conn
参数的函数,例如:
resp, err := ioutil.ReadAll(sconn)
if err != nil {
log.Fatal(err)
}
操作完成后,我们可以访问其 BytesRead
字段以获取总数。
上面是一个包装接口的示例,我们创建了一个新类型,该类型实现了现有接口。
如果我们不嵌套的话,虽然也可以实现类似功能,但是比较麻烦。比如下方结构体有一个明确的 conn
字段:
type StatsConn struct {
conn net.Conn
BytesRead uint64
}
然后为 net.Conn
接口中的每个方法编写转发方法,例如:
func (sc *StatsConn) Close() error {
return sc.conn.Close()
}
然而,net.Conn
接口有8个方法。为所有这些方法编写转发方法是繁琐且不必要的。通过嵌入接口,我们可以免费获得所有这些转发方法,并且只需要覆盖我们需要的方法。
例子:sort.Reverse
在 Go 标准库中,嵌套接口在结构体中的一个经典例子是 sort.Reverse
。对于 Go 新手来说,这个函数的使用经常令人困惑,主要是因为对它的工作原理不清楚。
让我们从一个简单的排序例子开始。
lst := []int{4, 5, 2, 8, 1, 9, 3}
sort.Sort(sort.IntSlice(lst))
fmt.Println(lst)
这将打印 [1 2 3 4 5 8 9]
。它是如何工作的呢?sort.Sort
函数接受实现了 sort.Interface
接口的参数,该接口定义为:
type Interface interface {
// Len is the number of elements in the collection.
Len() int
// Less reports whether the element with
// index i should sort before the element with index j.
Less(i, j int) bool
// Swap swaps the elements with indexes i and j.
Swap(i, j int)
}
如果我们有一个想用 sort.Sort
进行排序的类型,那么我们必须实现这个接口。对于像整数切片这样的简单类型,标准库提供了方便的类型,如 sort.IntSlice
,它接受我们的值并在其上实现 sort.Interface
的方法。
那么 sort.Reverse
是如何工作的呢?通过巧妙地使用一个嵌套在结构体中的接口。sort
包有这个(未导出的)类型来帮助完成任务:
type reverse struct {
sort.Interface
}
func (r reverse) Less(i, j int) bool {
return r.Interface.Less(j, i)
}
到这里就很清楚了。reverse
通过嵌入 sort.Interface
实现了该接口(只要初始化时带入实现该接口的值即可)的方法,并拦截覆盖了该接口的一个方法 - Less
。然后,它将其委托给被嵌入值的 Less
方法,但是反转了参数的顺序。这使得排序按相反的顺序进行。
sort.Reverse
函数只需生成这个包装后的结构体:
func Reverse(data sort.Interface) sort.Interface {
return &reverse{data}
}
现在我们可以这样做:
sort.Sort(sort.Reverse(sort.IntSlice(lst)))
fmt.Println(lst)
这将打印[9 8 5 4 3 2 1]
。这里要理解的关键点是,调用 sort.Reverse
本身不会对任何东西进行排序或反转。它可以被看作是一个高阶函数:它生成一个包装给定接口的值,并调整其功能。排序发生的地方是对 sort.Sort
的调用。
例子:context.WithValue
context
包有一个名为 WithValue
的函数:
func WithValue(parent Context, key, val interface{}) Context
它“返回 parent
的副本,其中与 key
关联的值为 val
”。让我们看看它在底层是如何工作的。
略去错误检查代码,WithValue
基本上可以归结为:
func WithValue(parent Context, key, val interface{}) Context {
return &valueCtx{parent, key, val}
}
其中 valueCtx
是:
type valueCtx struct {
Context
key, val interface{}
}
这就是一个嵌入接口的结构体。valueCtx
实现了 Context
接口,并可以自由拦截覆盖 Context
的4个方法之一。它拦截了 Value
:
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key)
}
并保持其他方法不变。
例子:通过更受限制的接口降级能力
这个技术相当高级,但在整个标准库中都有使用。
让我们首先讨论 io.ReaderFrom
接口:
type ReaderFrom interface {
ReadFrom(r Reader) (n int64, err error)
}
这个接口由那些可以从 io.Reader
读取数据的类型实现。例如,os.File
类型实现了这个接口,并将数据从读取器读取到它打开的文件中。让我们看看它是如何实现的:
func (f *File) ReadFrom(r io.Reader) (n int64, err error) {
if err := f.checkValid("write"); err != nil {
return 0, err
}
n, handled, e := f.readFrom(r)
if !handled {
return genericReadFrom(f, r)
}
return n, f.wrapErr("write", e)
}
它首先尝试使用 readFrom
方法从 r
中读取,该方法是特定于操作系统的。例如,在 Linux 上,它使用 copy_file_range
系统调用在内核中直接在两个文件之间进行非常快速的复制。
readFrom
返回一个布尔值,表示是否成功(handled
)。如果没有成功,ReadFrom
将尝试执行一个“通用”操作,使用 genericReadFrom
,其实现如下:
func genericReadFrom(f *File, r io.Reader) (int64, error) {
return io.Copy(onlyWriter{f}, r)
}
它使用 io.Copy
从 r
复制到 f
。这个 onlyWriter
包装器是什么呢?
type onlyWriter struct {
io.Writer
}
有趣。这是我们熟悉的在结构体中嵌入接口的机制。但是,如果我们在文件中搜索,不会找到在 onlyWriter
上定义的任何方法,因此它没有拦截覆盖任何内容。那为什么需要它呢?
我们应该看一下 io.Copy
的作用。它的代码很长,所以我不会在这里完整重现它;但要注意的关键部分是,如果其目标实现了 io.ReaderFrom
,它将调用 ReadFrom
。但是这使我们陷入了一个循环,因为我们是在调用 File.ReadFrom
时最终进入了 io.Copy
。这导致了无限递归!
现在 onlyWriter
的存在理由变得清晰起来。通过在调用 io.Copy
时包装 f
,io.Copy
得到的不是实现了 io.ReaderFrom
的类型,而是仅实现了 io.Writer
的类型。然后,它将调用我们的 File
的 Write
方法,并避免 ReadFrom
的无限递归陷阱。
正如我之前提到的,这个技术属于高级技巧。
在 File
中的使用是一个很好的例子,因为它为 onlyWriter
提供了一个明确定义的类型,这有助于理解它的作用。
标准库中的一些代码没有借鉴这种自我说明的模式,使用了一个匿名结构体,例如,在 tar
包中的使用:
io.Copy(struct{ io.Writer }{sw}, r)