Struct Embedding in Go: Composition That Bites When You Reach for Inheritance

Struct Embedding in Go: Composition That Bites When You Reach for Inheritance

Go 语言中的结构体嵌入:当你试图用它实现继承时,它会反咬你一口

You come to Go from a language with classes. You see struct embedding for the first time, and it reads like inheritance. A field with no name, methods that “carry over” to the outer type, a base struct that your type extends. So you write code the way you always have, and most of it works. 如果你是从其他面向对象语言转到 Go 的,第一次看到结构体嵌入(Struct Embedding)时,它看起来非常像继承:一个没有名字的字段、自动“传递”给外部类型的方法,以及一个你的类型似乎在扩展的“基类”结构体。于是你按照以往的习惯写代码,大部分情况下它确实能跑通。

Then a method does something you did not ask for, a type satisfies an interface you never meant to implement, or two embedded types fight over a name and the compiler shrugs until the exact line that calls it. Embedding is not inheritance. It is composition with a syntax that promotes methods and fields up one level. Once you hold that distinction, the surprises stop being surprises. Here is where they come from. 但随后,某个方法执行了你意料之外的操作,或者你的类型意外地实现了一个你从未打算实现的接口,又或者两个嵌入类型因为名称冲突而打架,而编译器直到你调用该名称的那一行才报错。嵌入不是继承,它是一种通过语法将方法和字段提升一级的组合方式。一旦你理解了这种区别,这些“惊喜”就不再是惊喜了。以下是这些问题的根源。

Embedding promotes, it does not subclass

嵌入是提升,而非子类化

Write an embedded field by giving a type with no field name: 通过提供一个没有字段名的类型来编写嵌入字段:

type Engine struct { Horsepower int }
func (e Engine) Start() string { return "vroom" }

type Car struct {
    Engine // embedded
    Brand string
}

Car now has a Start method and a Horsepower field, both promoted from Engine. You can write car.Start() and car.Horsepower as if they were declared on Car. 现在 Car 拥有了 Start 方法和 Horsepower 字段,它们都从 Engine 提升而来。你可以像在 Car 中声明的那样直接调用 car.Start()car.Horsepower

car := Car{Engine: Engine{Horsepower: 300}, Brand: "Fiat"}
fmt.Println(car.Start()) // vroom
fmt.Println(car.Horsepower) // 300

This is where the inheritance illusion starts. car.Start() is sugar. The compiler rewrites it to car.Engine.Start(). The receiver of Start is still an Engine, never a Car. There is no base class, no super, no virtual dispatch. Engine does not know Car exists. That last point is the one that bites. A promoted method runs against the embedded value, not the outer struct. 这就是继承幻觉的开始。car.Start() 只是语法糖,编译器会将其重写为 car.Engine.Start()Start 的接收者依然是 Engine,绝不是 Car。这里没有基类,没有 super,也没有虚函数分发(virtual dispatch)。Engine 根本不知道 Car 的存在。最后这一点最容易让人踩坑:被提升的方法是在嵌入的值上运行的,而不是在外部结构体上运行的。

The method that ignores the outer struct

忽略外部结构体的方法

Say you want a stringer on the embedded type that reads outer fields. This is the move that feels like overriding a base method. 假设你想在嵌入类型上实现一个读取外部字段的字符串转换器。这看起来就像是在重写基类方法。

type Base struct { Name string }
func (b Base) Describe() string { return "base: " + b.Name }

type User struct {
    Base
    Role string
}

You construct a User, set the role, and call Describe, expecting the role to show up somehow. 你构建了一个 User,设置了角色,并调用 Describe,期待角色能显示出来。

u := User{Base: Base{Name: "ana"}, Role: "admin"}
fmt.Println(u.Describe()) // base: ana

There is no path from Describe to Role. Describe has a Base receiver. Base has no idea what Role is. In a class hierarchy a method on the parent can be overridden by the child and dynamic dispatch picks the override. Embedding has no dispatch. 从 DescribeRole 没有任何路径。Describe 的接收者是 Base,而 Base 不知道 Role 是什么。在类继承体系中,父类方法可以被子类重写,动态分发会选择重写后的版本。但嵌入没有分发机制。

