Go 的 struct 与方法
用 struct 聚合数据、用方法附着行为:字段可见性、零值可用、值/指针接收者选择,以及与方法集、接口的衔接。
[!info] Related notes
Go 的 struct 与方法
这个概念为什么会出现
Go 没有 class/继承主线,但仍需要:
- 把相关字段聚合成一个值
- 把操作绑定到类型上,方便调用与接口抽象
struct 解决聚合;方法(带接收者的函数)解决行为附着。二者共同构成 Go 最常见的“面向对象风格”表达,却保持“就是数据 + 函数”的透明模型。
[!abstract] 一句话理解
struct是字段的复合值类型;方法是带接收者参数的函数。值接收者在副本上工作,指针接收者可共享并修改原对象——接口满足则看方法集。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:会员中心改昵称与过生日
用户资料用 Member 聚合:对外展示名 Name,对内年龄 age。
读展示名用值接收者(本例只读取 string 字段);
改昵称、过生日用指针接收者(必须改到原对象上)。
package main
import "fmt"
// Member 是会员资料聚合。
// 业务意图:公开昵称,隐藏年龄实现细节。
// 教学点:字段首字母大小写 = 是否导出。
type Member struct {
Name string
age int // 包内可见
}
// DisplayName 只读展示名。
// 教学点:值接收者拿到副本;本例字段为 string/int,读取不会修改共享状态。
func (m Member) DisplayName() string {
return m.Name
}
// Rename 修改昵称。
// 业务意图:用户在设置页提交新名字。
// 教学点:指针接收者才能写回原 Member,值接收者改的是副本。
func (m *Member) Rename(name string) {
m.Name = name
}
// CelebrateBirthday 年龄 +1。
// 业务意图:生日任务触发时更新内部年龄。
func (m *Member) CelebrateBirthday() {
m.age++
}
func main() {
// 字面量初始化:导出字段用 Name:,未导出 age 同包可写。
m := Member{Name: "Ann", age: 20}
fmt.Println(m.DisplayName()) // Ann
// 语法糖:m 是值,调用指针方法时编译器自动取址。
m.Rename("Bob")
m.CelebrateBirthday()
fmt.Println(m.Name, m.age) // Bob 21
}
保存为 main.go 后运行:
go run main.go
期望输出:
Ann
Bob 21
结合场景再看三个关注点
-
struct 聚数据,方法挂行为
没有 class,但Member+ 方法已经够表达会员领域对象。 -
值接收者 vs 指针接收者
本例读取用值、修改字段用指针,以展示两种语义;查询方法也可用指针接收者,不应机械地按读/写划分。 -
调用处的自动取址
m.Rename在m可寻址时会变成(&m).Rename;接口赋值时方法集规则更严(见方法集笔记)。
核心概念
结构体类型
type Point struct {
X, Y int
}
- 字段名大写导出,小写包内
- 零值是字段零值的组合,常可直接使用
- 字面量:
Point{1, 2}或Point{X: 1, Y: 2} - 类型可比较当且仅当所有字段类型可比较;若含 interface 字段,其动态值不可比较时,实际比较仍可能 panic
规范:Struct types
方法声明
func (r ReceiverType) MethodName(params) results { }
- 接收者基类型 T 必须是本包定义的类型,不能是指针或接口类型;接收者写作
T或*T,不局限于 struct - 方法必须声明在与类型同一包
- 可寻址的 T 值可隐式取址调用
*T方法;map 元素和函数返回的 T 值不可这样调用。*T调用 T 的值方法需要解引用,nil 指针会 panic;这些简写不会扩大方法集
值接收者 vs 指针接收者
值接收者 (T) | 指针接收者 (*T) | |
|---|---|---|
| 收到什么 | T 的副本 | 指针值的副本,指向同一对象 |
| 修改原值 | 直接给字段赋值只改副本;经 map、slice、指针字段可改共享数据 | 可经指针改原对象;重绑接收者本身不改调用方指针 |
| 大结构体 | 可能复制成本高 | 常更合适 |
| 接口 | 仅值方法进 T 的方法集 | 见方法集笔记 |
选择经验(非硬性):
- 需要改接收者 → 指针
- 大结构体通常选指针;含锁应避免复制,
sync.Mutex首次使用后禁止复制 - 小不变语义、类似内置值 → 值
- 同一类型方法集保持一致(通常整组都用指针或明确文档)
精确接口规则 → go-method-sets-and-receivers。
构造与零值
Go 无构造器关键字;New* 只是普通函数命名惯例,不能阻止调用者构造零值。例如:
type User struct { Name string }
func NewUser(name string) *User {
return &User{Name: name}
}
零值可用是设计目标:var sb strings.Builder 即可用。
嵌入预告
匿名字段实现组合,见 go-embedding-and-composition。标签见 go-struct-tags。
设计动机
- 组合优于继承:数据平面简单,行为通过方法与接口扩展
- 显式:没有隐式 this 可变性之外的魔法;接收者类型写在签名上
- 与接口衔接:方法集决定隐式满足
边界情况
- 值方法直接给字段赋值只改副本;通过字段中的 map、slice 或指针仍可修改共享目标。
- nil 指针可作为指针接收者进入方法;是否 panic 取决于方法体。示例的 Rename 和 CelebrateBirthday 均会解引用,不支持 nil。
- 不可导出字段在包外无法直接访问,但同包方法可封装。
- struct 赋值逐字段复制;slice 共享底层数组、map 共享映射,指针共享指向对象,不会深拷贝这些数据。
- 方法值在创建时求值并保存接收者:T 接收者保存当时的副本,
*T接收者保存指针,后续调用可见该对象的新状态。
常见误区
[!warning] 常见误区:把 struct 当 class 错误:期待构造继承多态关键字。
正确:组合 + 接口 + 方法集。
[!warning] 常见误区:值方法里修改并期望生效 错误:
(u User) Set(x)改u.x。
正确:用指针接收者或返回新值。
[!warning] 常见误区:混用接收者导致接口困惑 错误:有的方法值、有的指针,却期望
T满足接口。
正确:先查方法集。
工程实践
- 字段少而内聚;大对象拆分
- 可变性通过指针接收者集中表达
- 校验放在
New*或显式Validate - 对导出 API 保持最小字段导出
- 测试表驱动覆盖零值与非法状态
- 含
sync.Mutex的结构体不要值复制
可验证实验
- 值接收者改字段,打印调用前后。
- 指针接收者改字段对比。
- 将仅指针方法的类型赋给接口:值与指针谁能编译。
- 复制含 map 字段的 struct,改 map 看是否共享。
验证记录(Go 1.26.8):运行了完整会员示例,以及浅拷贝共享、方法值绑定、nil 接收者、接口字段比较 panic 的边界;验证了 map 元素/函数返回值调用指针方法、值赋给仅指针方法接口,以及非法接收者基类型的编译失败。
本节总结
- 本质:聚合数据 + 带接收者的函数。
- 关键:导出规则、零值、接收者复制 vs 共享、方法集连接接口。
- 下一步:go-method-sets-and-receivers 与 go-interfaces。
自测题
概念题
- 方法与普通函数的本质区别?
- 何时必须用指针接收者才能改状态?
- 小写字段包外能直接读吗?
代码推理题
type C struct{ n int }
func (c C) Inc() { c.n++ }
func main() {
var c C
c.Inc()
fmt.Println(c.n)
}
输出?如何修正?
工程思考题
Config 含 map[string]string 与 mu sync.Mutex。应值传递还是指针?New 返回什么?
参考答案
展开
- 多了接收者,归属类型方法集。
- 要直接修改调用方 struct 的字段时;若只修改 map、slice 或指针字段引用的共享数据,值接收者也能做到。
- 不能。
代码输出 0;改为*C接收者。
工程:指针传递;New返回*Config并初始化 map;避免复制锁。
延伸阅读
| 资料 | 支撑 |
|---|---|
| Spec — Struct types | 结构体 |
| Spec — Method declarations | 方法 |
| Effective Go — Methods | 风格 |
| Go Code Review Comments | 接收者等约定 |
笔记元信息
- 文件名:
go-structs-and-methods.md - 状态:已深化