接口与组合

上一篇我们学了刷算法题的常用技巧,包括 I/O 模板、排序、数学操作、优先队列。这些已经够你应付绝大部分算法题了。

这篇是 Go 教程系列的最后一篇,介绍一些进阶特性:结构体方法、接口、组合、类型断言。这些知识在阅读别人代码或者理解设计模式时很有用。如果你暂时只想刷算法题,可以先跳过,等需要的时候再回来看。

结构体方法

前面学结构体的时候,我们只是把数据打包在一起。但有时候你想给结构体绑定一些操作——比如一个 Student 结构体,你想让它能"自我介绍"。这就需要给结构体定义方法。

值接收者

Go 的方法就是一个带"接收者"的函数。接收者写在 func 关键字和函数名之间,用括号括起来,表示这个方法属于哪个类型:

func (s Student) Introduce() 这行代码的意思是:给 Student 类型定义一个叫 Introduce 的方法,方法体里可以通过 s 来访问结构体的字段。

调用的时候用 stu.Introduce(),就像访问字段一样用 . 号。接收者 s 就会自动绑定为 stu

这里的 (s Student)值接收者,意思是方法拿到的是结构体的一个副本。如果你在方法里修改 s 的字段,是不会影响到外面的原始结构体的。

指针接收者

如果你想在方法里修改结构体的字段,就需要用指针接收者,把接收者类型从 Student 改成 *Student

对比一下:TrySetAge 用的是值接收者 (s Student),方法里修改了 s.Age,但外面的 stu 完全没变。而 SetAge 用的是指针接收者 (s *Student),修改直接作用在原始结构体上。

你可能注意到了,调用指针接收者方法时我们写的是 stu.SetAge(25),而不是 (&stu).SetAge(25)。这是 Go 的语法糖——Go 会自动帮你取地址,不需要你手动写 &

什么时候用哪个

简单记住一条规则就行:

  • 需要修改结构体的字段 → 用指针接收者 (s *Student)
  • 只读不修改 → 用值接收者 (s Student)
  • 拿不准 → 用指针接收者(更安全,也能避免大结构体的复制开销)

实际中大多数方法都用指针接收者,因为你经常需要修改数据,而且指针接收者不会复制整个结构体,性能更好。

接口

定义与隐式实现

接口是 Go 语言里非常重要的概念。简单说,接口就是一组方法的集合——它规定了"你必须有哪些方法",但不关心你具体怎么实现。

先看怎么定义一个接口:

上述代码中,DogCat 都实现了 Speaker 接口。但请注意:DogCat 并不需要显式声明「我实现了 Speaker 接口」

不需要写 class Dog implements Speaker 这类代码,Go 的编译器会自动帮你检查,只要你的类型拥有接口要求的所有方法,它就自动实现了这个接口。这就是隐式实现,是 Go 的一大特点。

多态(接口类型的变量)

接口有啥用呢?

接口的真正威力在于:你可以用接口类型的变量来持有任何实现了这个接口的值。这样就能写出统一处理不同类型的代码:

这段代码已经展现了接口的两个方便之处:

  • []Speaker 是一个接口类型的切片,里面可以装 Dog、Cat、Duck 等任何实现了 Speaker 接口的值。

  • MakeNoise 函数接受一个 Speaker 参数,不管你传什么具体类型进来,只要它有 Speak() 方法就行。

这就是多态:同一个函数调用,传入不同类型的参数,会表现出不同的行为。Go 通过接口实现多态,非常简洁。

常见内置接口

Go 标准库里有很多预定义的接口,其中最常用的两个是 fmt.Stringererror

fmt.Stringer 接口只有一个 String() string 方法。如果你的类型实现了这个接口,用 fmt.Println 打印时会自动调用你的 String() 方法:

error 接口更简单,只有一个 Error() string 方法。Go 用它来表示错误:

Go 的错误处理不用异常机制(没有 try-catch),而是通过返回 error 值来表示。nil 表示没有错误,非 nil 表示有错误发生。这就是 Go 里面到处都能看到 if err != nil 的原因。

结构体嵌入(组合代替继承)

嵌入结构体

Go 没有类继承。那怎么复用代码呢?答案是组合——把一个结构体嵌入到另一个结构体里面。

关键点在于 Dog 结构体里只写了 Animal,没有给它起字段名。这种写法叫嵌入(embedding),效果是 Dog 直接拥有了 Animal 的所有字段和方法——dog.Name 就相当于 dog.Animal.Namedog.Eat() 就相当于 dog.Animal.Eat()

方法提升

