Go Struct、方法、接口与错误

# Go Struct、方法、接口与错误

本篇目标

理解 type 如何声明不同类型,使用 Struct 和方法组织数据与行为,通过小接口隔离依赖,并建立 Go 显式错误处理的正确心智模型。

理解 type设计 Struct理解方法接收者使用隐式接口包装与判断错误

# 先记住一句话

Struct 组合数据,方法定义行为,接口由使用方描述所需能力;错误是普通返回值,应被检查、补充上下文并交给合适边界处理。

# type 给类型命名

Go 中的 type 是类型声明关键字,可以先把基本语法记成:

type 类型名称 类型定义
1

type 表示 “现在要声明一种类型”,中间是开发者取的类型名,右边的 struct、interface、int 或函数签名决定这种类型内部是什么:

// Struct 类型:由一组字段组成。
type Note struct {
	Title string
}

// 接口类型:规定值必须拥有哪些方法。
type NoteService interface {
	Get(id string) (Note, error)
}

// 自定义基础类型:定义一个底层类型为 int 的新类型。
type Score int

// 函数类型:所有接收 string 并返回 error 的函数都可以赋给 Handler。
type Handler func(string) error
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

因此,type NoteService interface { ... } 中的 type 和 interface 不是在重复做一件事:

type NoteService interface { ... }
│    │           └─ 这个类型的定义是 “一组方法契约”
│    └─ 类型名称
└─ 开始声明类型
1
2
3
4

# 定义新类型与类型别名

type Score int 会定义一个新类型。Score 的底层类型虽然是 int,但两者不是同一类型,把已有的 int 值赋给 Score 时需要显式转换:

type Score int

var raw int = 90
var score Score = Score(raw) // 把 int 显式转换为 Score。
1
2
3
4

type UserID = string 中多了一个 =,表示类型别名。UserID 只是 string 的另一个名称,没有创建新类型:

type UserID = string

var id UserID = "user-1"
var rawID string = id // UserID 和 string 是同一类型,不需要转换。
1
2
3
4

# 与 TypeScript 有什么不同

TypeScript 的 interface 可以自己完成接口声明,type 通常配合 = 定义类型别名。Go 则统一使用 type 名称 定义 声明 Struct、接口、函数等类型;只有明确写出 = 才是类型别名。

// TypeScript:interface 直接声明接口。
interface NoteService {
  get(id: string): Note
}

// TypeScript:type 通常创建类型别名。
type Handler = (id: string) => Promise<void>
1
2
3
4
5
6
7
// Go:type 负责开始类型声明,interface 是右边的类型定义。
type NoteService interface {
	Get(id string) (Note, error)
}

// Go:type 同样可以声明函数类型,这里不写 =。
type Handler func(id string) error
1
2
3
4
5
6
7

类型名首字母大写时可被其他包使用,例如 NoteService;首字母小写时只在当前包内可见,例如 ownerIDKey。这是 Go 的导出规则,不是 type 本身带来的私有性。

# Struct 与方法

package note

import "strings" // 提供忽略大小写比较和清理首尾空白的字符串工具。

// Note 把一篇笔记的相关数据组合成一个类型。
type Note struct {
	ID      string   // 笔记唯一标识。
	Title   string   // 展示标题。
	Content string   // 正文内容。
	Tags    []string // 标签列表;切片中的每个元素都是一个标签。
}

// (n Note) 是方法接收者,它让 HasTag 成为 Note 类型的方法。
// HasTag 只读取字段,不需要修改原 Note,因此这里使用值接收者。
func (n Note) HasTag(target string) bool {
	// range 依次取出 n.Tags 中的标签;_ 表示不需要元素下标。
	for _, tag := range n.Tags {
		// EqualFold 使用 Unicode 大小写折叠比较字符串,例如 Go 与 go 会被视为相同。
		if strings.EqualFold(tag, target) {
			return true // 找到匹配项后即可提前结束遍历。
		}
	}
	return false // 所有标签都不匹配。
}

