Golang 接口

7.6 接口快速入门

虽然 Go 的并发模型(详见“十二 Go 并发编程<251页>”)获得了所有的关注,但 Go 设计的真正明星是其隐式接口,这是 Go 中唯一的抽象类型。让我们看看是什么让它如此出色。

首先,看一下如何声明接口。接口很简单,本质上接口与其他用户定义类型一样,都使用 type 关键字来声明

如下是 fmt 包中 Stringer 接口的定义:

type Stringer interface {
	String() string
}

在一个接口声明中,接口字面量位于接口名称之后。它列出了必须由具体类型(Concrete Type)实现的方法,以满足该接口。接口定义中的这些方法是接口的方法集。正如“7.2.1 指针接收者(Pointer Receiver)与值接收者(Value Receiver)<129页>”一节所述,一个指针实例的方法集包含了使用指针接收者和值接收者定义的方法,而一个值实例的方法集只包含使用值接收者定义的方法。以下是一个使用之前定义的 Counter 结构体的快速示例:

type Counter struct {
	count int
}

// String 使用值接收者实现 fmt.Stringer 接口
// 值接收者方法的方法集会同时归属于 T 和 *T
func (c Counter) String() string {
	return fmt.Sprintf("count: %d", c.count)
}

// Increment 使用指针接收者
// 指针接收者方法的方法集只归属于 *T,不归属于 T
func (c *Counter) Increment() {
	c.count++
}

type Incrementer interface {
	Increment()
}

var myStringer fmt.Stringer
var myIncrementer Incrementer

pointerCounter := &Counter{} // *Counter 类型
valueCounter := Counter{}    // Counter 类型(值类型)

// --- 赋值给 Stringer 接口 ---

// *Counter 的方法集包含 String()(值接收者方法会被指针"继承")
// 因此 *Counter 满足 Stringer 接口
myStringer = pointerCounter // ok

// Counter(值类型)的方法集本身就包含 String(),因为 String 是值接收者
// 因此 Counter 满足 Stringer 接口
myStringer = valueCounter // ok

// --- 赋值给 Incrementer 接口 ---

// *Counter 的方法集包含 Increment()(指针接收者方法只属于指针类型)
// 因此 *Counter 满足 Incrementer 接口
myIncrementer = pointerCounter // ok

// Counter(值类型)的方法集不包含 Increment()
// 因为 Increment 是指针接收者方法,只属于 *Counter,不属于 Counter
// 因此 Counter 不满足 Incrementer 接口,编译报错
myIncrementer = valueCounter // compile-time error!

尝试编译此代码将导致编译错误:

cannot use valueCounter (variable of type Counter) as Incrementer
value in assignment: Counter does not implement Incrementer (method Increment has pointer receiver)

与其他类型一样,可以在任何代码块中声明接口。

接口名称通常以“er”结尾。例如,fmt.Stringerio.Readerio.Closerio.ReadCloserjson.Marshalerhttp.Handler 等。

7.7 接口是类型安全的鸭子类型(Duck Typing)

到目前为止,关于 Go 语言接口的描述与其他语言中的接口并没有太大区别。Go 语言接口的特殊之处在于它们是隐式实现的。正如之前的例子中看到的 Counter 结构体类型和 Incrementer 接口类型,具体类型(Concrete Type)无需声明它实现了某个接口。如果一个具体类型(Concrete Type)的方法集包含了接口方法集中的所有方法,则这个具体类型就实现了该接口。因此,这个具体类型(Concrete Type)可以赋值给一个声明为该接口类型的变量或字段

这种隐式行为使得接口成为 Go 语言类型中最有趣的部分,因为这种隐式行为既保证了类型安全又实现了解耦,同时融合了静态和动态语言的特性。

为了理解这一点,让我们讨论一下为什么编程语言会有接口。“7.4 类型嵌入<137页>”一节提到的《设计模式》这本书教会开发者们“优先使用组合而非继承”。这本书中的另一条建议是“针对接口编程,而不是针对实现编程”。这样做可以让你依赖于行为,而不是具体实现,从而在需要时可以更换实现。这允许代码随着时间的推移而演化迭代,因为需求不可避免地会发生变化。

