Appearance
P2 · 语言核心:用 Rust 的经验写 Go,哪里需要换直觉?
这章学完你能干什么:读懂任意 Go 工程里的接口分派和错误传播,并预言一段代码的行为。包括
speakAll([]Speaker{Dog{}, Robot{}})打印什么、嵌入后为什么不能当父类用、errors.Is顺着什么链找 sentinel。 读者:熟悉 Rust。前置:P1.5。后续:P2.5 惯用法,P3 并发。动手方式:开一个空目录,
go mod init demo,每个例子存成main.go,用go run main.go运行。片段省略了package main和import。
体系背景:接口值在内存里长什么样
先看三个数字:
go
var s Speaker = Dog{"rex"}
var n int
fmt.Println("int 的大小:", unsafe.Sizeof(n))
fmt.Println("接口值的大小:", unsafe.Sizeof(s))sh
$ go run main.go
int 的大小: 8
接口值的大小: 16接口值是两个机器字,64 位机上共 16 字节。第一个字指向类型信息和方法表,叫 itab,第二个字指向实际数据。
通过接口调用方法,就是先查 itab 找到函数地址,再间接调用。这和 Rust 的 trait object(胖指针加 vtable)是同一个设计,也是 Java 接口调用在 JVM 里的 invokeinterface 要做的事。
另一件事是零值。Go 分配内存时会清零,所以任何类型的"全 0 字节"状态都是合法的零值。后面"零值"一节会用到这个事实。
接口
假设你有 Dog 和 Robot 两个类型,想用同一个函数处理它们。先给它们各一个 Speak 方法:
go
type Speaker interface{ Speak() string }
type Dog struct{ Name string }
func (d Dog) Speak() string { return d.Name + ": woof" }
type Robot struct{ ID int }
func (r Robot) Speak() string { return fmt.Sprintf("robot-%d: beep", r.ID) }
func speakAll(ss []Speaker) string { /* 逐个调 Speak,用换行拼接 */ }
fmt.Println(speakAll([]Speaker{Dog{"rex"}, Robot{7}}))sh
$ go run main.go
rex: woof
robot-7: beepDog 和 Robot 没写过 impl Speaker for Dog,也互相不认识。Go 的规则是结构化满足:类型的方法集覆盖接口的方法集,就自动实现了这个接口。
这和 TypeScript 的结构化类型是同一个思路:对象形状对得上就行。Java 和 Kotlin 则要显式写 implements 或 : Speaker。
检查发生在编译期。给一个没有 Speak 的类型试试:
sh
$ go run main.go
# command-line-arguments
./main.go:8:18: cannot use Rock{} (value of struct type Rock) as Speaker value in variable declaration: Rock does not implement Speaker (missing method Speak)由此带来一个惯例:接口定义在使用方。Searcher 接口写在调用它的包里,被调用的包根本不知道自己被抽象了。标准库全是一两个方法的小接口。
代价是方法必须和类型定义在同一个包里。所以你不能给 string 加方法,也不能给别人的类型补方法。Kotlin 的扩展函数、JS 的 prototype 扩展,Go 都不给。
方法集
接口检查看的是方法集。T 的方法集只有值接收者方法,*T 的方法集两种都有。
go
type Adder interface{ Add(int) }
type Counter struct{ n int }
func (c *Counter) Add(d int) { c.n += d } // 指针接收者
c := Counter{}
var a Adder = csh
$ go run main.go
# command-line-arguments
./main.go:13:16: cannot use c (variable of struct type Counter) as Adder value in variable declaration: Counter does not implement Adder (method Add has pointer receiver)把 c 改成 &c 就能编译。这是新手编译错误的前几名,记住"接口要求什么接收者,就传什么形态"。Rust 的方法集没有值和指针之分,这里要换直觉。
嵌入
有了接口,下一个问题是复用。把一个类型嵌进另一个,外层就能直接调用内层的方法:
go
type Base struct{ Greeting string }
func (b Base) Hello() string { return b.Greeting }
type Greeter struct {
Base // 嵌入
Count int
}
g := Greeter{Base: Base{Greeting: "hi"}}
fmt.Println(g.Hello())
fmt.Println(g.Base.Hello())sh
$ go run main.go
hi
hi两行完全等价。g.Hello() 是编译期转发糖,编译器把它改写成 g.Base.Hello()。
Kotlin 读者可以把它看成没有 by 关键字的委托。Java 读者要注意,这里没有 extends,没有 super,也没有 @Override。
这不是继承。想把 g 赋给 Base 类型的变量,编译器会拒绝:
go
var b Base = gsh
# command-line-arguments
./main.go:18:15: cannot use g (variable of struct type Greeter) as Base value in variable declaration方法提升只让外层"能调到"内层,不让外层"是"内层。也没有 override。让外层定义同名方法,看内层的方法会不会被替换:
go
type Base struct{}
func (b Base) Name() string { return "Base" }
func (b Base) Hi() string { return "我是 " + b.Name() }
type Child struct{ Base }
func (c Child) Name() string { return "Child" }
c := Child{}
fmt.Println(c.Name())
fmt.Println(c.Hi())sh
$ go run main.go
Child
我是 Basec.Name() 用了外层的,但 Base.Hi 里的 b.Name() 仍然调用 Base 自己的。没有虚表,内层不知道外层存在。
结论是:复用用嵌入,多态用接口。别拿嵌入伪装继承。练习 TestEmbedding 断言的就是转发这一面。
错误
Go 没有 Result 和 ?,也没有 try/catch。error 是一个普通接口,错误就是返回值,if err != nil 是习惯不是语法。
要解决的第一件事,是让错误带上下文,同时调用方还能认出它是哪一类。用 %w 包装:
go
var ErrDivZero = errors.New("除数为零")
func div(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("divide %d by %d: %w", a, b, ErrDivZero)
}
return a / b, nil
}
_, err := div(7, 0)
fmt.Println(err)
fmt.Println(errors.Is(err, ErrDivZero), err == ErrDivZero)sh
$ go run main.go
divide 7 by 0: 除数为零
true false消息里有 7 和 0,给读日志的人看。errors.Is 顺着包装链找到 sentinel,直接 == 比较则找不到。
第二件事是取出具体类型拿字段,用 errors.As,或 Go 1.26 起的泛型版 errors.AsType:
go
type NotFound struct{ Key string }
func (e *NotFound) Error() string { return "未找到 " + e.Key }
err = fmt.Errorf("查询失败: %w", &NotFound{Key: "user:42"})
var nf *NotFound
if errors.As(err, &nf) {
fmt.Println("key =", nf.Key)
}
if nf, ok := errors.AsType[*NotFound](err); ok {
fmt.Println("AsType key =", nf.Key)
}sh
$ go run main.go
查询失败: 未找到 user:42
key = user:42
AsType key = user:42%w 对应 Java 的 new Exception(msg, cause) 和 JS 的 new Error(msg, { cause }),errors.As 对应 catch 里按类型匹配,也就是 instanceof。
分界线是:Is 按值比 sentinel,As 按类型取结构体。两者都沿着 Unwrap() 链穿透。练习 TestErrorWrapping、TestErrorAs 和 TestAsTypeVsAs 各练一种。
接口里的 nil
回到体系背景里的"两个字"。错误是接口,接口是 (类型, 数据) 二元组,这会带来 Go 最著名的坑:
go
func bad(fail bool) error {
var e *MyErr // 具体类型的 nil 指针
if fail {
e = &MyErr{msg: "boom"}
}
return e
}
err := bad(false)
fmt.Println(err == nil, err)
fmt.Printf("%T\n", err)sh
$ go run main.go
false <nil>
*main.MyErr打印出来是 <nil>,== nil 却是 false。return e 把 (类型=*MyErr, 数据=nil) 装进了接口,类型字不是 0,接口就不等于 nil。
修法是 nil 时显式 return nil,让两个字都是 0:
go
func good(fail bool) error {
if fail {
return &MyErr{msg: "boom"}
}
return nil
}练习 TestInterfaceNil 的参考实现就是这样。Rust 没有这个坑,Option<Box<dyn T>> 把"没有"写在类型里。Java 和 JS 的 null 只有一种,也不会遇到。
零值
Go 每个类型天生有零值,递归到每个字段。好的设计让零值直接可用:
go
var mu sync.Mutex
mu.Lock()
mu.Unlock()
var buf bytes.Buffer
buf.WriteString("hello")
var sb strings.Builder
sb.WriteString("world")
fmt.Println(buf.String(), sb.String())
var s []int
s = append(s, 1)
fmt.Println(s, len(s))sh
$ go run main.go
hello world
[1] 1Java 的字段也有默认零值,但局部变量没有,且对象引用默认是 null。Go 的零值覆盖所有类型,切片和 map 的零值是 nil。
nil 切片能直接 append。但 map 不行:
go
var m map[string]int
fmt.Println(m["k"], len(m))
m["k"] = 1sh
0 0
panic: assignment to entry in nil map
goroutine 1 [running]:
main.main()
.../main.go:26 +0x2d4
exit status 2nil map 能读、不能写。写之前要 make。slice、map、channel 的零值都是"指向底层存储的指针还没分配"。
另外有些类型要求必须构造。time.Timer 的文档要求先用 NewTimer 或 AfterFunc 创建。判断口诀:文档写 "zero value ... ready to use" 就可以直接用,要求 New 或 initialize 的就别省。
类型断言与泛型
接口里装的具体类型,用 type switch 取出来:
go
func describe(v any) string {
switch x := v.(type) {
case string:
return fmt.Sprintf("str:%d", len(x))
case Robot:
return fmt.Sprintf("robot:%d", x.ID)
default:
return "unknown"
}
}sh
$ go run main.go
str:5
robot:7
unknown
"" false最后一行是单断言 s, ok := any(42).(string),类型不对时 ok 为 false,不会 panic。
泛型函数把类型参数写在函数名后面,约束必须是接口:
go
func Max[T cmp.Ordered](a, b T) T { /* a > b 则返回 a */ }
func MapAny[T, R any](xs []T, f func(T) R) []R { /* make 预分配,for 填充 */ }
fmt.Println(Max(3, 8), Max("go", "rust"))
fmt.Println(MapAny([]int{1, 2, 3}, strconv.Itoa))sh
$ go run main.go
8 rust
[1 2 3]Go 没有 .map().filter() 迭代器链,显式循环是主流。练习 TestTypeSwitch 和 TestGenerics 对应这两节。
小结
- 隐式满足加方法同包:接口定义在使用方,小接口优先。
- 方法集决定接口满足,接口要什么接收者就传什么形态。
- 嵌入是编译期转发糖,不是继承。多态只有接口一条路。
- 上下文用
%w包装,sentinel 用errors.Is,类型用errors.As。 - 接口是二元组,
return nil要显式。 - 零值可用是设计契约,slice 和 map 要
make,文档要求 New 的听文档。
练习:p2lang
先跑一遍看全绿:
sh
$ go test ./exercises/p2lang/...
ok golearn/exercises/p2lang 0.273s练习地图:
TestInterface:隐式满足加接口分派。TestEmbedding:方法提升不是多态。TestInterfaceNil:接口二元组的坑。TestZeroValue:零值strings.Builder。TestGenerics:泛型函数语法。TestTypeSwitch:type switch 取具体类型。TestErrorWrapping、TestErrorAs:在exercises/p1tool/error_test.go,练%w和Is/As。
预测,先写答案再跑:
speakAll([]Speaker{Dog{"rex"}, Robot{7}})输出什么?Greeter嵌入Base后,改掉Base.Hello的实现,g.Hello()的结果变吗?g能赋给Base变量吗?maybeFail(false)返回的 err 是 nil 吗?为什么?
观察:打开 lang_test.go 的 maybeFail,删掉 if e == nil { return nil } 三行,再跑 go test -run TestInterfaceNil ./exercises/p2lang/:
sh
--- FAIL: TestInterfaceNil (0.00s)
lang_test.go:127: after fix, want nil, got <nil>
FAIL看完用 git checkout 还原。
改造:给 exercises/p2lang/ 加一个只有 Speak() string 的 Cat 类型,让 speakAll 接受它。验收是 go vet ./exercises/p2lang/ && go test ./exercises/p2lang/ 全绿,并且你的测试断言了 Cat 在输出里。
章末参考
Rust 泛型对照
| Rust | Go | 备注 |
|---|---|---|
fn max<T: Ord>(a, b) | func Max[T cmp.Ordered](a, b T) T | 约束必须是 interface,cmp.Ordered 和 comparable 预置 |
impl<T> Trait for T | 不存在 | 无 impl 泛型,无特化 |
| 关联类型 / GAT | 无 | 接口方法不能带自己的类型参数 |
| 单态化 | GC shape stenciling 加字典 | 泛型不装箱,接口装 |
老代码里 map[string]interface{} 是泛型前的写法,新代码用 any 加泛型。
方法集精确规则
- 接口方法要求指针接收者时,
T不满足,*T满足。 - 值接收者方法,
T和*T都满足。 - 可寻址的值调用
x.M()会自动取址。map 元素和接口内的具体值不可寻址,只能调值接收者方法。这是map[string]T方法调用编译报错的根源。
var _ json.Marshaler = (*RawMessage)(nil) 是编译期验证满足关系的惯用法(Effective Go 的 Interface checks)。Rust 的 impl 块本身就是登记,不需要它。
嵌入的细节
- 字段也会提升:
g.Greeting合法。 - 外层自己定义了同名成员就遮蔽内层。同深度两个嵌入有同名方法,选择子非法,编译报错。
- 指针嵌入
*Base也提升,为 nil 时调用会 panic。 - 接口可以嵌接口,
io.ReadWriter就是Reader加Writer。Go 没有 default impl,靠接口组合拼装。
错误处理补充
errors.Join(errs...)(1.20 起)包一个错误列表,格式是换行拼接,Is和As会穿透。fmt.Errorf也允许多个%w,Unwrap() []error形成多叉树。- Go 1.26 起
errors.AsType[E]直接返回(E, bool)。go doc errors.As的建议是 "For most uses, prefer AsType",As保留给 target 不实现 error 的少数场景。并排的对照例子在exercises/p2lang/astype.go(TestAsTypeVsAs)。 - 判断
context.DeadlineExceeded这类 sentinel 也走errors.Is。 - panic 只用于程序员 bug,不可恢复的错误不走 panic。
- 自定义错误类型建议实现
Unwrap() error,否则Is和As断链。
零值与构造器惯例
NewXxx()返回*T是"零值不够用"的信号。文档样例都用NewXxx时,别用var x T。- 零值可用是传递的:
SyncedBuffer{lock, buffer}的两个字段零值都可用,整体就可用(Effective Go 原例)。 new(T)等价于&T{},字面量能带字段值。new在 1.26 起也能带初始化值。- P15 的
slog.NewJSONHandler必须传 writer,opts 传 nil 则落回默认级别。
Java/Kotlin 与 TS/JS 类比
| 概念 | Java / Kotlin | TS / JS |
|---|---|---|
| 隐式满足接口 | 需 implements,Kotlin 需 : Iface | 结构化类型,最接近 |
| 嵌入 | Kotlin by 委托,Java 无 | 组合或 mixin |
| 方法提升 | 无,Java 用 extends | 无 |
error 返回值 | checked exception,Kotlin Result | try/catch,Error 的 cause |
errors.As | catch 加 instanceof | instanceof Error 子类 |
| 零值 | 字段默认值,局部变量无 | undefined |
| type switch | switch 模式匹配(Java 21) | typeof 与 instanceof |