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)