// *Note 是指针类型;Rename 要修改原对象,因此使用指针接收者。
func (n *Note) Rename(title string) {
	// TrimSpace 去掉新标题首尾的空格和换行,再写回指针指向的原 Note。
	n.Title = strings.TrimSpace(title)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

# func 后面的第一个括号是什么

普通函数直接在函数名后声明参数;方法会在 func 和方法名之间多出一个括号,这部分叫作方法接收者(receiver),用于说明这个方法属于哪个类型:

func (n Note) HasTag(target string) bool
     └──┬──┘ └─方法名─┘ └普通参数──┘ └返回值
      方法接收者
1
2
3

n 是接收者变量名,Note 是接收者类型。n 的作用类似 TypeScript 类方法中的 this,但 Go 不提供固定的 this 关键字,而是由开发者自己命名接收者变量,通常使用类型名的首字母。

note := Note{Title: " Go 方法 ", Tags: []string{"Go", "AI"}}

matched := note.HasTag("go") // n 表示当前的 note,target 接收普通参数 "go"。
note.Rename(" 新标题 ")      // Go 会对可取地址的 note 自动使用 &note 调用指针接收者方法。
1
2
3
4

对应到 TypeScript,写法大致是:

class Note {
  title = ""

  rename(title: string) {
    this.title = title.trim()
  }
}
1
2
3
4
5
6
7

接收者有两种常见形式:

写法 名称 含义 常见场景
(n Note) 值接收者 方法得到 Note 值的副本,给 n.Title 重新赋值不会修改原对象 只读取状态,或处理较小的值类型
(n *Note) 指针接收者 方法通过指针访问原来的 Note,可以修改原对象 修改状态、对象较大,或需要统一方法集合

值接收者并不等于绝对只读:如果 Struct 中包含 Slice、Map 或指针,副本仍可能与原对象共享它们指向的数据。选择接收者时应优先表达是否需要修改原对象,并尽量让同一类型的方法保持一致。

可以简单记成:普通参数表示 “方法要处理什么数据”,方法接收者表示 “这个方法属于谁”;接收者变量相当于 Go 显式命名的 this。

# 接口由使用方定义

package note

import (
	"context" // 把请求的取消信号和截止时间继续传给存储层。
	"errors"  // 创建和判断错误链中的领域错误。
	"fmt"     // 使用 Errorf 为底层错误补充操作上下文。
)

// ErrNotFound 是包级哨兵错误,表示请求的 Note 不存在。
var ErrNotFound = errors.New("note not found")

// interface 是方法契约;任何拥有相同方法的类型都会自动实现它。
// Store 只声明当前服务真正需要的能力,便于替换数据库和编写测试。
type Store interface {
	Get(ctx context.Context, id string) (Note, error)
}

type Service struct {
	store Store // 依赖接口而不是具体数据库实现,便于替换实现和编写测试。
}

// NewService 注入 Store,并返回可供调用的 Service 指针。
func NewService(store Store) *Service {
	// Service{store: store} 创建 Service 值,并把参数 store 赋给同名字段。
	// 前面的 & 取得该值的地址,因此函数返回 *Service(Service 指针)。
	return &Service{store: store}
}

func (s *Service) GetTitle(ctx context.Context, id string) (string, error) {
	// 多返回值同时取得业务结果和错误;:= 在函数内声明并赋值。
	note, err := s.store.Get(ctx, id)
	if err != nil {
		// %q 把 id 以带引号的形式写入错误信息;%w 专门包装 err。
		// %w 在增加上下文的同时保留原错误,调用方仍可用 errors.Is 判断错误类别。
		return "", fmt.Errorf("get note %q: %w", id, err)
	}
	return note.Title, nil // 读取成功时返回标题;nil 表示没有错误。
}

// IsNotFound 隐藏具体错误判断方式,让调用方只关心是否属于 “未找到” 类别。
func IsNotFound(err error) bool {
	return errors.Is(err, ErrNotFound)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

Go 类型不需要声明 “implements Store”。只要方法集合满足接口,就会隐式实现。这能降低实现方与抽象之间的耦合。

# 错误处理的三层边界

基础设施层:返回数据库或网络错误
  ↓ 使用 %w 补充操作上下文
业务层:转换为稳定的领域错误
  ↓
传输层:映射为 HTTP 状态码和安全错误信息
1
2
3
4
5
if err != nil {
	switch {
	case errors.Is(err, ErrNotFound):
		// HTTP 层可映射为 404,但不要在业务包中直接写 HTTP 响应。
	default:
		// 未知错误记录完整内部上下文,对客户端只返回通用信息。
	}
}
1
2
3
4
5
6
7
8

panic 适合表示程序内部必须始终成立的规则已经被破坏,导致当前流程无法安全继续;它不应代替普通业务错误。HTTP 服务可以在边界恢复单次请求的 panic,但仍必须记录堆栈并调查根因。

什么是不变量

不变量(invariant)是程序在正常运行期间必须始终成立的规则。例如,一个创建成功的任务池必须至少拥有一个 Worker,这就是任务池的不变量。

package worker

import "errors" // 用于创建调用方能够检查和处理的普通输入错误。

// Pool 保存一组可选择的 Worker;创建成功后 workers 必须始终非空。
type Pool struct {
	workers []string // 每个字符串代表一个可用 Worker 的标识。
}

// NewPool 在构造阶段校验参数,只有满足不变量时才返回 Pool。
func NewPool(workers []string) (*Pool, error) {
	if len(workers) == 0 {
		// 外部输入不合法是可以预料的失败,交给调用方处理。
		return nil, errors.New("workers cannot be empty")
	}

	return &Pool{workers: workers}, nil // 保存 Worker 切片并返回 Pool 指针。
}

// Next 返回当前选择的第一个 Worker。
func (p *Pool) Next() string {
	if len(p.workers) == 0 {
		// Pool 已经创建成功却没有 Worker,说明内部保证被破坏,通常是代码 Bug。
		panic("pool invariant broken: no workers")
	}

	return p.workers[0] // 不变量成立时,下标 0 一定存在。
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

创建时收到空列表属于可预期的输入错误,因此返回 error;创建成功后内部却突然没有 Worker,说明程序进入了理论上不该出现的状态,继续执行可能产生更严重的错误,此时才考虑 panic。

nil 接口陷阱

接口值同时保存动态类型和动态值。一个内部值为 nil 的具体指针放进接口后,接口本身可能不等于 nil。返回错误时优先直接返回 nil,不要先把 nil 指针赋给具体错误类型再作为 error 返回。

# 高频面试题与回答

1. Go 中的 type 有什么作用,与 TypeScript 有什么不同?参考答案

Go 的 type 用于声明类型,右边的 struct、interface、基础类型或函数签名决定类型定义。type Score int 会创建与 int 不同的新类型,type Score = int 才是别名。TypeScript 的 interface 可以直接声明接口,type 声明则使用等号;Go 声明接口时要写成 type Store interface。

2. Go 接口为什么通常由使用方定义?参考答案

使用方最清楚自己真正依赖哪些行为。定义小接口可以避免实现方暴露的大接口扩散到业务层,也便于用假实现测试。Go 的隐式实现让已有类型无需额外声明即可满足接口。

3. 为什么包装错误要使用 %w?参考答案

%w 在补充当前操作上下文的同时保留错误链。上层既能看到 “读取哪篇笔记失败” ,又能通过 errors.Is 或 errors.As 判断根因并决定是否重试或映射状态码。

4. 方法应该使用值接收者还是指针接收者?参考答案

需要修改接收者、避免复制较大结构体,或类型中包含锁等不可复制状态时使用指针接收者;小型不可变值可以使用值接收者。同一类型的方法通常保持一致,并注意接收者选择会影响方法集和接口实现。

5. errors.Is 和 errors.As 有什么区别?参考答案

errors.Is 判断错误链中是否包含某个目标错误,适合判断 sql.ErrNoRows 这类已知值;errors.As 找出错误链中可赋给某种错误类型的值,适合读取结构化字段。两者都会沿保留了 %w 的错误链查找。

6. 业务失败应该返回 error 还是 panic?参考答案

可预期的业务失败和外部依赖失败应返回 error,让调用方决定重试、降级或映射状态码;只有程序内部必须始终成立的规则被破坏、当前流程已经无法安全继续时,才考虑 panic。比如任务池创建成功后内部却没有任何 Worker,通常说明代码存在 Bug。HTTP 服务可以在最外层 recover 防止单个请求拖垮进程,但不能把 recover 当成正常错误处理。

# 接下来学什么

下一篇学习 Go 运行时、内存与接口陷阱,理解值复制、指针、逃逸分析、接口值和 Goroutine 调度。

# 参考资料

上次更新时间: 2026年09月18日 02:14:27