嵌入之后,内层结构体的方法会被"提升"到外层,这意味着外层结构体自动获得了内层的所有方法。而且如果外层结构体也定义了同名方法,会覆盖内层的:

Dog 和 Cat 都嵌入了 Animal,自动获得了 Eat() 方法(提升)。但它们各自定义了自己的 Speak() 方法,覆盖了 Animal 的默认实现。

这就是 Go 的"组合代替继承"的思想——不需要复杂的继承层次,嵌入 + 方法覆盖就能达到类似的效果,而且代码更加简单直接。

类型断言

value.(Type) 语法

接口变量里装的是某个具体类型的值,有时候你需要把它"拆开来",拿到里面的具体类型。这个操作叫类型断言

s.(Dog) 就是类型断言,意思是"我认为 s 里装的是 Dog 类型,帮我取出来"。推荐使用两个返回值的安全写法 dog, ok := s.(Dog),如果断言成功 oktrue,失败就是 false,程序不会崩溃。

type switch

如果你需要判断接口变量可能是好几种类型中的哪一种,一个一个断言太麻烦了。Go 提供了 type switch,让你用 switch 语法优雅地处理多种类型:

switch v := s.(type) 是特殊语法,s.(type) 只能在 switch 语句中使用。每个 case 分支里的 v 会自动变成对应的具体类型,你可以直接访问该类型的字段和方法,不需要再做类型断言。

空接口 interface / any

Go 有一个特殊的接口叫空接口 interface{},它没有定义任何方法。因为不需要实现任何方法,所以任何类型都满足空接口——也就是说,空接口可以持有任意类型的值。

从 Go 1.18 开始,anyinterface{} 的别名,写起来更简洁。两者完全等价。

%Tfmt.Printf 的一个格式化占位符,用来打印值的类型,调试的时候很方便。

空接口很灵活,但不要滥用。因为用了空接口之后,你就失去了类型检查的保护,想使用具体类型的方法就必须做类型断言。能用具体类型或者明确的接口类型的地方,就不要用 any

下面这个例子展示了空接口配合 type switch 的典型用法:

这是处理"不确定类型"数据的标准套路:用 any 接收,然后用 type switch 分类处理。

panic 与 recover

error 用于预期内的错误,但有些情况属于严重异常,比如空指针解引用、数组越界、整数除零,这类错误会直接触发 panic,默认让整个程序崩溃。recover 能在 defer 里把它接住,避免进程直接挂掉。

触发 panic 的场景一般有两类。一类是运行时自动触发,比如空指针、越界、整数除零、类型断言失败(注意浮点除零不会 panic,会得到 InfNaN);另一类是代码主动调用 panic(...),多用于程序进入不该出现的状态。

panic 发生后,当前函数会停下,沿调用栈向上展开,一路执行每一层的 defer,直到被 recover 接住或者程序整体崩溃退出。

recover 必须在被 defer 的函数体里直接调用才有效。如果在 defer 里再嵌一层普通函数、由那个普通函数去调 recover,依然拿不到任何东西。

实战里用 recover 的场景相对固定:库函数的边界处防止崩溃扩散给调用方、HTTP 中间件兜底保证单个请求崩溃不拖垮整个服务、测试框架收集失败信息。

不要用 panic 替代 error。业务上能预期的错误,比如用户输入非法、文件找不到、网络超时,就老老实实返回 error,让调用方选择怎么处理。一个简单判断标准,程序员写错代码才 panic,外部输入错了只能返回 error

小结

这篇介绍了 Go 的进阶特性。结构体方法中,值接收者只读,指针接收者可以修改结构体,拿不准就用指针接收者。Go 的接口是隐式实现的,不需要显式声明,实现了多态。Go 用组合代替继承,通过嵌入结构体来复用代码。类型断言用于从接口变量中提取具体类型,推荐用 v, ok := s.(Type) 安全写法。空接口 any 可以持有任意类型的值,但要配合类型断言使用。


到这里,整个 Go 教程系列就结束了。我们来回顾一下学了什么:

  1. 基础语法:程序结构、输入输出、变量、运算符、控制流
  2. 函数与指针:函数定义、多返回值、指针传参、闭包
  3. 常用数据结构:数组、切片、字符串、map、结构体、栈、队列
  4. 算法题常用技巧:I/O 模板、排序、数学操作、优先队列
  5. 进阶特性:方法、接口、组合、类型断言(本篇)

这五篇文章覆盖了 Go 语言中刷算法题和阅读代码需要的核心知识。Go 的语法很简洁,真正的学习是在实践中完成的。建议你现在就去做几道题,遇到不会的语法再回来查。理论看十遍不如动手写一遍,加油!