接口:Go 最不像 Kotlin 的东西
如果这本书你只精读一章,选这章。Go 的接口和你在 Java / Kotlin 用了十年的 interface 是两种根本不同的东西。那边你要写 class Dog : Animal,明确宣告「我实现了这个接口」。Go 这边——你什么都不用宣告。一个类型只要碰巧有了接口要求的那几个方法,它就自动满足了那个接口。「我实现了谁」这一栏,永远是留白的。
先看 Java / Kotlin 是怎么做的
在你熟的世界里,接口是名义式的(nominal)—— 一个类实现了哪些接口,是它亲口声明的:
interface Greeter {
fun greet(): String
}
// Dog 必须显式写出 : Greeter,宣告「我实现了它」
class Dog : Greeter {
override fun greet() = "woof"
}
关键在那个 : Greeter。没有它,哪怕 Dog 有一个一模一样的 greet() 方法,它也不算 Greeter,你没法把它当 Greeter 用。「是不是这个接口」取决于「有没有声明」,而不只是「有没有那些方法」。这套机制还有个硬约束:你得先有接口,类才能去实现它 —— 你没法让一个别人写的、早就存在的类,去实现一个你今天才定义的接口。
Go 的接口:只看方法,不看声明
同样的东西,Go 这样写:
type Greeter interface {
Greet() string
}
type Dog struct {
Name string
}
// 注意:没有任何「implements Greeter」的字样!
func (d Dog) Greet() string {
return "woof"
}
Dog 从头到尾没有提过 Greeter 一个字。但因为它有一个签名匹配的 Greet() string 方法,它就自动、隐式地满足了 Greeter 接口。你可以直接把 Dog 当 Greeter 用:
var g Greeter = Dog{Name: "旺财"} // 直接就能赋值,编译器认
fmt.Println(g.Greet()) // woof
// 一个吃 Greeter 的函数,Dog 直接能传进去
func sayHi(g Greeter) {
fmt.Println(g.Greet())
}
sayHi(Dog{}) // 没问题
这就是 Go 的结构化类型(structural typing)—— 一个类型是不是某接口,只看它的结构(有没有那些方法),不看它的声明(有没有说过实现谁)。编译器在你「把 Dog 当 Greeter 用」的那一刻,去检查 Dog 有没有 Greeter 要的方法,有就放行。这也常被叫「鸭子类型」的静态版本:走起来像鸭子、叫起来像鸭子,它就是鸭子 —— 只不过 Go 是在编译期查的,安全。
这本书叫《留白》,这一章是它最正的一个注脚。在 Kotlin 里,每个类都要显式填上「我实现了哪些接口」这一栏;在 Go 里,这一栏永远空着 —— 类型只管长好自己的方法,「它满足了哪些接口」由编译器在使用的地方现算,从不写在类型自己身上。
这个留白不是省事,是解耦。类型和接口之间没有了那根「声明」的硬连线,于是它们可以各自独立地存在、演化,在需要的时候才被匹配到一起。这个自由度,是 Go 接口全部威力的来源。
亲手验一验:接口满足检查器
下面这台是真的引擎,不是示意图。你敲进 struct、方法、interface,它真的去解析你的代码,算出类型的方法集,逐个方法比对接口要求,判断满不满足。先跑第一个例子(值接收者的 Point.String()):
看第一个例子的结论:Point 有一个 String() string,正好是 Stringer 接口要的,于是值 Point 和指针 *Point 都满足。你没写过一个 implements 字,检查器却算出它满足了 —— 因为满足与否是从方法结构推出来的。
先别急着切别的例子(第 2 个「指针接收者」是下一章的主角,那里藏着最大的坑)。这一章你先建立一个直觉:接口满足是「算」出来的,不是「声明」出来的。
这个留白,到底解锁了什么
隐式满足看着只是「少写一个词」,但它带来一个 Kotlin 做不到的能力,值得单独讲。
你能为「别人的类型」定义接口
在 Java 里,接口和实现是绑死的:一个类要实现接口,得在类的定义处写上。所以你没法让标准库里、或者第三方库里一个早已存在、你改不了源码的类,去实现你今天新写的接口。
Go 反过来。因为满足是隐式的,你可以在自己的代码里定义一个接口,然后让任何已存在的类型(哪怕是标准库的、第三方的)自动满足它 —— 只要它碰巧有对的方法。经典例子是 io.Writer:
// 标准库定义的接口,只有一个方法
type Writer interface {
Write(p []byte) (n int, err error)
}
凡是有 Write([]byte) (int, error) 方法的类型,都自动是 Writer —— 文件 *os.File、网络连接、内存缓冲 *bytes.Buffer、HTTP 响应、gzip 压缩器…… 它们的作者各写各的,谁都没有引用过对方,却都能塞进任何一个吃 io.Writer 的函数(比如 fmt.Fprintf(w, ...))。这种「互不知情却能协作」的解耦,是靠隐式接口才可能的。
这是 Go 接口设计里最深、也最反 Java 直觉的一条:接口应该定义在消费者那一侧,而不是生产者那一侧。
Java 的习惯是「先设计一套接口,再让实现类去实现」—— 接口是生产者预先规划好、发布出去的契约。Go 的习惯是「我这个函数需要什么行为,我就在我这里定义一个小接口描述这个需求」—— 谁提供这个行为、是不是提前知道我这个接口,都不重要。接口是「需求方」表达「我要什么」,而不是「供给方」宣布「我提供什么」。这个视角反过来了,你写 Go 才算入门。
Go 谚语:接口越大,抽象越弱
你可能注意到 io.Writer 只有一个方法。这不是偷懒,是 Go 接口设计的核心美学:
「接口越大,抽象越弱。」
The bigger the interface, the weaker the abstraction. — Rob Pike
想想看:一个接口要求的方法越多,能满足它的类型就越少、越难替换。而一个只要求一个方法的接口(io.Writer、io.Reader、fmt.Stringer、sort.Interface 也才三个),几乎什么类型都能轻松满足,组合起来极其灵活。Go 标准库里最有力的抽象,全是这种一两个方法的小接口。
这也和结构化类型天然契合:接口越小,一个类型「碰巧满足它」的概率就越大,隐式满足的威力就越强。所以 Go 的接口风格是「许多小接口,按需组合」,而不是 Java 那种「一个大接口列一堆方法」。你想要的能力用大接口表达不了,就把它拆成几个小接口 —— 第 12 章会讲接口怎么组合。
凡事有代价。隐式满足意味着:你没法从一个类型的定义处,一眼看出它实现了哪些接口(那一栏是空的,得去搜谁在用它)。也意味着你可能不小心满足一个接口(方法名撞了),或者改个方法签名不小心就不满足了、而编译错误出现在很远的调用处。
Go 社区有个惯用法应对:如果你想确保某类型满足某接口、并让编译器帮你盯着,加一行 var _ Greeter = Dog{} —— 这行什么也不做,但如果 Dog 哪天不满足 Greeter 了,它会当场编译报错。这是把「隐式」在你需要的地方局部变「显式」的小技巧。
Kotlin 对照
Kotlin / Java 是名义式(nominal):类型的身份由「它声明自己是什么」决定。class Dog : Animal 里那个 : Animal 是身份证。好处是显式、可追溯 —— 打开 Dog 就知道它是 Animal,IDE 能直接跳。接口是提前设计、由实现方签署的契约。
Go 是结构化(structural):类型的身份由「它长什么样(有哪些方法)」决定。没有身份证,编译器现场比对。好处是解耦、灵活、能事后为已有类型定义接口;代价是从类型本身看不出它满足了谁。
迁移时最大的心态转变有两个。一,停止「先设计接口层次再实现」的冲动。别一上来画一堆接口,而是等某个函数真的需要某种行为时,就地定义一个小接口。二,接口写在用它的地方(消费者包),别写在实现它的地方(生产者包)。带着 Java 的「接口驱动设计」惯性写 Go,你会造出一堆过度抽象、没人满足的大接口 —— 那正是 Go 社区嘲笑的 FooManagerFactoryInterface 味道。Go 的接口是从具体需求里「浮现」出来的,不是提前规划出来的。
这一章的一句话
Go 的接口是结构化、隐式的:一个类型满不满足接口,看它有没有那几个方法,而不看它声明了什么 ——「我实现了谁」那一栏永远留白;这解锁了「为别人的类型定义接口」「接口属于消费者而非生产者」两大能力,配合「接口越小越强」的美学,构成了 Go 最有力也最不像 Kotlin 的抽象方式。
刚才 Demo 里我们跳过了第 2 个例子 —— 那个用指针接收者的 Counter。下一章我们直面它:为什么同样的方法,值类型不满足接口、指针类型却满足?这是全书最容易让人栽跟头的坑,我们把它彻底挖开。