Go 语言 interface 入门:从隐式实现到实战设计

刚学 Go 的时候,interface 很容易让人困惑。

在 Java、C# 这类语言里,一个类型通常要显式写:

复制代码
implements SomeInterface

但 Go 不是这样。

Go 里没有 implements 关键字。一个类型只要拥有接口要求的方法,就自动实现了这个接口。

这听起来很轻,但也正是 Go 接口最重要的地方:

复制代码
接口不是某个类型的父类。
接口描述的是一组行为。

本文会从新手视角详细介绍 Go 的接口,包括:

  • interface 是什么

  • 如何定义接口

  • 什么叫隐式实现

  • 接口值内部到底装了什么

  • 方法集和指针接收者的关系

  • 空接口 interface{}any

  • 类型断言和类型选择

  • 标准库里的常见接口

  • nil 接口的坑

  • 实战中应该怎么设计接口

读完以后,你应该能看懂标准库里的 io.Readerfmt.Stringererror,也能在自己的代码里写出更清楚的接口。

一句话理解接口

先记住这句话:

复制代码
Go 的接口是一组方法签名的集合。

例如:

复制代码
type Speaker interface {
	Speak() string
}

这个接口的意思是:

复制代码
只要某个类型有 Speak() string 方法,它就是 Speaker。

接口关心的是"你能做什么",不关心"你是谁"。

第一个接口例子

先看一段完整代码:

复制代码
package main

import "fmt"

// Speaker 是一个接口。
// 它要求实现者必须有 Speak() string 方法。
type Speaker interface {
	Speak() string
}

// Dog 是一个普通结构体。
type Dog struct {
	Name string
}

// Dog 拥有 Speak() string 方法。
// 因此 Dog 自动实现了 Speaker 接口。
func (d Dog) Speak() string {
	return d.Name + " says: woof"
}

// Introduce 接收一个 Speaker。
// 它不关心传进来的是 Dog、Cat 还是其他类型。
// 它只关心这个值能不能 Speak。
func Introduce(s Speaker) {
	fmt.Println(s.Speak())
}

func main() {
	dog := Dog{Name: "Lucky"}
	Introduce(dog)
}

输出:

复制代码
Lucky says: woof

这里最关键的一点是:

复制代码
func (d Dog) Speak() string

因为 Dog 有了 Speak() string 方法,所以它自动满足 Speaker 接口。

你不需要写:

复制代码
// Go 没有这种写法
type Dog implements Speaker

这就是 Go 的隐式实现。

什么是隐式实现

隐式实现的意思是:

复制代码
类型不需要声明自己实现了哪个接口。
只要方法对得上,编译器就认为它实现了接口。

再加两个类型:

复制代码
package main

import "fmt"

type Speaker interface {
	Speak() string
}

type Dog struct {
	Name string
}

func (d Dog) Speak() string {
	return d.Name + " says: woof"
}

type Cat struct {
	Name string
}

func (c Cat) Speak() string {
	return c.Name + " says: meow"
}

type Robot struct {
	ID int
}

func (r Robot) Speak() string {
	return fmt.Sprintf("robot %d says: beep", r.ID)
}

func Introduce(s Speaker) {
	fmt.Println(s.Speak())
}

func main() {
	Introduce(Dog{Name: "Lucky"})
	Introduce(Cat{Name: "Mimi"})
	Introduce(Robot{ID: 1001})
}

输出:

复制代码
Lucky says: woof
Mimi says: meow
robot 1001 says: beep

DogCatRobot 是完全不同的类型,但它们都有 Speak() string 方法,所以都可以当作 Speaker 使用。

这就是接口带来的好处:

复制代码
调用方可以依赖行为,而不是依赖具体类型。

接口变量里装的是什么

接口变量看起来像一个普通变量:

复制代码
var s Speaker

但它内部可以理解成两部分:

复制代码
动态类型
动态值

例如:

复制代码
package main

import "fmt"

type Speaker interface {
	Speak() string
}

type Dog struct {
	Name string
}

