4.1 结构体定义与初始化
前 3 章里,TaskAPI 的 Task 是一个「能用」的类型:它靠字段字面量在 main.go 里被临时拼装,add、list、toggle 三个命令式函数各自操作它。到了第 4 章,我们要让 Task 真正承担起领域对象的职责——它能自我描述、能改变自己的状态、能通过组合表达子任务。这一步的起点,是把结构体本身的语义吃透。
本节把 TaskAPI 推进到「领域类型成型」:
Task从一组散落字段变成一个带明确零值语义、可安全复制、可比较的值类型,为 4.2 的Complete()/Rename()方法打好地基。
4.1.1 从字段到类型
第 2 章我们用最朴素的方式定义过 Task:
type Task struct {
ID int64
Title string
Done bool
}
这行 type ... struct { ... } 做了三件事:声明一个新的具名类型 Task、把 ID / Title / Done 三个字段聚合进同一个内存块、给这个聚合体一个可以在函数签名和接口里使用的名字。字段的书写顺序就是它们在内存中的排列顺序(编译器可能为对齐插入填充,但不会重排字段)。
字段命名遵循一条 Go 社区惯例:导出性由首字母大小写决定。ID、Title、Done 都是大写,意味着它们在包外可见。第 7 章把代码拆进 internal/task 之后,这层可见性会直接影响 cmd/taskapi 能否读写字段。现在先记住结论:领域字段通常导出,纯内部状态才用小写。
4.1.2 三种初始化写法
Task 的构造方式有三种,它们语义不同,不是风格偏好:
| 写法 | 示例 | 特点 |
|---|---|---|
| 字段名初始化 | Task{ID: 1, Title: "写稿"} | 推荐;字段顺序无关,可读性最好,新增字段不受影响 |
| 位置初始化 | Task{1, "写稿", false} | 紧凑但脆弱;字段增删或重排会静默改变语义,团队项目应避免 |
new | p := new(Task) | 返回 *Task,指向全零值,等价于 &Task{} |
项目里统一用字段名初始化。它的关键收益是「新增字段不破坏调用点」:以后给 Task 加一个 Priority int 字段,所有 Task{ID: ..., Title: ...} 都仍然合法,新字段自动取零值。
func main() {
// 字段名初始化:推荐
t := Task{ID: 1, Title: "写稿"}
fmt.Printf("%+v\n", t) // {ID:1 Title:写稿 Done:false}
// 零值:所有字段取各自类型的零值
var zero Task
fmt.Printf("%+v\n", zero) // {ID:0 Title: Done:false}
// new 返回指针
p := new(Task)
p.ID = 2
fmt.Printf("%+v\n", *p) // {ID:2 Title: Done:false}
}
实测输出(GOTOOLCHAIN=go1.27.0 go run):
{ID:1 Title:写稿 Done:false}
{ID:0 Title: Done:false}
{ID:2 Title: Done:false}
4.1.3 零值语义:Go 的设计信条
Go 有一条贯穿语言的设计原则:类型的零值应当可用。int 的零值是 0,string 的零值是 "",bool 的零值是 false,而结构体的零值就是「每个字段都取零值」——它是递归可用的,不需要构造函数兜底。
这对 Task 意味着:var t Task 立刻就是一个合法对象,t.Title 是空串而不是 nil,t.Done 是 false 而不是未定义。项目里判断「任务是否为空」时,靠的是字段值本身,而不是「是否被初始化过」这种无法表达的额外状态。
对比其他语言要小心的地方:Go 没有「未初始化字段」的概念。var t Task 之后 t.Title == "",你无法区分「用户没填标题」和「用户填了空标题」。这种区分要靠额外的 *string 或校验层表达——第 6 章的 ErrInvalidTitle 就承担这个职责。
零值可用是「能不能少写构造函数」的判据。如果一个类型的零值不可用(例如内部 map 为 nil 就不能写入),惯用法是让它在首次使用时惰性初始化,而不是强制所有人走构造函数。
4.1.4 结构体是可比较的(有条件)
只要所有字段都可比较,结构体就可以用 == 比较,也能作为 map 的 key。int64、string、bool 都可比较,所以 Task 天然可比较:
a := Task{ID: 1, Title: "写稿"}
b := Task{ID: 1, Title: "写稿"}
fmt.Println(a == b) // true
seen := map[Task]bool{a: true}
fmt.Println(seen[b]) // true
一旦字段里出现不可比较的类型——slice、map、func——整个结构体就失去可比较性,== 会直接编译报错。这是项目设计里的一个真实约束:如果以后给 Task 加 Tags []string 字段,Task 就再也不能当 map 的 key,也不能用 == 整体比较。届时正确做法是改用 ID 做 key,而不是给 Task 塞一个手工比较函数。
| 字段类型 | 可比较 | 可作 map key |
|---|---|---|
int / int64 / float64 | 是 | 是 |
string / bool | 是 | 是 |
| 指针 | 是(比较地址) | 是 |
| 数组(元素可比较) | 是 | 是 |
| 结构体(字段全可比较) | 是 | 是 |
slice / map / func | 否 | 否 |
4.1.5 匿名结构体与嵌套
有时你只需要一个临时的数据聚合,不想为它命名。匿名结构体适合一次性场景,比如测试用例、JSON 编解码的中间形态:
point := struct {
X, Y int
}{X: 3, Y: 4}
fmt.Println(point.X + point.Y) // 7
但不要把匿名结构体用在项目的主数据模型上。Task 必须是具名类型,因为它要出现在函数签名、接口定义、以及第 8 章的测试用例里——没有名字就无法被引用。
结构体字段本身也可以是结构体,这是「组合」的最原始形态:
type DueDate struct {
Year int
Month int
Day int
}
type Task struct {
ID int64
Title string
Done bool
DueDate DueDate
}
这里 DueDate 是具名字段,访问方式是 t.DueDate.Year。4.3 会讲另一种形态——嵌入(DueDate 不带字段名),它带来方法提升,但语义和具名嵌套完全不同。现阶段先用具名嵌套,它的意图更直白:Task 拥有一个截止日期。
4.1.6 内存布局与对齐
结构体的字段按声明顺序紧密排列,但编译器会插入填充字节让每个字段落在对齐边界上。在 64 位平台,int64 需要 8 字节对齐,bool 需要 1 字节。把大字段排在小字段前面通常能减少填充:
// 可能有填充:int64(8) + bool(1) + pad(7) + int64(8) = 24 字节
type Wasteful struct {
A int64
B bool
C int64
}
// 更紧凑:int64(8) + int64(8) + bool(1) + pad(7) = 24 字节
type Compact struct {
A int64
C int64
B bool
}
用 unsafe.Sizeof 可以实测。对入门阶段的项目来说,Task 只有 3 个字段,填充开销可以忽略;但当你定义会被大量复制的高频结构体(比如第 10 章 worker 队列里的任务项)时,字段顺序就值得看一眼。
import "unsafe"
fmt.Println(unsafe.Sizeof(Wasteful{})) // 24
fmt.Println(unsafe.Sizeof(Compact{})) // 24
实测两个都是 24 字节——因为 bool 后面的填充在两种排布下都无法被消除。这恰好说明:不要凭直觉猜布局,要实测。结构体填充的收益往往比想象中小。
4.1.7 值传递与指针传递
结构体是值类型:把它传给函数、赋给变量、放进容器,都会复制一份完整数据。这个行为决定了「什么时候传值、什么时候传指针」。
func byValue(t Task) { t.Done = true } // 改的是副本
func byPointer(t *Task) { t.Done = true } // 改的是本体
func main() {
t := Task{ID: 1, Title: "x"}
byValue(t)
fmt.Println(t.Done) // false:副本被改,本体没动
byPointer(&t)
fmt.Println(t.Done) // true:本体被改
}
实测输出 false 和 true。判据可以总结成表:
| 场景 | 传值 Task | 传指针 *Task |
|---|---|---|
| 只读、小结构体 | 推荐 | 也可 |
| 需要修改调用方的对象 | 做不到 | 推荐 |
| 结构体很大(多个大字段) | 复制开销大 | 推荐 |
| 需要表达「可能为 nil」 | 做不到 | 用 *Task |
Task 只有 3 个字段,复制成本很低,只读函数传值完全没问题;但只要函数需要修改它,就必须传指针——这正是 4.2 选择接收者的依据。
一个直觉纠正:Go 的「指针」不是「引用」。传
*Task传的仍是一个值(地址),只是通过它可以改到本体。不存在「传引用」这种语义,一切参数都是值传递。
4.1.8 项目落地:Task 的最终定义
综合以上,第 4 章里 Task 的形态定为:
// Task 是 TaskAPI 的核心领域类型。
// 零值可用:var t Task 是合法对象,Title 为空串,Done 为 false。
type Task struct {
ID int64
Title string
Done bool
}
三个字段都导出(第 7 章拆包后 cmd 层需要读),全部可比较(ID 可以做 map key),零值可用(var t Task 无需构造)。这正是 4.2 要给它加方法的前提:值语义清晰,方法才能放心地选择值接收者或指针接收者。
一个容易踩的坑是结构体按值复制。Task 作为参数传递、赋值、放进 []Task 时都会复制整个结构体:
a := Task{ID: 1, Title: "原"}
b := a // 完整复制
b.Title = "改" // 只改 b,a 不受影响
fmt.Println(a.Title, b.Title) // 原 改
这个行为在第 3 章的 []Task 里已经出现过,但它真正的意义要到 4.2 讲接收者时才完全展开:值接收者方法操作的是副本,指针接收者方法操作的是本体。理解了结构体复制,接收者的选择就不再是玄学。
4.1.9 小结与检查清单
本节把 Task 从「能跑」推到「语义清晰」。落地时逐条对照:
- 用字段名初始化,不用位置初始化
- 确认零值可用,不需要强制构造函数
- 确认所有字段可比较(否则
Task不能做 map key) - 主数据模型用具名类型,匿名结构体只用于一次性场景
- 需要修改调用方对象时传指针;只读的小结构体传值即可
- 区分具名嵌套(
t.DueDate.Year)与嵌入(4.3 讲) - 需要精确内存布局时用
unsafe.Sizeof实测,不靠猜
下一节给这个类型加上 Complete() 与 Rename(),并回答一个看似简单却最容易出错的问题:这两个方法该用值接收者还是指针接收者。
阅读导航:上一节:3.3 字符串、rune 与字节 · 下一节:4.2 方法与值/指针接收者 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。