If you want User to describe itself, you declare the method on User: 如果你想让 User 描述自己,你必须在 User 上声明该方法:

func (u User) Describe() string {
    return "user: " + u.Name + " (" + u.Role + ")"
}

Now User.Describe shadows the promoted Base.Describe. Calling u.Describe() runs the User version. Calling u.Base.Describe() still runs the base one. Both exist. You chose which by the selector, not by runtime type. 现在 User.Describe 遮蔽了提升后的 Base.Describe。调用 u.Describe() 会运行 User 版本,而调用 u.Base.Describe() 依然会运行基类版本。两者都存在,你通过选择器(selector)来决定调用哪一个,而不是通过运行时类型。

Accidental interface satisfaction

意外的接口实现

This is the surprise that ships to production. Embedding a type drags its whole method set into yours, and that method set can satisfy interfaces you never intended to implement. 这是最容易带入生产环境的“惊喜”。嵌入一个类型会将它的整个方法集拖入你的类型中,而这个方法集可能会满足你从未打算实现的接口。

type Closer interface { Close() error }

type Conn struct{}
func (c *Conn) Close() error { fmt.Println("closing real connection"); return nil }

type Service struct {
    *Conn // embedded for the Query method, say
}

You embedded *Conn because you wanted its query helpers. But *Conn has Close, so *Service now satisfies Closer too. A pool that ranges over Closer values and calls Close will close your service’s underlying connection, possibly one shared across the program. 你嵌入 *Conn 是因为想要它的查询辅助方法。但因为 *ConnClose 方法,所以 *Service 现在也满足了 Closer 接口。如果有一个遍历 Closer 值并调用 Close 的连接池,它就会关闭你服务的底层连接,而这个连接可能是在整个程序中共享的。

The rule: embedding is a public promise. Everything the embedded type exports, your type exports too. 规则是:嵌入是一个公开的承诺。嵌入类型导出的所有内容,你的类型也会导出。

Pointer vs value embedding changes the method set

指针嵌入与值嵌入会改变方法集

Whether you embed Conn or *Conn decides which methods get promoted, because of how Go builds method sets. A method with a pointer receiver belongs to the method set of the pointer type, not the value type. 嵌入 Conn 还是 *Conn 决定了哪些方法会被提升,这是由 Go 构建方法集的方式决定的。带有指针接收者的方法属于指针类型的方法集,而不属于值类型。

The fix when you want the value type itself to satisfy the interface is to embed the pointer, or to keep all receivers as values. Mixing value and pointer receivers on the same type is where this gets hard to reason about. Pick one receiver style per type and the method-set rules stop fighting you. 如果你希望值类型本身满足接口,解决方法是嵌入指针,或者保持所有接收者为值类型。在同一个类型上混合使用值接收者和指针接收者会让逻辑变得难以推断。为每个类型选择一种接收者风格,方法集规则就不会再给你添乱了。

Name collisions compile until you call them

名称冲突在调用前不会报错

Embed two types that both export the same method or field name, and the outer struct compiles. The conflict is only an error at the selector that uses the ambiguous name. 嵌入两个都导出相同方法或字段名的类型,外部结构体依然可以通过编译。只有当你使用那个有歧义的名称进行调用时,冲突才会报错。

type Reader struct{}
func (Reader) Read() string { return "read" }

type Writer struct{}
func (Writer) Read() string { return "also read" }

type Pipe struct {
    Reader
    Writer
}

Pipe declares fine. You can construct it, store it, pass it around. The Go spec says a name promoted from two embedded types at the same depth is not promoted at all. So Pipe has no Read method. Pipe 的声明没问题。你可以构建它、存储它、传递它。Go 规范规定,从两个相同深度的嵌入类型提升的名称不会被提升。因此,Pipe 没有 Read 方法。

var p Pipe
p.Read() // compile error: ambiguous selector p.Read

The collision sleeps until a caller writes the code that triggers it. 这个冲突会一直“沉睡”,直到调用者写出触发它的代码为止。