func (d Dog) Speak() string {
	return d.Name + " says: woof"
}

func main() {
	var s Speaker

	s = Dog{Name: "Lucky"}

	fmt.Printf("type=%T\n", s)
	fmt.Printf("value=%v\n", s)
	fmt.Println(s.Speak())
}

输出:

复制代码
type=main.Dog
value={Lucky}
Lucky says: woof

此时:

复制代码
接口变量 s 的动态类型是 Dog
接口变量 s 的动态值是 Dog{Name: "Lucky"}

接口变量本身的静态类型是 Speaker,但运行时里面装的是 Dog

接口让函数更灵活

假设你一开始写了一个函数,只能处理 Dog

复制代码
func IntroduceDog(d Dog) {
	fmt.Println(d.Speak())
}

这个函数的问题是:它和 Dog 绑定死了。

如果后来你有 CatRobot,就要继续写:

复制代码
func IntroduceCat(c Cat) {
	fmt.Println(c.Speak())
}

func IntroduceRobot(r Robot) {
	fmt.Println(r.Speak())
}

这明显重复。

接口可以把函数改成:

复制代码
func Introduce(s Speaker) {
	fmt.Println(s.Speak())
}

现在任何会 Speak 的类型都能传进来。

这就是接口的核心价值:

复制代码
用更小的行为抽象,替代更死的具体类型依赖。

方法签名必须完全匹配

接口要求的是方法签名。

方法签名包括:

  • 方法名

  • 参数列表

  • 返回值列表

例如:

复制代码
type Speaker interface {
	Speak() string
}

下面这个类型没有实现 Speaker

复制代码
type Person struct {
	Name string
}

// 返回值不匹配:这里没有返回 string。
func (p Person) Speak() {
	fmt.Println("hello")
}

虽然方法也叫 Speak,但签名是:

复制代码
Speak()

接口要求的是:

复制代码
Speak() string

所以不匹配。

下面这个也不匹配:

复制代码
// 参数不匹配:接口要求没有参数。
func (p Person) Speak(lang string) string {
	return "hello"
}

Go 的接口匹配是静态检查。方法签名不完全一致,编译器不会放过。

接口可以包含多个方法

接口不只能有一个方法。

例如:

复制代码
type Animal interface {
	Speak() string
	Move() string
}

要实现这个接口,类型必须同时拥有这两个方法:

复制代码
package main

import "fmt"

type Animal interface {
	Speak() string
	Move() string
}

type Bird struct {
	Name string
}

func (b Bird) Speak() string {
	return b.Name + " says: chirp"
}

func (b Bird) Move() string {
	return b.Name + " flies"
}

func Describe(a Animal) {
	fmt.Println(a.Speak())
	fmt.Println(a.Move())
}

func main() {
	Describe(Bird{Name: "Rio"})
}

输出:

复制代码
Rio says: chirp
Rio flies

如果 Bird 少了 Move(),就不能作为 Animal 使用。

小接口更常见

虽然接口可以包含多个方法,但 Go 里更推荐小接口。

标准库里有很多非常小的接口:

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

type Writer interface {
	Write(p []byte) (n int, err error)
}

type Stringer interface {
	String() string
}

这些接口都很小,但非常有用。

小接口的好处是:

  • 更容易实现

  • 更容易测试

  • 更容易复用

  • 更不容易过度设计

如果一个接口里有十几个方法,新手应该先警惕:是不是抽象得太早了?

方法集:值接收者和指针接收者

接口最容易卡人的地方之一,是方法集。

先看值接收者:

复制代码
package main

import "fmt"

type Speaker interface {
	Speak() string
}

type Dog struct {
	Name string
}

// 值接收者。
func (d Dog) Speak() string {
	return d.Name + " says: woof"
}

func main() {
	var s1 Speaker = Dog{Name: "Lucky"}
	var s2 Speaker = &Dog{Name: "Mimi"}

	fmt.Println(s1.Speak())
	fmt.Println(s2.Speak())
}

这段代码能编译。

因为 Dog 的方法是值接收者:

