复合类型:数组、切片、Map与结构体 — 数据的”收纳术”
如果基础类型是一个个单独的物品,那复合类型就是收纳盒、书架和文件夹——它们帮你把数据有序地组织在一起,让程序不再杂乱无章。
📋 开篇自测:你已经知道多少?
- 数组和切片有什么本质区别?为什么实际开发中很少直接用数组?
- Map的零值是什么?直接往零值Map里写数据会怎样?
- 结构体和类有什么区别?Go是怎样实现”面向对象”的?
一、数组:固定长度的储物格
数组是最基础的复合类型——它把相同类型的数据排成一排,每个位置都有一个编号(索引)。
1.1 数组的声明和使用
// 声明一个包含5个整数的数组
var scores [5]int
scores[0] = 95
scores[1] = 88
scores[2] = 76
scores[3] = 92
scores[4] = 85
// 声明时直接初始化
colors := [3]string{"红", "绿", "蓝"}
// 让编译器数数组长度
primes := [...]int{2, 3, 5, 7, 11, 13}
fmt.Println(len(primes)) // 6
1.2 数组的特点
Go的数组有一个关键特点需要牢记:长度是数组类型的一部分。
var a [3]int
var b [5]int
// a 和 b 是不同的类型!不能互相赋值
这就像快递柜——10格的柜子和20格的柜子是完全不同的设备,你不能把10格柜子的数据直接塞进20格柜子。
另一个重要特点:数组是值类型。当你把数组传给函数时,传递的是完整副本:
func modifyArray(arr [3]int) {
arr[0] = 999 // 只修改了副本
}
original := [3]int{1, 2, 3}
modifyArray(original)
fmt.Println(original[0]) // 还是 1,没变
正因为这两个限制——长度固定、传递要复制——在实际Go开发中,我们很少直接使用数组,而是使用切片。
🤔 想一想 为什么Go要把数组长度作为类型的一部分?这和C语言的数组有什么不同?
二、切片:灵活的动态数组
切片(Slice)是Go中最重要、最常用的数据结构之一。如果数组是固定大小的储物格,切片就是可以伸缩的拉杆箱——需要多大空间,就展开多大。
2.1 切片的创建
// 方式一:直接声明
fruits := []string{"苹果", "香蕉", "橘子"}
// 方式二:使用make函数
numbers := make([]int, 5) // 长度5,容量5
buffer := make([]byte, 0, 100) // 长度0,容量100
// 方式三:从数组中截取
arr := [5]int{10, 20, 30, 40, 50}
slice := arr[1:4] // [20, 30, 40],包含索引1、2、3
2.2 切片的底层结构
要真正理解切片,你需要知道它的内部构造。一个切片在内存中由三部分组成:
- 指针(ptr):指向底层数组的某个位置
- 长度(len):当前切片包含的元素数量
- 容量(cap):从切片起始位置到底层数组末尾的元素数量
s := make([]int, 3, 5)
fmt.Println(len(s)) // 3 - 当前有3个元素
fmt.Println(cap(s)) // 5 - 底层数组能容纳5个元素
把切片想象成一个窗户——窗户后面是一面完整的墙(底层数组),窗户的大小(长度)决定你能看到多少,而墙的面积(容量)决定窗户最多能开多大。
2.3 append:切片的增长
s := []int{1, 2, 3}
s = append(s, 4) // 追加一个元素
s = append(s, 5, 6, 7) // 追加多个元素
s = append(s, []int{8, 9}...) // 追加另一个切片
核心要点:append 返回的可能是一个新切片。 当底层数组容量不够时,Go会分配一个更大的数组,把数据复制过去,返回新的切片。所以一定要用 s = append(s, ...) 的形式,而不是忘记接收返回值。
切片的扩容策略: 当append触发扩容时,Go并不是简单地翻倍。具体策略大致为:当切片容量小于256时,新容量约为原来的2倍;当容量大于等于256时,增长因子逐渐从2倍降至约1.25倍。了解这一点有助于你在性能敏感的场景中合理预分配容量(使用 make([]T, 0, expectedCap)),避免频繁扩容带来的内存分配和数据复制开销。
s1 := make([]int, 3, 3) // 长度3,容量3
fmt.Printf("追加前:地址=%p\n", s1)
s1 = append(s1, 4) // 容量不够了,会创建新的底层数组
fmt.Printf("追加后:地址=%p\n", s1) // 地址变了!
2.4 切片的复制
src := []int{1, 2, 3, 4, 5}
dst := make([]int, len(src))
copy(dst, src) // 完整复制
dst[0] = 999
fmt.Println(src[0]) // 1 - 不受影响
直接赋值切片只是创建了一个新的”窗户”指向同一面”墙”,修改会互相影响。copy 才是真正的深拷贝。
2.5 切片的常用操作技巧
// 删除索引为i的元素
s := []int{1, 2, 3, 4, 5}
i := 2
s = append(s[:i], s[i+1:]...) // [1, 2, 4, 5]
// 在索引i处插入元素
s = append(s[:i], append([]int{99}, s[i:]...)...) // [1, 2, 99, 4, 5]
// 判断切片是否为空
if len(s) == 0 {
fmt.Println("切片为空")
}
注意Go中没有内置的 contains 函数,要判断元素是否存在,需要手动遍历(或导入标准库 slices 包,使用Go 1.21+的 slices.Contains)。
⚠️ 常见误区
- 误区一:用
==比较两个切片。Go中切片不能直接用==比较(除了和nil比较),需要手动逐元素对比或导入slices包使用slices.Equal。- 误区二:认为切片赋值是复制。
s2 := s1之后,s1和s2共享底层数组,修改一个会影响另一个。- 误区三:append后继续使用旧的切片变量。一定要用
s = append(s, ...)接收返回值。
三、Map:键值对的百宝箱
Map(映射)是Go中的哈希表/字典,它存储键值对,能以接近O(1)的时间复杂度进行查找。
3.1 Map的创建和使用
// 方式一:make创建
userAge := make(map[string]int)
userAge["张三"] = 28
userAge["李四"] = 35
userAge["王五"] = 22
// 方式二:字面量初始化
capitals := map[string]string{
"中国": "北京",
"日本": "东京",
"法国": "巴黎",
}
3.2 Map的操作
m := map[string]int{"a": 1, "b": 2, "c": 3}
// 读取
val := m["a"] // 1
// 判断键是否存在(重要!)
val, exists := m["d"]
if exists {
fmt.Println("找到了:", val)
} else {
fmt.Println("不存在")
}
// 删除
delete(m, "b")
// 遍历
for key, value := range m {
fmt.Printf("%s -> %d\n", key, value)
}
// 获取元素数量
fmt.Println(len(m))
3.3 Map的注意事项
陷阱一:零值Map不能直接写入
var m map[string]int // m 是 nil
m["key"] = 1 // panic: assignment to entry in nil map
这就像给你一个空信封——你只有一个”可以装信的概念”,但信封本身还没有被制造出来。必须先用 make 或字面量初始化。
陷阱二:Map不是并发安全的
多个goroutine同时读写同一个Map会导致panic。在并发场景下需要使用 sync.Map 或加锁。这个我们会在并发编程那一章深入讨论。
陷阱三:遍历顺序不确定
m := map[int]string{1: "一", 2: "二", 3: "三"}
for k, v := range m {
fmt.Println(k, v) // 每次运行的顺序可能不同!
}
Go故意把Map的遍历顺序随机化,防止开发者依赖特定顺序。如果你需要有序遍历,先把key排序:
keys := make([]int, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Ints(keys)
for _, k := range keys {
fmt.Println(k, m[k])
}
🤔 想一想 Map的键可以是哪些类型?为什么切片不能作为Map的键?
四、结构体:自定义数据类型
如果说数组、切片、Map是”容器”,那结构体就是”定制容器”——你可以自己定义里面有几个格子,每个格子放什么类型的东西。
4.1 定义和使用结构体
// 定义一个用户结构体
type User struct {
Name string
Age int
Email string
IsActive bool
}
// 创建结构体实例
user1 := User{
Name: "张三",
Age: 28,
Email: "zhangsan@example.com",
IsActive: true,
}
// 也可以按顺序初始化(不推荐,容易出错)
user2 := User{"李四", 30, "lisi@example.com", false}
// 访问和修改字段
fmt.Println(user1.Name) // 张三
user1.Age = 29
4.2 结构体方法
Go没有类,但可以给结构体定义方法——这就是Go实现”面向对象”的方式:
type Rectangle struct {
Width float64
Height float64
}
// 值接收者方法
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
// 指针接收者方法(可以修改结构体)
func (r *Rectangle) Scale(factor float64) {
r.Width *= factor
r.Height *= factor
}
rect := Rectangle{Width: 10, Height: 5}
fmt.Println(rect.Area()) // 50
rect.Scale(2)
fmt.Println(rect.Area()) // 200
值接收者 vs 指针接收者:
- 值接收者
(r Rectangle):方法内操作的是副本,不影响原对象 - 指针接收者
(r *Rectangle):方法内操作的是原对象本身
经验法则:如果方法需要修改结构体,或者结构体很大(避免复制开销),用指针接收者。
4.3 结构体嵌套(组合)
Go用组合代替继承。你可以把一个结构体嵌入到另一个结构体中:
type Address struct {
City string
Street string
ZipCode string
}
type Employee struct {
Name string
Age int
Address // 匿名嵌入 - 相当于"继承"了Address的所有字段
}
emp := Employee{
Name: "王五",
Age: 35,
Address: Address{
City: "北京",
Street: "中关村大街1号",
ZipCode: "100080",
},
}
// 可以直接访问嵌入结构体的字段
fmt.Println(emp.City) // 北京(不需要写 emp.Address.City)
fmt.Println(emp.Street) // 中关村大街1号
这种组合方式就像拼装玩具——每个零件都是独立的,你可以自由组合出不同的造型。这比传统的继承链更加灵活,不会出现”钻石继承”等复杂问题。
4.4 结构体标签(Tags)
结构体标签是附加在字段上的元数据,最常见的用途是控制JSON序列化:
import (
"encoding/json"
"fmt"
)
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
Internal string `json:"-"` // "-" 表示序列化时忽略此字段
}
p := Product{ID: 1, Name: "Go编程书", Price: 59.9, Internal: "秘密"}
// 序列化为JSON
data, _ := json.Marshal(p)
fmt.Println(string(data))
// {"id":1,"name":"Go编程书","price":59.9}
标签就像是给每个字段贴上的”翻译指南”——告诉JSON序列化器”这个字段在JSON里叫什么名字”。
4.5 构造函数模式
Go没有构造函数关键字,但有一个约定俗成的模式:
func NewUser(name string, age int, email string) *User {
return &User{
Name: name,
Age: age,
Email: email,
IsActive: true, // 设置默认值
}
}
user := NewUser("赵六", 25, "zhaoliu@example.com")
以 New 开头的函数就是Go社区的”构造函数”约定——简单直接,没有语法糖,但人人都能看懂。
⚠️ 常见误区
- 误区一:混淆值接收者和指针接收者。如果方法需要修改结构体状态,必须用指针接收者。
- 误区二:结构体比较的陷阱。只有所有字段都可比较时,结构体才能用
==比较。如果包含切片或Map字段,就不能直接比较。- 误区三:忘记导出字段。Go中只有首字母大写的字段才能被外部包访问,小写字母开头的是私有字段。
五、综合实战:用复合类型构建一个简单的学生管理系统
把我们学到的所有复合类型组合起来,构建一个小系统:
package main
import (
"fmt"
"sort"
)
// 学生结构体
type Student struct {
ID int
Name string
Scores []int // 各科成绩,使用切片
}
// 计算平均分
func (s Student) Average() float64 {
if len(s.Scores) == 0 {
return 0
}
total := 0
for _, score := range s.Scores {
total += score
}
return float64(total) / float64(len(s.Scores))
}
// 学生管理器
type StudentManager struct {
students map[int]*Student // 用Map管理,ID为键
}
// 创建管理器
func NewStudentManager() *StudentManager {
return &StudentManager{
students: make(map[int]*Student),
}
}
// 添加学生
func (m *StudentManager) Add(s *Student) {
m.students[s.ID] = s
}
// 查找学生
func (m *StudentManager) Find(id int) (*Student, bool) {
s, ok := m.students[id]
return s, ok
}
// 获取排名(按平均分降序)
func (m *StudentManager) Ranking() []*Student {
list := make([]*Student, 0, len(m.students))
for _, s := range m.students {
list = append(list, s)
}
sort.Slice(list, func(i, j int) bool {
return list[i].Average() > list[j].Average()
})
return list
}
func main() {
mgr := NewStudentManager()
mgr.Add(&Student{ID: 1, Name: "张三", Scores: []int{90, 85, 92}})
mgr.Add(&Student{ID: 2, Name: "李四", Scores: []int{78, 82, 88}})
mgr.Add(&Student{ID: 3, Name: "王五", Scores: []int{95, 90, 98}})
// 查找学生
if s, ok := mgr.Find(1); ok {
fmt.Printf("%s 的平均分:%.1f\n", s.Name, s.Average())
}
// 排名
fmt.Println("\n成绩排名:")
for rank, s := range mgr.Ranking() {
fmt.Printf("第%d名:%s(平均分:%.1f)\n", rank+1, s.Name, s.Average())
}
}
这个小例子综合运用了结构体(Student)、切片(Scores)、Map(students)、方法、指针等知识点。虽然代码不长,但已经展示了Go中”用简单的零件拼出复杂功能”的哲学。
🤔 想一想 如果要给学生管理系统增加”按姓名查找”的功能,你会怎么设计?是遍历Map还是增加一个新的索引?
📝 掌握度自测
-
以下关于Go数组的说法,哪个是正确的?
- A) 数组长度可以动态变化
- B)
[3]int和[5]int是相同的类型 - C) 数组是值类型,赋值和传参会复制整个数组
- D) 数组可以存放不同类型的元素
-
以下代码的输出是什么?
s := []int{1, 2, 3} s2 := s s2[0] = 99 fmt.Println(s[0])- A) 1
- B) 99
- C) 0
- D) 编译错误
-
以下哪种操作会导致panic?
- A) 读取Map中不存在的键
- B) 向nil Map中写入数据
- C) 创建一个空切片
- D) 访问结构体的零值字段
-
关于结构体方法的接收者,以下说法正确的是:
- A) 值接收者可以修改结构体字段
- B) 指针接收者不能读取结构体字段
- C) 指针接收者可以修改结构体的原始值
- D) Go不支持给结构体定义方法
-
以下关于切片容量的说法,哪个是正确的?
- A) 切片的容量永远等于长度
- B) append超出容量时,Go会分配新的底层数组
- C) 切片的容量不能通过cap()函数获取
- D) 切片容量增长总是翻倍
💡 自我评估
- 答对5题:复合类型已经掌握扎实,可以向接口和并发进军了!
- 答对3-4题:基本功不错,建议重点复习切片底层原理和Map的陷阱。
- 答对0-2题:建议回到代码示例,打开编辑器亲手实验每个知识点。
参考答案: 1-C, 2-B, 3-B, 4-C, 5-B
购买课程解锁全部内容
高并发不踩坑:Go 语言从语法到微服务
¥29.90