跳到主要内容
预计阅读 25 分钟

复合类型:数组、切片、Map与结构体 — 数据的”收纳术”

如果基础类型是一个个单独的物品,那复合类型就是收纳盒、书架和文件夹——它们帮你把数据有序地组织在一起,让程序不再杂乱无章。

📋 开篇自测:你已经知道多少?

  1. 数组和切片有什么本质区别?为什么实际开发中很少直接用数组?
  2. Map的零值是什么?直接往零值Map里写数据会怎样?
  3. 结构体和类有什么区别?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 切片的底层结构

要真正理解切片,你需要知道它的内部构造。一个切片在内存中由三部分组成:

  1. 指针(ptr):指向底层数组的某个位置
  2. 长度(len):当前切片包含的元素数量
  3. 容量(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 之后,s1s2 共享底层数组,修改一个会影响另一个。
  • 误区三: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还是增加一个新的索引?


📝 掌握度自测

  1. 以下关于Go数组的说法,哪个是正确的?

    • A) 数组长度可以动态变化
    • B) [3]int[5]int 是相同的类型
    • C) 数组是值类型,赋值和传参会复制整个数组
    • D) 数组可以存放不同类型的元素
  2. 以下代码的输出是什么?

    s := []int{1, 2, 3}
    s2 := s
    s2[0] = 99
    fmt.Println(s[0])
    • A) 1
    • B) 99
    • C) 0
    • D) 编译错误
  3. 以下哪种操作会导致panic?

    • A) 读取Map中不存在的键
    • B) 向nil Map中写入数据
    • C) 创建一个空切片
    • D) 访问结构体的零值字段
  4. 关于结构体方法的接收者,以下说法正确的是:

    • A) 值接收者可以修改结构体字段
    • B) 指针接收者不能读取结构体字段
    • C) 指针接收者可以修改结构体的原始值
    • D) Go不支持给结构体定义方法
  5. 以下关于切片容量的说法,哪个是正确的?

    • 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