复制代码
func (d Dog) Speak() string

所以:

复制代码
Dog 实现了 Speaker
*Dog 也实现了 Speaker

再看指针接收者:

复制代码
package main

import "fmt"

type Incrementer interface {
	Inc()
}

type Counter struct {
	N int
}

// 指针接收者。
// 这个方法需要修改 Counter,所以用 *Counter。
func (c *Counter) Inc() {
	c.N++
}

func main() {
	counter := Counter{}

	var inc Incrementer = &counter
	inc.Inc()

	fmt.Println(counter.N)
}

这段代码能编译。

但下面这样不行:

复制代码
// 编译错误:Counter 没有实现 Incrementer。
// var inc Incrementer = counter

原因是:

复制代码
如果方法接收者是 *Counter,那么实现接口的是 *Counter,不是 Counter。

新手可以先记住这个规则:

复制代码
值接收者:T 和 *T 通常都能满足接口。
指针接收者:通常只有 *T 能满足接口。

为什么 c.Inc() 可以,但接口赋值不行

你可能会问:

复制代码
counter := Counter{}
counter.Inc()

这不是能调用吗?

是的。因为 counter 是一个可寻址变量,Go 可以帮你自动取地址,等价于:

复制代码
(&counter).Inc()

但接口赋值更严格:

复制代码
var inc Incrementer = counter

这里编译器检查的是 Counter 的方法集是否包含 Inc()。由于 Inc() 属于 *Counter 的方法集,不属于 Counter 的方法集,所以赋值失败。

这不是 Go 故意刁难,而是为了让接口实现规则保持清楚。

编译期检查接口实现

有时你希望明确告诉读者:

复制代码
这个类型必须实现某个接口。

可以写编译期检查:

复制代码
package main

type Speaker interface {
	Speak() string
}

type Dog struct{}

func (d Dog) Speak() string {
	return "woof"
}

// 这行不会生成实际运行逻辑。
// 它只是在编译期检查 Dog 是否实现了 Speaker。
var _ Speaker = Dog{}

func main() {}

如果 Dog 没有实现 Speaker,这行就会编译失败。

如果方法是指针接收者,常见写法是:

复制代码
var _ SomeInterface = (*SomeType)(nil)

例如:

复制代码
type Closer interface {
	Close() error
}

type File struct{}

func (f *File) Close() error {
	return nil
}

var _ Closer = (*File)(nil)

这是一种很常见的 Go 代码习惯。

空接口 interface{} 和 any

空接口写作:

复制代码
interface{}

它没有任何方法要求。

因为没有要求,所以所有类型都满足它。

例如:

复制代码
package main

import "fmt"

func PrintAny(v interface{}) {
	fmt.Printf("type=%T value=%v\n", v, v)
}

func main() {
	PrintAny(123)
	PrintAny("hello")
	PrintAny(true)
	PrintAny([]int{1, 2, 3})
}

输出:

复制代码
type=int value=123
type=string value=hello
type=bool value=true
type=[]int value=[1 2 3]

Go 1.18 引入了 any,它是 interface{} 的别名。

所以下面两个写法等价:

复制代码
func PrintAny(v interface{}) {}

func PrintAny(v any) {}

现在更推荐使用 any 表达"任意类型"。

但要注意:

复制代码
any 不是没有类型。
any 表示这里可以接收任意类型的值。

如果你想对里面的具体类型做不同处理,仍然需要类型断言或类型选择。

类型断言:从接口里取出具体类型

接口变量里装着动态类型和动态值。

如果你想把接口值还原成某个具体类型,可以用类型断言。

复制代码
package main

import "fmt"

func main() {
	var v any = "hello"

	s, ok := v.(string)
	if !ok {
		fmt.Println("v is not a string")
		return
	}

	fmt.Println("string value:", s)
}

输出:

复制代码
string value: hello

类型断言的常见写法是:

复制代码
value, ok := x.(SomeType)

如果 x 里面确实装着 SomeTypeoktrue

如果不是,okfalse

不推荐新手直接省略 ok

