Featured image of post Go 语言进阶:方法、接口与错误处理

Go 语言进阶:方法、接口与错误处理

Go 语言进阶语法——方法、接口、空接口与类型断言、错误处理、panic/recover、泛型,从基础到实战的第二步

承接 Go 语言基础:从变量到指针,这一篇覆盖 Go 的进阶核心概念:方法、接口、错误处理,以及泛型入门。

方法

// 普通函数:参数是 Student
func greet(s Student) string {
    return "Hello, " + s.Name
}

// 方法:绑在 Student 上
func (s Student) greet() string {
    return "Hello, " + s.Name
}

区别就在于函数名前面多了一个 (s Student),这叫接收者(receiver)

调用方式不同:

student := Student{"Jasper", 90}

// 函数调用
greet(student)

// 方法调用
student.greet()

返回一个字符串可以用 fmt.Sprintf

值接收者 vs 指针接收者

在接收者的类型前加 * 就变成指针接收者。值接收者只改副本,指针接收者改原件。

// 值接收者:改的是副本
func (s Student) setScore(score float64) {
    s.Grade = score  // 不影响原值
}

// 指针接收者:改的是原件
func (s *Student) setScore(score float64) {
    s.Grade = score  // 修改原值
}

接口

Go 中最重要的概念之一。

// 定义接口:任何有 speak() 方法的类型,都算 Speaker
type Speaker interface {
    speak() string
}

// Dog 有 speak() 方法,Dog 实现了 Speaker
type Dog struct {
    Name string
}

func (d Dog) speak() string {
    return "Woof"
}

// 同理 Cat 亦如此
type Cat struct {
    Name string
}

func (c Cat) speak() string {
    return "Meow"
}

// 通用函数:接受任何 Speaker
func makeItSpeak(s Speaker) {
    fmt.Println(s.speak())
}

func main() {
    dog := Dog{"Buddy"}
    cat := Cat{"Kitty"}
    makeItSpeak(dog)  // Woof!
    makeItSpeak(cat)  // Meow!
}

三个要点:

  • 接口定义type Speaker interface { speak() string } — 定义了一个"能力",不关心谁实现
  • 隐式实现:Dog 和 Cat 都有 speak() 方法,所以它们自动"算"是 Speaker,不需要显式声明 implements
  • 通用函数makeItSpeak(s Speaker) 接受任何 Speaker,不关心具体是 Dog 还是 Cat

嵌入接口

小接口可以组合成大接口:

// 两个独立接口
type Speaker interface { speak() string }
type Walker interface { walk() string }

// 嵌入组合
type SpeakerWalker interface {
    Speaker  // 把 Speaker 的方法拿过来
    Walker   // 把 Walker 的方法拿过来
}

效果:SpeakerWalker 自动拥有 speak()walk() 两个方法。

接口哲学:小接口 > 大接口

Go 的接口是隐式实现的:

// C++ / Java:必须显式声明"我实现了这个接口"
class Person implements Speaker { ... }

// Go:只要你有方法,就自动满足接口
type Person struct { Name string }
func (p Person) speak() string { return "hi" }
// Person 自动满足 Speaker,不需要声明

标准库里最常见的接口:

// io.Reader — 只有一个方法
type Reader interface {
    Read(p []byte) (n int, err error)
}

// io.Writer — 只有一个方法
type Writer interface {
    Write(p []byte) (n int, err error)
}

Go 标准库里很多接口只有 1~2 个方法,而不是把所有功能堆在一个大接口里。

为什么小接口好?

  1. 容易实现 — 一个方法的接口,任何类型都能轻松满足
  2. 容易组合 — 用嵌入把小接口组合成大接口
  3. 灵活 — 你只关心你需要的能力,不关心具体类型
特性GoJava/C++
实现方式隐式(自动满足)显式(需要声明)
接口大小通常 1-2 个方法通常很多方法
设计哲学小接口,按需组合大接口,一次定义

io.Reader 与 io.Writer

这是 Go 标准库里最常用的两个接口,用来处理"数据流"。

先想一个问题:从哪里读数据?

  • 从文件读
  • 从网络读
  • 从键盘读
  • 从字符串读

这些地方都能读数据,但它们的实现完全不同。接口解决这个问题:

// io.Reader:任何"能读出数据"的东西
type Reader interface {
    Read(p []byte) (n int, err error)
}

翻译成大白话:

“你只要有 Read 方法,能往 p 里填数据,你就是个 Reader”

参数/返回含义
p []byte一个空的容器(桶),你把读到的数据放进去
返回 n int实际读了多少字节
返回 err error读取过程中有没有出错

