Go 语言 struct 完全指南:定义、初始化与嵌入字段实战(Build Web Application with Golang 第 2.4 节)
Go 语言 struct 完全指南:定义、初始化与嵌入字段实战(Build Web Application with Golang 第 2.4 节)
struct(结构体)是 Go 语言中组织多个相关字段的核心复合类型,也是后续章节中面向对象编程、方法绑定、接口实现乃至 Web 请求处理模型的基础。本文以《Build Web Application with Golang》第 2.4 节为骨架,系统讲解 struct 的声明语法、三种初始化方式、按值传递语义、嵌入字段(embedded fields)的组合复用,以及字段名称冲突时的解析规则,并逐一对应仓库中的可运行示例源码,帮助你在搭建 Go Web 应用前彻底掌握这一数据建模基本功。
什么是 struct:Go 中的自定义复合类型
与大多数编程语言一样,Go 允许我们定义一种"容器"类型,用来聚合多个属性或字段。例如,可以创建一个名为 person 的类型,包含 name(姓名)和 age(年龄)两个字段,用来表示一个人。这种类型在 Go 中被称为 struct(结构体)。
type person struct {
name string
age int
}
定义非常简单,这个 person 结构体包含两个字段:
name是string类型,用于存储人名;age是int类型,用于存储年龄。
结构体定义本身不占用任何运行时资源,只有实例化(分配内存)后才真正产生值。在 Web 应用中,struct 最常见的用途就是定义数据模型,例如用户、文章、订单等实体,后续配合 JSON 编解码或数据库 ORM 使用。
声明与访问字段:var 声明 + 字段赋值
先声明一个 person 类型的变量,再逐字段赋值并读取:
type person struct {
name string
age int
}
var P person // P 是 person 类型
P.name = "Astaxie" // 给 P 的 'name' 字段赋值
P.age = 25 // 给 P 的 'age' 字段赋值
fmt.Printf("The person's name is %s\n", P.name) // 访问 P 的 'name' 字段
要点:
var P person会为P分配零值内存(字符串为零值"",整数为零值0),之后再通过P.field = value逐字段写入;- 访问字段使用点号语法
P.name,与 C 语言类似; - 字段访问和赋值都发生在值副本上(详见下文"按值传递"小节)。
仓库中对应的可运行示例位于 pt-br/code/src/apps/ch.2.4/main.go,其中 show_basic_struct() 演示了上述声明-赋值-访问流程,并额外用 %+v 与 %#v 两种格式化方式打印结构体内容,方便调试时查看字段明细。
初始化 struct 的三种方式
除"先声明后赋值"外,Go 还提供了三种更简洁的初始化方式:
方式一:按顺序给定初始值
P := person{"Tom", 25}
值必须与字段声明的顺序、类型一一对应(name 在前、age 在后)。
方式二:使用 field:value 格式,不要求顺序
P := person{age: 24, name: "Bob"}
使用键值对形式,字段顺序可以任意排列,代码可读性更强,也避免了字段新增或调整顺序时破坏已有初始化代码。
方式三:定义匿名结构体并立即初始化
P := struct {
name string
age int
}{"Amy", 18}
匿名结构体没有独立类型名,适合一次性使用的场景,例如在函数内部临时组织数据、作为 map 的 value 或测试用例的结构。仓库示例 pt-br/code/src/apps/ch.2.4/main.go 中的 show_anonymous_struct() 演示了匿名结构体的初始化与 %#v 打印。
从编译语义看,方式一要求类型完全匹配且数量一致;方式二允许只初始化部分字段(未指定的字段保持零值);匿名结构体则完全脱离具名类型体系,它的类型标识由字段集合决定。这三种方式在本项目后续章节(如第 4 章表单处理中的 User 模型、第 5 章数据库映射)会被反复使用。
完整示例:结构体按值传递与多返回值
下面是一个完整的可运行示例,它定义 person 结构体,并编写 Older 函数比较两个人的年龄,返回年龄较大者及其年龄差:
package main
import "fmt"
// 定义一个全新的类型
type person struct {
name string
age int
}
// 比较两个人的年龄,返回年龄较大的人以及年龄差
// 注意:结构体是按值传递的
func Older(p1, p2 person) (person, int) {
if p1.age > p2.age {
return p1, p1.age - p2.age
}
return p2, p2.age - p1.age
}
func main() {
var tom person
// 初始化:先声明再赋值
tom.name, tom.age = "Tom", 18
// 使用 "field:value" 格式初始化
bob := person{age: 25, name: "Bob"}
// 按顺序初始化
paul := person{"Paul", 43}
tb_Older, tb_diff := Older(tom, bob)
tp_Older, tp_diff := Older(tom, paul)
bp_Older, bp_diff := Older(bob, paul)
fmt.Printf("Of %s and %s, %s is older by %d years\n", tom.name, bob.name, tb_Older.name, tb_diff)
fmt.Printf("Of %s and %s, %s is older by %d years\n", tom.name, paul.name, tp_Older.name, tp_diff)
fmt.Printf("Of %s and %s, %s is older by %d years\n", bob.name, paul.name, bp_Older.name, bp_diff)
}
运行结果:
Of Tom and Bob, Bob is older by 7 years
Of Tom and Paul, Paul is older by 25 years
Of Bob and Paul, Paul is older by 18 years
这个例子同时展示了三个重要特性:
- 结构体按值传递:
Older(p1, p2 person)的参数是结构体值副本,函数内部对字段的修改不会影响调用方;这与第 2.3 节介绍的"值传递与指针"主题一脉相承,若需要修改原始结构体,应传入*person指针; - 多返回值:
Older同时返回person和int两个值,这是 Go 惯用的错误/结果分离风格的雏形; - 三种初始化方式混用:同一个
main函数中,tom用声明后赋值,bob用键值对,paul用顺序值,说明三种方式可以按场景自由选择。
仓库中的对应完整源码位于 pt-br/code/src/apps/ch.2.4/compare_age/main.go,可直接运行验证。运行前建议按 pt-br/code/readme.md 的说明,将 GOPATH 环境变量指向该 code 目录,再从任意子目录编译执行。
嵌入字段(embedded fields):Go 的组合式复用
前面定义的结构体,每个字段都有名字和类型。实际上,Go 还支持只有类型、没有名字的字段,称为嵌入字段(embedded fields)。
当一个嵌入字段本身是结构体时,该结构体的所有字段都会隐式地成为外层结构体的字段——这就实现了类似其他语言"继承"的效果,但本质是组合(composition)。
来看一个例子:
package main
import "fmt"
type Human struct {
name string
age int
weight int
}
type Student struct {
Human // 嵌入字段:Student 结构体包含 Human 的全部字段
specialty string
}
func main() {
// 初始化一个学生
mark := Student{Human{"Mark", 25, 120}, "Computer Science"}
// 访问字段(包括来自 Human 的字段)
fmt.Println("His name is ", mark.name)
fmt.Println("His age is ", mark.age)
fmt.Println("His weight is ", mark.weight)
fmt.Println("His specialty is ", mark.specialty)
// 修改 specialty
mark.specialty = "AI"
fmt.Println("Mark changed his specialty")
fmt.Println("His specialty is ", mark.specialty)
// 修改 age(来自嵌入字段 Human)
fmt.Println("Mark become old")
mark.age = 46
fmt.Println("His age is", mark.age)
// 修改 weight
fmt.Println("Mark is not an athlete any more")
mark.weight += 60
fmt.Println("His weight is", mark.weight)
}
可以看到,mark.name、mark.age、mark.weight 都可以像访问 Student 自身字段一样直接使用,这正是嵌入字段的作用。对应的示意图如下:
Figure 2.7 Student 与 Human 的组合关系(图片来源:pt-br/images/2.4.student_struct.png)
除了字段提升,你甚至可以通过外层结构体访问到被嵌入的结构体本身,并整体替换它:
mark.Human = Human{"Marcus", 55, 220}
mark.Human.age -= 1
这相当于把 Human 作为 Student 的一个普通命名成员来使用,既支持整体赋值,也支持通过 mark.Human.age 精确访问深层字段。
仓库中对应源码为 pt-br/code/src/apps/ch.2.4/embedded_structs/main.go。
任意类型都可以作为嵌入字段
嵌入字段并不仅限于结构体——Go 中所有类型都可以作为嵌入字段,包括命名类型(如自定义的 slice 类型)甚至内建类型(如 int):
package main
import "fmt"
type Skills []string
type Human struct {
name string
age int
weight int
}
type Student struct {
Human // 结构体作为嵌入字段
Skills // string 切片类型作为嵌入字段
int // 内建类型作为嵌入字段
specialty string
}
func main() {
// 初始化 Student Jane
jane := Student{Human: Human{"Jane", 35, 100}, specialty: "Biology"}
// 访问字段
fmt.Println("Her name is ", jane.name)
fmt.Println("Her age is ", jane.age)
fmt.Println("Her weight is ", jane.weight)
fmt.Println("Her specialty is ", jane.specialty)
// 修改 skills 字段
jane.Skills = []string{"anatomy"}
fmt.Println("Her skills are ", jane.Skills)
fmt.Println("She acquired two new ones ")
jane.Skills = append(jane.Skills, "physics", "golang")
fmt.Println("Her skills now are ", jane.Skills)
// 修改内建类型嵌入字段
jane.int = 3
fmt.Println("Her preferred number is", jane.int)
}
这里 Skills 是 []string 的命名类型,作为嵌入字段后可以直接赋值、append;int 作为嵌入字段则让 Student 获得一个匿名的 int 成员,通过 jane.int 访问。这印证了原文档的结论:任何类型都能作为嵌入字段,并且可以对其直接使用对应的函数和操作符。
仓库中对应源码为 pt-br/code/src/apps/ch.2.4/embedded_structs2/main.go。
字段名称冲突:外层字段优先(field overloading)
假如 Human 有一个 phone 字段,而 Student 自身也定义了同名 phone 字段,访问 student.phone 时会发生什么?
Go 用一条非常简单的规则解决这个问题:外层字段拥有更高的访问级别。也就是说,访问 student.phone 时,得到的是 Student 自己的 phone 字段,而不是 Human 中嵌入的那个。这个特性可以理解为字段的"覆盖/重载"(field overloading)。
package main
import "fmt"
type Human struct {
name string
age int
phone string // Human 拥有 phone 字段
}
type Employee struct {
Human // 嵌入字段 Human
specialty string
phone string // Employee 也有 phone 字段
}
func main() {
Bob := Employee{Human{"Bob", 34, "777-444-XXXX"}, "Designer", "333-222"}
fmt.Println("Bob's work phone is:", Bob.phone)
// 访问 Human 中的 phone 字段
fmt.Println("Bob's personal phone is:", Bob.Human.phone)
}
运行结果:
Bob's work phone is: 333-222
Bob's personal phone is: 777-444-XXXX
要点:
Bob.phone命中Employee.phone(外层字段优先),得到工作电话;- 若要访问被覆盖的内层字段,必须显式写出嵌入字段名:
Bob.Human.phone,得到私人电话; - 这条规则同样适用于方法名冲突(第 2.5 节"面向对象"会进一步展开),是 Go 组合模型中最容易踩坑也最值得牢记的语义之一。
仓库中对应源码为 pt-br/code/src/apps/ch.2.4/embedded_structs_with_name_conflict/main.go。
小结
本节围绕 Go 的 struct 覆盖了四块核心知识:类型声明(type person struct {...})、三种初始化方式(顺序值、field:value、匿名结构体)、按值传递与多返回值(Older 示例),以及 嵌入字段(结构体嵌入实现字段提升、任意类型可嵌入、外层字段优先的冲突规则)。这些都是后续第 2.5 节"面向对象"(方法绑定与方法覆盖)、第 2.6 节"接口"以及本书 Web 开发各章数据模型设计的前提。建议结合仓库 pt-br/code/src/apps/ch.2.4/ 下的五个示例程序逐一运行验证,将语法转化为肌肉记忆。