你也可以这样写:

复制代码
s := v.(string)

但如果 v 里面不是 string,程序会 panic。

所以新手更推荐写:

复制代码
s, ok := v.(string)

这更安全。

类型选择 type switch

如果你要判断多种类型,可以用 type switch。

复制代码
package main

import "fmt"

func Describe(v any) {
	switch value := v.(type) {
	case int:
		fmt.Println("int:", value)
	case string:
		fmt.Println("string:", value)
	case bool:
		fmt.Println("bool:", value)
	default:
		fmt.Printf("unknown type %T: %v\n", value, value)
	}
}

func main() {
	Describe(100)
	Describe("go")
	Describe(true)
	Describe(3.14)
}

输出:

复制代码
int: 100
string: go
bool: true
unknown type float64: 3.14

type switch 很适合处理 any、解析数据、做通用日志等场景。

但不要滥用。

如果你发现自己到处都在 type switch,可能说明接口设计不够好。很多时候应该让类型自己实现方法,而不是在外面不断判断类型。

标准库里的接口:error

Go 里最常见的接口之一是 error

它的定义可以理解为:

复制代码
type error interface {
	Error() string
}

只要一个类型有 Error() string 方法,它就可以当作错误返回。

示例:

复制代码
package main

import (
	"fmt"
)

type ConfigError struct {
	Field string
}

func (e ConfigError) Error() string {
	return "missing config field: " + e.Field
}

func LoadConfig() error {
	return ConfigError{Field: "database_url"}
}

func main() {
	err := LoadConfig()
	if err != nil {
		fmt.Println(err)
	}
}

输出:

复制代码
missing config field: database_url

error 之所以强大,是因为函数可以只声明返回 error

复制代码
func LoadConfig() error

调用方不需要知道具体错误类型,先按错误处理即可。

如果调用方确实关心具体错误类型,再用类型断言或 errors.As

标准库里的接口:fmt.Stringer

fmt.Stringer 是另一个常见接口。

它的定义是:

复制代码
type Stringer interface {
	String() string
}

如果一个类型实现了 String() stringfmt 打印它时会使用这个方法。

复制代码
package main

import "fmt"

type User struct {
	ID   int
	Name string
}

func (u User) String() string {
	return fmt.Sprintf("User<ID=%d, Name=%s>", u.ID, u.Name)
}

func main() {
	user := User{ID: 1, Name: "Alice"}
	fmt.Println(user)
}

输出:

复制代码
User<ID=1, Name=Alice>

这就是接口的漂亮之处:

复制代码
fmt.Println 不需要专门认识 User。
它只要发现 User 实现了 String() string,就知道怎么打印。

标准库里的接口:io.Reader

io.Reader 是 Go 里非常经典的接口。

它的定义是:

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

意思是:

复制代码
任何能把数据读进 []byte 的类型,都可以叫 Reader。

比如文件、网络连接、字符串读取器、压缩流都可以实现 io.Reader

来看一个字符串读取例子:

复制代码
package main

import (
	"fmt"
	"io"
	"strings"
)

func main() {
	reader := strings.NewReader("hello go")
	buffer := make([]byte, 4)

	for {
		n, err := reader.Read(buffer)
		if n > 0 {
			fmt.Printf("read %d bytes: %q\n", n, buffer[:n])
		}

		if err == io.EOF {
			break
		}

		if err != nil {
			fmt.Println("read error:", err)
			break
		}
	}
}

可能输出:

复制代码
read 4 bytes: "hell"
read 4 bytes: "o go"

为什么 io.Reader 这么重要?

因为很多函数只需要"能读数据的东西",并不关心数据来自哪里。

例如你可以写:

复制代码
func CountBytes(r io.Reader) (int, error) {
	total := 0
	buffer := make([]byte, 1024)

	for {
		n, err := r.Read(buffer)
		total += n

		if err == io.EOF {
			return total, nil
		}

		if err != nil {
			return total, err
		}
	}
}

这个函数可以统计任何 io.Reader 的字节数。