像 Python、Ruby 和 JavaScript 这样的动态类型语言没有接口。相反,这些开发者使用鸭子类型(Duck Typing),这是基于这样的表达:“如果它走起来像鸭子,叫起来也像鸭子,那么它就是一只鸭子”。这个概念是,只要函数可以找到一个它期望调用的方法,就可以将一个类型的实例作为参数传递给该函数

class Logic:
    def process(self, data):
        # 业务逻辑


def program(logic):
    # 获取数据
    logic.process(data) # 调用 process 方法,处理数据


logicToUse = Logic()
program(logicToUse)

鸭子类型(Duck Typing)可能一开始听起来很奇怪,但它已经被用来构建大型且成功的系统。静态语言的开发者会觉得鸭子类型(Duck Typing)一团糟。没有明确指定类型,很难确切知道应该期望什么功能。当新开发者接手一个项目,或者现有开发者忘记代码的功能时,他们必须追踪代码以确定实际的依赖关系。

鸭子类型(Duck Typing)的问题:

  • 接口契约不明显:仅看一个函数签名,很难一眼看出它到底需要传入具备哪些具体行为的对象,必须深入阅读函数内部实现或依赖完备的文档。
  • 缺乏编译期检查(类型不安全):在动态语言(如 Python)中,如果对象少了一个方法,错误直到运行到该行代码时才会暴雷(抛出 AttributeError)。
  • IDE 提示和重构困难:因为没有明确的类型声明,编辑器很难准确推断出对象到底有哪些方法,代码重构(如批量修改方法名)时容易漏掉隐式实现的类。

Java 开发人员使用不同的编程模式。他们定义一个接口,创建接口的实现,但仅在客户端(调用方)代码中引用接口:

public interface Logic {
    String process(String data);
}

public class LogicImpl implements Logic {
    public String process(String data) {
        // business logic
    }
}

public class Client {
    private final Logic logic;
    // this type is the interface, not the implementation

    public Client(Logic logic) {
        this.logic = logic;
    }

    public void program() {
        // get data from somewhere
        this.logic.process(data);
    }
}

public static void main(String[] args) {
    Logic logic = new LogicImpl();
    Client client = new Client(logic);
    client.program();
}

动态语言的开发者们看待 Java 中显式的接口,并且不理解在存在**显式依赖(LogicImpl 显示声明实现了 Logic 接口)**的情况下,如何能够在时间的推移中重构代码。具体来说,如果需要从一个不同的供应商切换到一个新的实现,这意味着需要重写代码以依赖于一个新的接口。相比之下,动态语言的开发者们更倾向于使用鸭子类型(Duck Typing),这种方式不依赖于显式的接口或类定义,而是依赖于对象是否具有所需的方法和属性。这种方式更加灵活,但也可能导致代码的可读性和可维护性降低,因为缺乏明确的类型定义和接口规范²。

² 动态语言的开发者们认为显式接口会限制代码的灵活性,而静态类型语言的开发者们则认为显式接口有助于提高代码的可读性和可维护性。

Go 语言的开发者们认为双方都是正确的。如果应用程序需要随着时间的推移而演化迭代,则需要有灵活性来改变具体实现(需要 Ducking Typing 语义)。然而,为了让人们理解代码在做什么(同时不丢失代码的可读性和可维护性)(因为随着时间的推移会有新人接手现有代码),还需要指定代码所依赖的内容。这就是隐式接口的应用价值所在。Go 代码是前面两种风格的混合体:

type LogicProvider struct{}

func (lp LogicProvider) Process(data string) string {
	// 业务逻辑
}

type Logic interface {
	Process(data string) string
}

type Client struct {
	L Logic
}

func (c Client) Program() {
	// 获取数据
	c.L.Process(data)
}

func main() {
	c := Client{
		L: LogicProvider{},
	}
	c.Program()
}

