Go 从 0 到精通 · 第 12 课:结构体 struct

学习定位:这是整套 Go 教程的第 12 课。
前置要求:已经完成第 11 课,理解字符串的底层表示、byterune 的区别、字符串不可变性。
本课目标:掌握结构体的定义、初始化、字段访问,理解结构体嵌套与组合,能用结构体建模现实中的业务对象。


1. 本课你要解决的核心问题

到目前为止,你用的都是 Go 内置的类型:intstring[]intmap[string]int

但现实中一个"学生"有姓名、年龄、成绩——多个不同类型的数据需要组合在一起。你不可能用一个 int 或一个 string 来描述一个完整的学生。

结构体就是让你把多个相关的字段打包成一个自定义类型。

你需要搞明白以下问题:

  • 结构体怎么定义、怎么初始化
  • 怎么访问和修改字段
  • 结构体是值类型还是引用类型
  • 什么是结构体嵌套
  • 结构体有哪些常见坑

学完这一课,你就能用结构体来表达现实中的任何业务对象了。


2. 结构体的定义

2.1 基本语法

1
2
3
4
type 类型名 struct {
字段名 字段类型
字段名 字段类型
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

type Student struct {
Name string
Age int
Score float64
}

func main() {
var s Student
s.Name = "小明"
s.Age = 20
s.Score = 92.5

fmt.Println(s)
}

输出:

1
{小明 20 92.5}
  • type Student struct { ... } 定义了一个名为 Student 的结构体类型
  • 它有三个字段:NameAgeScore
  • s.Name 访问 sName 字段

2.2 命名规范

  • 类型名用大写驼峰(PascalCase):StudentBookInfo
  • 字段名用大写驼峰(首字母大写表示导出,可被其他包访问)

3. 结构体的初始化

3.1 声明后逐个赋值

1
2
3
4
var s Student
s.Name = "小明"
s.Age = 20
s.Score = 92.5

未赋值的字段保持零值。

3.2 字面量初始化(按顺序)

1
s := Student{"小明", 20, 92.5}

按字段定义的顺序传值。不推荐——字段顺序变化时容易出错。

3.3 字面量初始化(按字段名,推荐)

1
2
3
4
5
s := Student{
Name: "小明",
Age: 20,
Score: 92.5,
}

按字段名赋值,不依赖顺序。推荐这种写法

未指定的字段保持零值:

1
2
s := Student{Name: "小明"}
// s.Age = 0, s.Score = 0

3.4 new 函数(返回指针)

1
2
3
p := new(Student)
p.Name = "小红"
p.Age = 22

new(Student) 返回 *Student(指向 Student 的指针),所有字段是零值。

p.Name 是语法糖,等价于 (*p).Name——Go 自动解引用。

3.5 取结构体地址

1
2
3
4
5
s := Student{Name: "小明", Age: 20}
p := &s // p 是 *Student

p.Age = 21 // 通过指针修改
fmt.Println(s.Age) // 21

4. 访问和修改字段

4.1 用点号访问

1
2
3
4
5
6
7
8
s := Student{Name: "小明", Age: 20, Score: 92.5}

fmt.Println(s.Name) // 小明
fmt.Println(s.Age) // 20
fmt.Println(s.Score) // 92.5

s.Age = 21 // 修改
fmt.Println(s.Age) // 21

4.2 通过指针访问

1
2
3
4
p := &Student{Name: "小明", Age: 20}

fmt.Println(p.Name) // Go 自动解引用,等价于 (*p).Name
p.Age = 21 // 等价于 (*p).Age = 21

Go 的语法糖让你不需要写 (*p).Name,直接用 p.Name 就行。


5. 结构体是值类型

5.1 赋值会复制

1
2
3
4
5
6
7
s1 := Student{Name: "小明", Age: 20}
s2 := s1

s2.Age = 99

fmt.Println(s1.Age) // 20(没变)
fmt.Println(s2.Age) // 99

结构体赋值是完整复制。修改 s2 不影响 s1

5.2 传参也是复制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import "fmt"

type Student struct {
Name string
Age int
}

func birthday(s Student) {
s.Age++
fmt.Println("函数内:", s.Age)
}

func main() {
s := Student{Name: "小明", Age: 20}
birthday(s)
fmt.Println("函数外:", s.Age)
}

输出:

1
2
函数内: 21
函数外: 20

birthday 拿到的是 s 的副本,修改不影响外部。

5.3 传指针可以修改外部

1
2
3
4
5
6
7
8
9
func birthday(s *Student) {
s.Age++
}

func main() {
s := Student{Name: "小明", Age: 20}
birthday(&s)
fmt.Println(s.Age) // 21
}

这也是实践中最常见的做法:当函数需要修改结构体时,传指针。


6. 结构体嵌套

6.1 基本用法

结构体的字段可以是另一个结构体:

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
package main

import "fmt"

type Address struct {
City string
Street string
ZipCode string
}

type Student struct {
Name string
Age int
Address Address // 嵌套结构体
}

func main() {
s := Student{
Name: "小明",
Age: 20,
Address: Address{
City: "北京",
Street: "中关村大街",
ZipCode: "100080",
},
}

fmt.Println(s.Name) // 小明
fmt.Println(s.Address.City) // 北京
fmt.Println(s.Address.Street) // 中关村大街
}

通过 s.Address.City 访问嵌套结构体的字段。

6.2 匿名嵌套(嵌入)

如果嵌套时省略字段名,就是"嵌入"(embedding):

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
type Address struct {
City string
Street string
}

type Student struct {
Name string
Age int
Address // 匿名嵌套(嵌入)
}

func main() {
s := Student{
Name: "小明",
Age: 20,
Address: Address{
City: "北京",
Street: "中关村大街",
},
}

// 可以直接访问嵌入结构体的字段
fmt.Println(s.City) // 北京
fmt.Println(s.Street) // 中关村大街

// 也可以用完整路径
fmt.Println(s.Address.City) // 北京
}

嵌入后,Address 的字段被"提升"到了 Student 的层级,可以直接通过 s.City 访问。

这是 Go 实现"组合"的核心机制之一,第 15 课《组合优于继承》会深入讲解。

6.3 嵌入与命名嵌套的选择

  • 命名嵌套Address Address,需要通过 s.Address.City 访问
  • 匿名嵌入Address,可以通过 s.City 直接访问

当嵌套结构体的字段名和外层冲突时,必须用完整路径:

1
2
3
4
5
6
type Student struct {
Name string
Address // Address 也有 City,没有冲突
}

// s.City 没有歧义,直接访问 Address 的 City

如果 Student 也加了个 City 字段,就需要用 s.Address.City 来区分。


7. 结构体的比较

7.1 可比较的情况

如果结构体的所有字段都是可比较类型,结构体本身也可以用 ==!= 比较:

1
2
3
4
5
6
7
8
9
10
type Point struct {
X, Y int
}

a := Point{1, 2}
b := Point{1, 2}
c := Point{3, 4}

fmt.Println(a == b) // true
fmt.Println(a == c) // false

7.2 不可比较的情况

如果字段包含切片、map、函数等不可比较类型,结构体就不能用 ==

1
2
3
4
5
6
7
type Data struct {
Items []int // 切片不可比较
}

a := Data{Items: []int{1, 2}}
b := Data{Items: []int{1, 2}}
// fmt.Println(a == b) // 编译错误

需要逐个字段比较,或自己写 Equal 方法。


8. 结构体的零值

每个字段按自身类型的零值初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Config struct {
Name string // ""
Port int // 0
Debug bool // false
Tags []string // nil
Options map[string]int // nil
}

var c Config
fmt.Println(c.Name) // ""
fmt.Println(c.Port) // 0
fmt.Println(c.Debug) // false
fmt.Println(c.Tags) // []
fmt.Println(c.Options) // map[]

9. 一段综合示例

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package main

import "fmt"

type Address struct {
Province string
City string
}

type Student struct {
Name string
Age int
Scores []float64
Address Address
}

// 计算平均分
func (s Student) AvgScore() float64 {
if len(s.Scores) == 0 {
return 0
}
total := 0.0
for _, score := range s.Scores {
total += score
}
return total / float64(len(s.Scores))
}

// 过生日(用指针修改)
func (s *Student) Birthday() {
s.Age++
}

func main() {
// 创建学生
s := Student{
Name: "小明",
Age: 20,
Scores: []float64{90, 85, 92, 88},
Address: Address{
Province: "北京",
City: "海淀区",
},
}

// 访问字段
fmt.Printf("姓名:%s\n", s.Name)
fmt.Printf("年龄:%d\n", s.Age)
fmt.Printf("地址:%s %s\n", s.Address.Province, s.Address.City)

// 调用方法
fmt.Printf("平均分:%.1f\n", s.AvgScore())

// 过生日
s.Birthday()
fmt.Printf("生日后年龄:%d\n", s.Age)

// 值复制验证
s2 := s
s2.Name = "小红"
fmt.Printf("s.Name = %s, s2.Name = %s\n", s.Name, s2.Name)

// 传指针修改
moveToShanghai(&s)
fmt.Printf("搬家后:%s %s\n", s.Address.Province, s.Address.City)
}

func moveToShanghai(s *Student) {
s.Address.Province = "上海"
s.Address.City = "浦东新区"
}

输出:

1
2
3
4
5
6
7
姓名:小明
年龄:20
地址:北京 海淀区
平均分:88.8
生日后年龄:21
s.Name = 小明, s2.Name = 小红
搬家后:上海 浦东新区

注意:示例中出现了方法 (s Student) AvgScore(),这是下一课的内容。这里先感受一下,不用担心。


10. 常见坑总结

10.1 以为结构体赋值是引用

1
2
3
4
s1 := Student{Name: "小明"}
s2 := s1
s2.Name = "小红"
// s1.Name 仍然是 "小明"

结构体是值类型,赋值和传参都会完整复制。

10.2 字面量初始化遗漏字段

1
s := Student{"小明", 20}  // 编译错误:少了 Score

用字面量初始化时,要么全部按顺序写,要么用字段名初始化。

10.3 nil 切片和 map 字段

1
2
3
4
5
6
var s Student
// s.Scores 是 nil 切片,直接 append 是安全的
s.Scores = append(s.Scores, 90)

// s.Tags 如果是 map,直接写入会 panic
// 必须先初始化

10.4 匿名嵌套字段名冲突

1
2
3
4
5
6
7
8
9
type A struct { X int }
type B struct { X int }
type C struct {
A
B
}

var c C
// c.X // 编译错误:ambiguous selector c.X

当多个嵌入的结构体有同名字段时,必须用完整路径:c.A.Xc.B.X

10.5 结构体太大时传值开销高

1
2
3
4
5
6
7
type BigStruct struct {
Data [1024 * 1024]byte // 1MB
}

func process(s BigStruct) { // 每次调用复制 1MB
// ...
}

大结构体应该传指针:

1
2
3
func process(s *BigStruct) {  // 只复制 8 字节的指针
// ...
}

11. 本课练习

练习 1:定义和初始化

要求:

  • 定义 Book 结构体,包含 TitleAuthorPages 字段
  • 用字段名初始化方式创建两本书
  • 打印每本书的信息

练习 2:结构体切片

要求:

  • 创建 []Book,包含若干本书
  • for range 遍历,打印每本的标题和作者
  • 找出页数最多的书

练习 3:嵌套结构体

要求:

  • 定义 ContactInfo 结构体(PhoneEmail
  • 定义 Employee 结构体(NameContactInfo
  • 创建员工,通过嵌套字段访问联系信息

练习 4:传指针修改

要求:

  • 定义函数 promote(e *Employee),给员工加薪(在结构体中加 Salary 字段)
  • 验证外部的结构体被修改

练习 5:结构体比较

要求:

  • 定义 Point 结构体(X, Y int
  • 创建两个相同的 Point,用 == 比较
  • 创建一个包含切片字段的结构体,尝试用 == 比较,观察编译结果

练习 6:匿名嵌套

要求:

  • 定义 Base 结构体(含 ID intCreatedAt string
  • 定义 Product 嵌入 Base,加上 NamePrice
  • 验证可以通过 product.ID 直接访问嵌入字段

12. 自测题

12.1 概念题

  1. 结构体怎么定义?
  2. 用字段名初始化和按顺序初始化,哪种更推荐?为什么?
  3. 结构体是值类型还是引用类型?
  4. 怎么在函数里修改外部的结构体?
  5. 结构体嵌套和嵌入有什么区别?
  6. new(Student) 返回什么?
  7. 结构体赋值 s2 = s1 是复制还是引用?
  8. 哪些情况下结构体不能用 == 比较?
  9. p.Namep 是指针)能直接访问字段吗?
  10. 大结构体传参为什么建议传指针?

13. 本课总结

这一课你学到了 Go 中自定义类型的核心能力——结构体。

你现在应该已经理解:

  • 结构体用 type Name struct { ... } 定义
  • 用字段名初始化最安全、最清晰
  • 用点号 s.Field 访问字段,指针也一样(语法糖)
  • 结构体是值类型:赋值和传参都复制
  • 需要修改外部结构体时传指针
  • 可以嵌套结构体(命名嵌套)或嵌入结构体(匿名嵌套)
  • 所有字段可比较时,结构体可以用 ==

14. 下一课预告

下一课我们学习:方法 method

会重点讲:

  • 方法是什么,和普通函数有什么区别
  • 值接收者 vs 指针接收者
  • 什么时候用值接收者,什么时候用指针接收者
  • 方法的本质是什么

学完下一课,你就能为结构体定义行为,实现 Go 中"数据 + 行为"的组织方式。