它可以接收:

  • 文件

  • 字符串

  • 网络连接

  • bytes.Buffer

  • 压缩解码器

  • HTTP 响应体

这就是接口带来的扩展性。

标准库里的接口:io.Writer

io.Writer 的定义是:

复制代码
type Writer interface {
	Write(p []byte) (n int, err error)
}

意思是:

复制代码
任何能写入 []byte 的类型,都可以叫 Writer。

示例:

复制代码
package main

import (
	"bytes"
	"fmt"
)

func main() {
	var buffer bytes.Buffer

	fmt.Fprintln(&buffer, "hello")
	fmt.Fprintln(&buffer, "go")

	fmt.Print(buffer.String())
}

输出:

复制代码
hello
go

fmt.Fprintln 接收的是 io.Writer,所以它既能写到文件,也能写到 bytes.Buffer,还能写到网络连接。

函数只依赖一个很小的接口,就能适配很多具体类型。

接口组合

接口可以组合其他接口。

例如标准库里常见的组合思想:

复制代码
type ReadWriter interface {
	Reader
	Writer
}

你也可以写自己的组合接口:

复制代码
package main

import "fmt"

type Reader interface {
	Read() string
}

type Writer interface {
	Write(value string)
}

type ReadWriter interface {
	Reader
	Writer
}

type Memory struct {
	data string
}

func (m *Memory) Read() string {
	return m.data
}

func (m *Memory) Write(value string) {
	m.data = value
}

func SaveAndPrint(rw ReadWriter, value string) {
	rw.Write(value)
	fmt.Println(rw.Read())
}

func main() {
	mem := &Memory{}
	SaveAndPrint(mem, "hello interface")
}

输出:

复制代码
hello interface

组合接口适合表达"同时需要多种能力"的场景。

但仍然要注意:不要为了抽象而抽象。只有确实需要组合能力时再组合。

nil 接口的坑

接口里的 nil 是 Go 新手常踩的坑。

先看普通 nil 接口:

复制代码
package main

import "fmt"

type Speaker interface {
	Speak() string
}

func main() {
	var s Speaker
	fmt.Println(s == nil)
}

输出:

复制代码
true

因为此时接口变量里没有动态类型,也没有动态值。

再看一个容易误解的例子:

复制代码
package main

import "fmt"

type Notifier interface {
	Notify()
}

type Email struct {
	Address string
}

func (e *Email) Notify() {
	if e == nil {
		fmt.Println("empty email")
		return
	}
	fmt.Println("send email to", e.Address)
}

func main() {
	var email *Email = nil

	var n Notifier = email

	fmt.Println(n == nil)
	n.Notify()
}

输出:

复制代码
false
empty email

为什么 n == nilfalse

因为此时接口变量 n 里有动态类型:

复制代码
*Email

只是它的动态值是 nil。

可以理解成:

复制代码
n = (动态类型: *Email, 动态值: nil)

接口本身不是空的,所以 n != nil

这就是接口 nil 的核心规则:

复制代码
只有动态类型和动态值都为空时,接口才等于 nil。

实际项目里,如果函数返回 error 或其他接口,要小心不要返回"装着 nil 指针的接口"。

接口和测试

接口很适合让代码更容易测试。

假设你有一个用户服务,它需要从存储层读取用户名。

你可以先定义服务真正需要的行为:

复制代码
package main

import (
	"fmt"
)

type UserStore interface {
	FindName(id int) (string, error)
}

type UserService struct {
	store UserStore
}

func NewUserService(store UserStore) *UserService {
	return &UserService{store: store}
}

func (s *UserService) Greeting(id int) (string, error) {
	name, err := s.store.FindName(id)
	if err != nil {
		return "", err
	}
	return "hello, " + name, nil
}

type MemoryUserStore struct {
	users map[int]string
}

func (m MemoryUserStore) FindName(id int) (string, error) {
	name, ok := m.users[id]
	if !ok {
		return "", fmt.Errorf("user %d not found", id)
	}
	return name, nil
}