Go 代码提供了一个接口,但只有调用者(Client)知道它;LogicProvider 上未声明任何内容来指示它满足接口。这足以在将来允许一个新的逻辑提供者,并提供可执行的文档,以确保传递到 Client 端的任何类型都符合 Client 端的需求。

接口(Interfaces)是用来定义调用者(callers)所需功能的一种方式。接口是一种约定,它规定了实现该接口的类或对象必须提供哪些方法或属性

具体来说,包含以下含义:

  • 接口指定调用者需要什么:接口定义了一组 API(应用程序编程接口),这组 API 描述了调用者(可以是另一个方法、函数或系统)需要的功能。调用者通过接口知道它可以调用哪些方法,以及这些方法的预期行为。
  • 客户端代码定义接口:通常,客户端代码(即使用接口的代码)会定义一个或多个接口。这样做是为了明确指定客户端需要的功能。例如,如果一个客户端需要一个能够执行计算和存储结果的逻辑提供者,它可能会定义一个包含 CalculateStore 方法的接口。
  • 指定所需功能:通过定义接口,客户端代码明确指出它需要哪些功能。这有助于确保任何实现该接口的类或对象都能满足客户端的需求。同时,这也为未来的扩展和维护提供了灵活性,因为只要新的类或对象实现了相同的接口,就可以替换旧的实现,而无需修改客户端代码。

举个例子,假设我们正在开发一个图形编辑软件,我们需要一个能够绘制形状的接口。客户端代码(比如一个绘图工具)可能会定义一个 Shape 接口,该接口包含一个 Draw 方法。任何实现了 Shape 接口的类(比如 CircleRectangle 等)都必须提供 Draw 方法的实现。这样,绘图工具就可以调用 Draw 方法来绘制任何形状,而不需要知道具体的形状类型。

接口是可以被共享和重用的。在 Go 语言的标准库中,已经定义了一些用于输入和输出的接口,例如 io.Readerio.Writer。这些标准接口非常强大,因为它们提供了一种通用的方式来处理不同的输入和输出操作。

具体来说,如果你编写代码时使用了 io.Readerio.Writer 接口,那么你的代码将能够正确地处理不同的底层实现。例如,无论是将数据写入本地磁盘上的文件,还是写入内存中的缓冲区,你的代码都不需要做任何修改,因为 io.Writer 接口抽象了具体的写入操作。

使用标准接口还鼓励了装饰器模式(decorator pattern)的使用。在 Go 语言中,经常编写一些工厂函数,这些函数接收一个接口的实例,并返回另一个实现了相同接口的类型。例如,假设你有一个如下定义的函数:

func process(r io.Reader) error

可以使用以下代码处理文件中的数据:

r, err := os.Open(fileName)
if err != nil {
	return err
}
defer r.Close()

return process(r)

os.Open 返回的 os.File 实例符合 io.Reader 接口,因此可以在任何读取数据的代码中使用。如果文件是 gzip 压缩的,可以将 io.Reader 包装在另一个 io.Reader 中:

r, err := os.Open(fileName)
if err != nil {
	return err
}
defer r.Close()

gz, err := gzip.NewReader(r)
if err != nil {
	return err
}
defer gz.Close()

return process(gz)

现在,用来读取未压缩文件的相同代码,也可以用来读取压缩文件。

在 Go 语言编程中,优先使用标准库中已经定义好的接口是一个很好的实践。常用的接口包括 io.Readerio.Writerio.Closer

一个类型在实现了某个接口的同时,还可以定义一些额外的、不属于该接口的方法。不同的客户端(调用者)代码可能会对这些额外的方法有不同的需求。例如,os.File 类型不仅实现了 io.Reader 接口,还实现了 io.Writer 接口。如果代码只需要从文件中读取数据,就可以只使用 io.Reader 接口来引用文件实例,而忽略其他方法。

7.8 类型嵌入与接口

类型嵌入不仅适用于结构体,还适用于接口。例如,io.ReadCloser 接口是 io.Readerio.Closer 两个接口的组合(即嵌入了这两个接口)。

