卷 III · 接口CH 09深度 09/24

接口:Go 最不像 Kotlin 的东西

本章卸下implements 声明「先有接口才能实现它」

如果这本书你只精读一章,选这章。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 接口设计的核心美学:

GO PROVERB

「接口越大,抽象越弱。」

The bigger the interface, the weaker the abstraction.  — Rob Pike

想想看:一个接口要求的方法越多,能满足它的类型就越少、越难替换。而一个只要求一个方法的接口(io.Writerio.Readerfmt.Stringersort.Interface 也才三个),几乎什么类型都能轻松满足,组合起来极其灵活。Go 标准库里最有力的抽象,全是这种一两个方法的小接口

这也和结构化类型天然契合:接口越小,一个类型「碰巧满足它」的概率就越大,隐式满足的威力就越强。所以 Go 的接口风格是「许多小接口,按需组合」,而不是 Java 那种「一个大接口列一堆方法」。你想要的能力用大接口表达不了,就把它拆成几个小接口 —— 第 12 章会讲接口怎么组合。

⚠ 隐式满足的代价:可能「不小心」满足或不满足

凡事有代价。隐式满足意味着:你没法从一个类型的定义处,一眼看出它实现了哪些接口(那一栏是空的,得去搜谁在用它)。也意味着你可能不小心满足一个接口(方法名撞了),或者改个方法签名不小心就不满足了、而编译错误出现在很远的调用处。

Go 社区有个惯用法应对:如果你想确保某类型满足某接口、并让编译器帮你盯着,加一行 var _ Greeter = Dog{} —— 这行什么也不做,但如果 Dog 哪天不满足 Greeter 了,它会当场编译报错。这是把「隐式」在你需要的地方局部变「显式」的小技巧。

Kotlin 对照

⇄ 名义式 vs 结构化:一次彻底的换脑

Kotlin / Java 是名义式(nominal):类型的身份由「它声明自己是什么」决定。class Dog : Animal 里那个 : Animal 是身份证。好处是显式、可追溯 —— 打开 Dog 就知道它是 Animal,IDE 能直接跳。接口是提前设计、由实现方签署的契约。

Go 是结构化(structural):类型的身份由「它长什么样(有哪些方法)」决定。没有身份证,编译器现场比对。好处是解耦、灵活、能事后为已有类型定义接口;代价是从类型本身看不出它满足了谁。

迁移时最大的心态转变有两个。一,停止「先设计接口层次再实现」的冲动。别一上来画一堆接口,而是等某个函数真的需要某种行为时,就地定义一个小接口。二,接口写在用它的地方(消费者包),别写在实现它的地方(生产者包)。带着 Java 的「接口驱动设计」惯性写 Go,你会造出一堆过度抽象、没人满足的大接口 —— 那正是 Go 社区嘲笑的 FooManagerFactoryInterface 味道。Go 的接口是从具体需求里「浮现」出来的,不是提前规划出来的。

这一章的一句话

Go 的接口是结构化、隐式的:一个类型满不满足接口,看它有没有那几个方法,而不看它声明了什么 ——「我实现了谁」那一栏永远留白;这解锁了「为别人的类型定义接口」「接口属于消费者而非生产者」两大能力,配合「接口越小越强」的美学,构成了 Go 最有力也最不像 Kotlin 的抽象方式。

刚才 Demo 里我们跳过了第 2 个例子 —— 那个用指针接收者Counter。下一章我们直面它:为什么同样的方法,值类型不满足接口、指针类型却满足?这是全书最容易让人栽跟头的坑,我们把它彻底挖开。