func main() {
	store := MemoryUserStore{
		users: map[int]string{
			1: "Alice",
		},
	}

	service := NewUserService(store)

	msg, err := service.Greeting(1)
	if err != nil {
		fmt.Println("error:", err)
		return
	}

	fmt.Println(msg)
}

输出:

复制代码
hello, Alice

UserService 不关心数据来自内存、数据库还是远程 API。

它只依赖一个小接口:

复制代码
type UserStore interface {
	FindName(id int) (string, error)
}

测试时,你可以写一个假的实现:

复制代码
type FakeUserStore struct{}

func (FakeUserStore) FindName(id int) (string, error) {
	return "TestUser", nil
}

这样测试 UserService 时就不需要真的连数据库。

接口应该定义在哪里

这是 Go 接口设计里很重要的一点。

在很多语言里,接口常常由实现方定义。

例如一个数据库包可能会先定义:

复制代码
type Database interface {
	FindUser(id int) (User, error)
	SaveUser(user User) error
	DeleteUser(id int) error
	ListUsers() ([]User, error)
	BeginTransaction() error
	Commit() error
	Rollback() error
}

然后业务层被迫依赖这个大接口。

Go 更常见的做法是:

复制代码
由使用方定义它需要的最小接口。

如果业务层只需要查用户名,那就定义:

复制代码
type UserNameFinder interface {
	FindName(id int) (string, error)
}

这样实现方只要提供这一个方法,就能被业务层使用。

这也是 Go 里常说的:

复制代码
Accept interfaces, return concrete types.

可以理解成:

复制代码
函数参数可以接收接口,让调用方更灵活。
函数返回值优先返回具体类型,让调用方拿到更明确的能力。

当然这不是绝对规则,但对新手很有帮助。

不要过早设计接口

新手很容易一上来就写接口:

复制代码
type UserService interface {
	CreateUser(name string) error
	DeleteUser(id int) error
	UpdateUser(id int, name string) error
	FindUser(id int) (User, error)
	ListUsers() ([]User, error)
}

然后再写一个实现:

复制代码
type userService struct{}

如果项目里只有一个实现,而且暂时没有测试替身、没有扩展需求,这种接口可能只是增加复杂度。

更稳的做法是:

  1. 先写具体类型。

  2. 当调用方真的需要替换实现时,再抽出接口。

  3. 接口只包含调用方真正需要的方法。

接口不是越多越好。

好的接口应该让代码更简单,而不是让代码更绕。

接口和泛型的区别

Go 1.18 之后,接口还有一个新用途:作为泛型约束。

例如:

复制代码
package main

import "fmt"

type Number interface {
	~int | ~int64 | ~float64
}

func Add[T Number](a, b T) T {
	return a + b
}

func main() {
	fmt.Println(Add(1, 2))
	fmt.Println(Add(1.5, 2.5))
}

这里的 Number 也是接口,但它不是普通的"方法集合接口",而是类型约束。

它表示:

复制代码
T 的底层类型可以是 int、int64 或 float64。

新手可以先这样区分:

复制代码
普通接口:用于运行时多态,描述对象能做什么。
泛型约束接口:用于编译期约束,描述类型参数允许是什么。

如果你刚开始学 Go,不需要急着深入泛型接口。先把普通接口、方法集、类型断言、nil 陷阱掌握好,更重要。

常见错误总结

错误一:把接口当继承

Go 接口不是父类。

不要把接口理解成:

复制代码
Dog 继承 Speaker

更准确的理解是:

复制代码
Dog 拥有 Speak() string 方法,所以 Dog 满足 Speaker。

错误二:接口太大

接口越大,实现它越困难。

如果一个函数只需要读取数据,就接收 io.Reader,不要接收一个拥有十几个方法的大对象。

错误三:返回不必要的接口

如果构造函数只有一个明确实现,通常返回具体类型:

复制代码
func NewMemoryUserStore() *MemoryUserStore {
	return &MemoryUserStore{}
}

不要为了"抽象"而写:

