Go嵌套:结构体嵌套接口

创建于 2023年12月15日修改于 2024年5月5日
GoEmbedding

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 接口的任何对象。这个对象从哪里来?当初始化时,它被赋值给 ContainerFooer 字段。下面是一个例子:

// 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.Copyr 复制到 f。这个 onlyWriter 包装器是什么呢?

type onlyWriter struct {
  io.Writer
}

有趣。这是我们熟悉的在结构体中嵌入接口的机制。但是,如果我们在文件中搜索,不会找到在 onlyWriter 上定义的任何方法,因此它没有拦截覆盖任何内容。那为什么需要它呢?

我们应该看一下 io.Copy 的作用。它的代码很长,所以我不会在这里完整重现它;但要注意的关键部分是,如果其目标实现了 io.ReaderFrom,它将调用 ReadFrom。但是这使我们陷入了一个循环,因为我们是在调用 File.ReadFrom 时最终进入了 io.Copy。这导致了无限递归!

现在 onlyWriter 的存在理由变得清晰起来。通过在调用 io.Copy 时包装 fio.Copy 得到的不是实现了 io.ReaderFrom 的类型,而是仅实现了 io.Writer的类型。然后,它将调用我们的 FileWrite 方法,并避免 ReadFrom 的无限递归陷阱。

正如我之前提到的,这个技术属于高级技巧。

File 中的使用是一个很好的例子,因为它为 onlyWriter 提供了一个明确定义的类型,这有助于理解它的作用。

标准库中的一些代码没有借鉴这种自我说明的模式,使用了一个匿名结构体,例如,在 tar 包中的使用:

io.Copy(struct{ io.Writer }{sw}, r)