为什么这个接口牛?因为它只有一个方法,所以实现简单、使用广泛——标准库里几百个函数都接受 Reader。

// 这些类型都实现了 io.Reader
// - os.File          (文件)
// - strings.Reader   (字符串)
// - bytes.Buffer     (字节缓冲区)
// - net.Conn         (网络连接)

// 通用函数:不关心数据从哪来,只管读
func printAll(r io.Reader) {
    data := make([]byte, 100)
    n, _ := r.Read(data)
    fmt.Println(string(data[:n]))
}

// 从文件读
file, _ := os.Open("test.txt")
printAll(file)

// 从字符串读
str := strings.NewReader("hello")
printAll(str)

同一个函数,能处理不同类型的数据源——这就是接口的价值。

接口方法数作用
io.Reader1能读
io.Writer1能写
io.Closer1能关闭
io.ReadWriter2能读能写(嵌入组合)

Go 的设计哲学:把大能力拆成小接口,按需组合。

空接口 any 与类型断言

有时候需要处理"任意类型",这时候用空接口:

// any 是 interface{} 的别名
func printAnything(v any) {
    fmt.Println(v)
}

但是有个问题:拿到的是 any,不知道具体的类型怎么办?此时引出类型断言type switch

类型断言

func printAnything(v any) {
    if s, ok := v.(string); ok {
        fmt.Println("是字符串:", s)
    } else if n, ok := v.(int); ok {
        fmt.Println("是整数:", n)
    } else {
        fmt.Println("其他类型:", v)
    }
}

Type Switch

更简洁的写法:

func printAnything(v any) {
    switch val := v.(type) {
    case string:
        fmt.Println("是字符串:", val)
    case Student:
        fmt.Println("是学生:", val)
    }
}

v.(type) 是 Go 的特殊语法,只能在 switch 里用,意思是"告诉我 v 的实际类型是什么"。val 会自动变成对应分支的类型。

Comma-Ok 模式详解

这是 Go 里处理"可能失败的操作"的通用模式,类型断言只是其中一个用法。

s, ok := v.(string)
  • 尝试把 v 转成 string
  • 如果成功:s = 转换后的值,ok = true
  • 如果失败:s = 空字符串零值,ok = false

关键点:如果不写 ok,断言失败会直接 panic(程序崩溃)。所以一般都用 , ok 安全地处理:

// 不安全:失败会 panic
s := v.(string)

// 安全:失败只是 ok = false
s, ok := v.(string)

接口 vs Type Switch

方式优点缺点
接口扩展性好,加新类型不用改函数需要提前定义接口
Type Switch直观,能处理任意类型加新类型要改函数

错误处理

strconv 是 Go 标准库里专门处理字符串和数字之间转换的包。strconv.Atoi = ASCII to Integer。

Go 的错误处理模式:

result, err := someFunction()
if err != nil {
    // 处理错误
    return ..., err  // 往上层传
}
// 正常使用 result

核心规则:

  1. 错误是普通值,函数返回 error 接口
  2. nil = 没错,非 nil = 有错(带错误信息)
  3. 函数只返回错误,调用方决定怎么处理

error 接口

error 是 Go 内置的接口,只有一个方法:

type error interface {
    Error() string
}

任何实现了 Error() string 方法的类型,都算 error

创建自己的错误

使用 errors.Newfmt.Errorf

import "errors"

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("除数不能为零")
    }
    return a / b, nil
}

或者用 fmt.Errorf 带格式化:

return 0, fmt.Errorf("除数不能为零:%f", b)

错误包装

有时候你想在错误上加点上下文,但是不想丢失原始错误:

func readConfig(path string) (string, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        // 包装原始错误,加上上下文
        return "", fmt.Errorf("读取配置文件失败:%w", err)
    }
    return string(data), nil
}

然后用 errors.Is 判断错误类型:

content, err := readFile("不存在的文件.txt")
if err != nil {
    fmt.Println("错误:", err)
    if errors.Is(err, os.ErrNotExist) {
        fmt.Println("原因:文件不存在")
    }
}

设计理念:错误处理就像"层层汇报"

想象一个公司出了问题:

  • 员工:硬盘坏了(原始错误)
  • 主管:服务器出问题了(包装一层)
  • 经理:系统故障(再包装一层)

如果只传一句话:经理只知道"系统故障",不知道根本原因,没法针对性解决。

如果层层包装但保留原始原因:经理收到 → 系统故障 → 服务器出问题 → 硬盘坏了。经理可以用 errors.Is 查问:“是不是硬盘的问题?"——穿透所有包装,直接找到根本原因。