type Reader interface {
	Read(p []byte) (n int, err error)
}

type Closer interface {
	Close() error
}

type ReadCloser interface {
	Reader
	Closer
}

就像可以在结构体中嵌入具体类型(Concrete Type)一样,也可以在结构体中嵌入一个接口。此用法的具体用途,详见“15.7 在 Go 中使用桩对象(Stub)<351页>”。

7.9 接受接口,返回结构体

有经验的 Go 开发者常会提到一个原则:“接受接口,返回结构体”。这句话最早很可能出自 Jack Lindamood 在 2016 年发表的博客《Go 语言中预防性接口反模式》³一文。其核心含义是:函数调用的业务逻辑应当通过接口来触发,但函数的输出结果应该是具体类型。前文已经解释过,函数接受接口能使代码更具灵活性,并明确声明所依赖的具体功能。

函数应当返回具体类型(Concrete Type)的主要原因在于:这种方式能更轻松地在代码新版本中逐步更新返回值。当函数返回具体类型时,新增字段或方法不会破坏现有调用代码——因为旧代码会直接忽略这些新增内容。但接口则完全不同:向接口添加新方法意味着所有现有实现都必须更新,否则代码就会崩溃。用语义化版本的概念来说,这相当于向后兼容的小版本更新与破坏性变更的大版本更新的区别。如果你提供的 API 需要被他人使用(无论是组织内部项目还是开源项目),避免破坏性变更才能让用户满意。

在某些罕见情况下,返回接口可能是“两害相权取其轻”的选择。例如标准库中的 database/sql/driver 包就定义了一系列接口来规范数据库驱动必须实现的功能。这些接口的所有方法几乎都返回接口类型,因为具体实现应该由数据库驱动作者提供。从 Go 1.8 开始,数据库驱动需要支持更多功能。但由于标准库必须保持向后兼容,既不能直接修改现有接口添加新方法,也不能更改现有方法的返回值类型。解决方案是:保持原有接口不变,定义新接口来描述新增功能,并告知驱动作者需要在其具体类型上同时实现新旧接口两类方法。

这就引出了一个关键问题:如何检测这些新增方法是否存在,以及存在时如何调用它们。答案详见“7.13 类型断言(Type Assertion)与类型判断(Type Switch)<150页>”一节。

与其编写一个统一的工厂函数(Factory Function)(根据输入参数返回隐藏在接口背后的不同实例),不如为每个具体类型(Concrete Type)编写独立的工厂函数。当然在某些场景下(例如解析器可能返回一种或多种类型的标记),这种情况无法避免,你只能选择返回接口。

错误处理是这个规则的一个例外。如“九 错误处理<181页>”所述,Go 的函数和方法可以声明返回 error 接口类型的参数。对于错误处理而言,很可能会返回该接口的不同实现,因此必须使用接口来涵盖所有可能的情况——毕竟接口是 Go 中唯一的抽象类型。

这种模式存在一个潜在缺点。“6.9 减少垃圾收集器的压力<119页>”所述,减少堆(heap)内存分配可以降低垃圾回收的工作量,从而提升性能。返回结构体能避免堆(heap)内存分配,这是其优势所在。然而,当调用接收接口类型参数的函数时,每个接口参数都会引发一次堆(heap)分配。在程序的生命周期中,需要在更好的抽象与更高的性能之间做好权衡。

编写代码时应优先保证可读性和可维护性。如果发现程序运行过慢,且通过性能分析确认问题源于接口参数引发的堆(heap)内存分配,这时才应该重写函数改用具体类型(Concrete Type)参数。若需向该函数传入接口的多种实现,就意味着要创建多个包含重复逻辑的函数版本。

有 C++ 或 Rust 开发背景的开发者可能会尝试使用泛型(Generics),希望编译器能生成特化函数。但截至 Go 1.21 版本,这种做法很可能不会生成更高效的代码,其原因详见“8.11 Go 惯用法与泛型<177页>”。

³ 原文标题:Preemptive Interface Anti-Pattern in Go

评论