复制代码
func NewMemoryUserStore() UserStore {
	return &MemoryUserStore{}
}

返回具体类型更灵活。调用方如果只需要接口,可以自己把它赋给接口变量。

错误四:到处使用 any

any 很方便,但它会丢失具体类型信息。

如果你的函数明明只接收字符串,就写:

复制代码
func PrintName(name string)

不要写:

复制代码
func PrintName(name any)

除非你真的要处理任意类型。

错误五:忽略 nil 接口

接口值是否为 nil,取决于动态类型和动态值是否都为空。

看到这种代码要小心:

复制代码
var p *MyError = nil
var err error = p
fmt.Println(err == nil) // false

如果你想返回没有错误,应该直接返回 nil:

复制代码
return nil

不要返回一个 nil 指针包装成的 error

一个完整小练习:支付接口

最后用一个小练习把接口串起来。

需求:

复制代码
订单系统不关心具体支付方式。
它只关心支付对象能不能 Pay。

代码:

复制代码
package main

import "fmt"

type Payer interface {
	Pay(amount int) error
}

type OrderService struct {
	payer Payer
}

func NewOrderService(payer Payer) *OrderService {
	return &OrderService{payer: payer}
}

func (s *OrderService) Checkout(orderID string, amount int) error {
	fmt.Printf("checkout order %s\n", orderID)

	if err := s.payer.Pay(amount); err != nil {
		return err
	}

	fmt.Println("checkout success")
	return nil
}

type WeChatPay struct{}

func (WeChatPay) Pay(amount int) error {
	fmt.Printf("wechat pay: %d cents\n", amount)
	return nil
}

type AliPay struct{}

func (AliPay) Pay(amount int) error {
	fmt.Printf("alipay: %d cents\n", amount)
	return nil
}

func main() {
	wechatOrder := NewOrderService(WeChatPay{})
	_ = wechatOrder.Checkout("order-001", 9900)

	fmt.Println()

	aliOrder := NewOrderService(AliPay{})
	_ = aliOrder.Checkout("order-002", 12800)
}

输出:

复制代码
checkout order order-001
wechat pay: 9900 cents
checkout success

checkout order order-002
alipay: 12800 cents
checkout success

这里的关键是:

复制代码
type Payer interface {
	Pay(amount int) error
}

OrderService 不依赖 WeChatPay,也不依赖 AliPay

它只依赖 Payer 这个行为。

以后你要加银行卡支付:

复制代码
type BankCardPay struct{}

func (BankCardPay) Pay(amount int) error {
	fmt.Printf("bank card pay: %d cents\n", amount)
	return nil
}

OrderService 不需要改。

这就是接口带来的扩展性。

学习路线建议

如果你是新手,可以按这个顺序练习接口:

  1. 定义只有一个方法的小接口。

  2. 写两个结构体,让它们都实现这个接口。

  3. 写一个函数接收接口参数。

  4. 练习值接收者和指针接收者的区别。

  5. fmt.Stringer 给结构体自定义打印格式。

  6. io.Reader 写一个能接收多种输入源的函数。

  7. 用类型断言和 type switch 处理 any

  8. 故意写一次 nil 接口例子,理解为什么 err != nil

  9. 在测试里用小接口替代真实依赖。

这几步练熟以后,接口就不会再显得抽象。

总结

Go 接口的核心可以压缩成几句话:

  • 接口是一组方法签名。

  • 类型只要拥有接口要求的方法,就自动实现接口。

  • Go 没有 implements 关键字。

  • 接口变量内部包含动态类型和动态值。

  • 值接收者和指针接收者会影响类型是否满足接口。

  • interface{}any 表示任意类型。

  • 类型断言可以从接口中取回具体类型。

  • 小接口比大接口更常见,也更容易维护。

  • 接口应该在使用方按需求定义。

  • 不要为了抽象而抽象。

最后记住一句:

复制代码
Go 的接口不是为了制造层级,而是为了描述行为。

当你能用"这个函数真正需要什么行为"来思考接口时,你就开始真正掌握 Go 的接口了。

参考资料