代码对应:

// 底层:硬盘坏了
os.ReadFile("xxx")  // 返回 os.ErrNotExist(文件不存在)

// 中层:服务器出问题了
fmt.Errorf("读取文件失败: %w", err)  // 包装,但保留原始错误

// 顶层:经理
if errors.Is(err, os.ErrNotExist) {
    // 穿透包装,发现根本原因是"文件不存在"
    fmt.Println("原因:文件不存在")
}

三种方式对比:

// 方式1:只传原始错误(不好)
return err  // main 只知道 "no such file or directory",不知道是哪个文件

// 方式2:只传新错误(不好)
return errors.New("读取文件失败")  // main 不知道为什么失败

// 方式3:包装(正确)
return fmt.Errorf("读取文件失败: %w", err)  // main 既知道上下文,又能判断原因

Sentinel Errors(哨兵错误)

就是预先定义好的错误值,让我们可以用 errors.Is 判断。os.ErrNotExist 就是一个 Sentinel Error。自己也可以定义:

var ErrInvalidAge = errors.New("年龄不能为负数")

func setAge(age int) error {
    if age < 0 {
        return ErrInvalidAge
    }
    return nil
}

// 调用方
err := setAge(-1)
if errors.Is(err, ErrInvalidAge) {
    fmt.Println("年龄不合法")
}

panic 与 recover

Go 中大部分用 error 处理错误,但有些情况下程序直接崩溃——panic

  • panic:程序崩溃
  • recover:捕获 panic,让程序继续运行

什么时候用 panic

程序遇到无法继续运行的严重错误:

func divide(a, b int) int {
    if b == 0 {
        panic("除数不能为零")  // 程序崩溃
    }
    return a / b
}

什么时候用 recover

在服务器程序里,你不想因为一个请求崩溃就让整个服务器挂掉:

func safeDivide(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("捕获 panic: %v", r)
        }
        // %v 是 Go 里最通用的格式化动词,意思是"用默认格式打印值"
    }()
    return divide(a, b), nil
}

几个要点:

  1. 命名返回值(result int, err error) — Go 允许给返回值起名字,函数内部可以直接用这些名字,return 时可以省略返回值
  2. defer:延迟执行,函数结束的时候才运行
  3. recover:捕获 panic 的唯一方式,只能在 defer 里用
// defer 里的匿名函数:定义后立即调用
defer func() {
    if r := recover(); r != nil {
        // r 是 panic 传出来的信息
        fmt.Println("捕获到 panic:", r)
    }
}()  // 定义完立刻调用

泛型(了解即可)

为什么需要泛型?

没有泛型时,同一个逻辑要写多份:

func maxInt(a, b int) int {
    if a > b { return a }
    return b
}

func maxFloat(a, b float64) float64 {
    if a > b { return a }
    return b
}

逻辑完全一样,只是类型不同。泛型用一个函数搞定:

func max[T int | float64 | string](a, b T) T {
    if a > b { return a }
    return b
}

泛型函数语法

func 函数名[类型参数 约束](参数) 返回类型

拆解 func max[T int | float64 | string](a, b T) T

  • func max → 函数名
  • [T int | float64 | string] → T 可以是 int、float64 或 string
  • (a, b T) → a 和 b 都是 T 类型
  • T → 返回类型也是 T

调用时 Go 会自动推断 T 的类型:

max(1, 2)           // T = int
max(1.5, 2.5)       // T = float64
max("hello", "world")  // T = string

类型约束

约束就是告诉编译器"T 必须是什么类型”。

常用约束:

  • [any] → 任意类型
  • [comparable] → 可以用 == 比较的类型
  • [constraints.Ordered] → 可以用 < > 比较的类型(int、float64、string)

自定义约束:

type Number interface {
    int | int32 | int64 | float32 | float64
}

func sum[T Number](nums []T) T {
    var total T
    for _, n := range nums {
        total += n
    }
    return total
}

泛型类型

泛型还能用在 struct 上:

type Stack[T any] struct {
    items []T
}

func (s *Stack[T]) push(item T) {
    s.items = append(s.items, item)
}

func (s *Stack[T]) pop() T {
    item := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return item
}

// 使用
intStack := &Stack[int]{}
intStack.push(1)
intStack.push(2)
fmt.Println(intStack.pop())  // 2

strStack := &Stack[string]{}
strStack.push("hello")
fmt.Println(strStack.pop())  // hello

参考源

  • raw/Go语言.md