摘要:本文是 Go 语言学习系列的第二篇,覆盖**第 2 章(复合数据类型)**的全部四种类型——数组(Array)、切片(Slice)、映射(Map)、结构体(Struct)。每种类型都从「是什么、怎么声明与初始化、底层语义(值类型还是引用类型)、怎么遍历、怎么当函数参数、有哪些常见陷阱」展开,配有可运行代码、对比表格、结构图示与速记钩子。读完你应能正确、地道地选用这四种类型,并看清它们彼此的关系。文末附核心速记、易错 Checklist 与自测题。
目录
第 2 章 复合数据类型
2.1 数组(Array)
2.1.1 三条铁律
数组(Array)是同一种数据类型的、固定长度的序列。它与 Python/JS 里的「列表」有本质区别:
- 长度固定:定义后长度不可改变。
- 类型单一:所有元素必须是同一类型。
- 长度是类型的一部分:
[5]int与[10]int是两种完全不同的类型,不能互相赋值、不能当同一个函数的参数。
1 | var a [5]int // 声明一个长度为 5 的 int 数组,元素自动初始化为 0 |
这里的 5 必须是常量,不能是运行时变量。
🔑 速记:数组 = 固定长度 + 同类型 + 长度进类型。记住这三条,后面所有反直觉行为都从这里来。
2.1.2 四种初始化
1 | var arr0 = [5]int{1, 2, 3} // [1 2 3 0 0],未赋值补零 |
要点:未显式赋值的元素获得零值(int=0、string=””、bool=false、指针=nil);... 是语法糖,让编译器数长度;{2: 100} 适合稀疏初始化。
2.1.3 长度是类型的一部分
1 | var a [5]int |
这意味着数组不能动态扩容,是它「不如切片常用」的根本原因。
2.1.4 零值
声明时所有元素自动初始化为零值,不会出现「未定义」:
1 | var a [3]int |
好处:数组总是干净的,可直接用 == 比较(见 2.1.7)。
2.1.5 下标与越界
1 | a := [5]int{10, 20, 30, 40, 50} |
下标从 0 到 len(a)-1,越界在运行时 panic。
⚠️ 注意:越界未必在编译期报错,务必自己守住边界。
2.1.6 值类型:整体复制
赋值或传参会复制整份数组,而非传引用:
1 | func test(x [2]int) { x[1] = 1000 } |
大数组拷贝开销大,实际开发优先用切片或数组指针 *[5]int。
2.1.7 数组比较
数组内存总被初始化过,可整体比较:
1 | a := [3]int{1, 2, 3} |
要求类型完全相同(长度也要一致),否则编译报错。
2.1.8 指针数组 vs 数组指针
1 | var p [3]*int // 指针数组:每个元素是 *int |
想修改原数组,传 *[5]int 即可避免拷贝:
1 | func modify(arr *[5]int) { arr[0] = 999 } |
🔑 速记:
[3]*int装的是「一堆指针」;*[3]int是「指向整个数组的指针」——看星号贴着谁。
2.1.9 多维数组
1 | a := [2][3]int{{1, 2, 3}, {4, 5, 6}} |
只有第一维可用 ... 推断长度。
2.1.10 遍历
1 | a := [5]int{10, 20, 30, 40, 50} |
2.1.11 作为函数参数
1 | func printArr(arr *[5]int) { // 写法 A:数组指针 |
2.2 切片(Slice)
2.2.1 底层结构
切片是数组的「动态视图」,本身不是数组,而是个引用类型。它的底层是一个三元组结构体:
1 | type slice struct { |
len 决定你「能看到多少」,cap 决定你「还能 append 多少而不会另起炉灶」。
1 | flowchart LR |
🔑 速记:切片 = 指针 + 长度 + 容量。你拿到的切片变量只是「头部」,真正的数据在别处的底层数组里。
2.2.2 三种创建方式
1 | s1 := make([]int, 3, 5) // len=3, cap=5,元素为零值 |
2.2.3 长度与容量
1 | s := []int{1, 2, 3, 4, 5} |
重新切片(reslice)会扩大 cap 的可视窗,但底层数组仍是同一块。
2.2.4 append 与扩容
1 | s := []int{1, 2, 3} |
append 可能触发扩容:当 len == cap 时,Go 会分配一块更大的底层数组(通常以约 2 倍或 1.25 倍增长),原切片指向新数组,旧底层数组若无人引用则被回收。
💡 提示:
append一定要接回返回值(s = append(s, ...)),因为扩容后s的头部可能已指向新数组。
2.2.5 共享底层数组的陷阱
切片之间可能共享底层数组,误改会「牵一发而动全身」:
1 | a := []int{1, 2, 3, 4} |
更隐蔽的 append 陷阱:容量足够时不分配新数组,会污染原切片——
1 | a := make([]int, 2, 4) // [0 0],cap=4 |
1 | flowchart LR |
避免污染的办法:用 copy 产出独立切片(见 2.2.8),或用全切片表达式 a[i:j:k] 收紧 cap。
⚠️ 注意:这是 Go 切片最高频的 bug 来源,面试必问。
2.2.6 遍历
1 | s := []int{10, 20, 30} |
2.2.7 作为函数参数
切片头部很小(指针+len+cap),传参几乎零拷贝,日常首选:
1 | func sum(s []int) int { |
注意:函数内 append 导致扩容时,原调用方的切片不会跟着变(因为是新底层数组);要改调用方的切片,需返回 append 的结果。
2.2.8 深拷贝 copy
copy(dst, src) 把 src 的元素复制到 dst,返回复制的个数;复制多少算多少,不会自动扩容:
1 | src := []int{1, 2, 3} |
这样 dst 是独立的,改它不影响 src。
2.2.9 nil 切片与空切片
1 | var n []int // nil 切片:ptr 为 nil,len=cap=0 |
两者 len 都是 0,range 都能正常遍历;通常不必区分,JSON 序列化时 nil 输出 null、空切片输出 []。
🔑 速记:切片是引用类型,传参零拷贝、要防共享污染;
copy出独立副本,append记得接返回值。
2.3 映射 Map
2.3.1 引用类型的哈希表
map[K]V 是键值对的无序集合,K 必须可比较(如 int、string、指针、可比较结构体),V 任意类型。它是引用类型,底层是哈希表。
2.3.2 创建与初始化
1 | m1 := make(map[string]int) // 空 map,可写 |
2.3.3 增删改查
1 | m := make(map[string]int) |
查不存在的 key 不会报错,返回 V 的零值(如 int 为 0),需用 ok-idiom 区分。
2.3.4 键存在性(ok-idiom)
1 | v, ok := m["y"] |
🔑 速记:查 map 永远拿到「值 + 是否存在的布尔」,用
v, ok := m[k]区分「真的是 0」和「键不存在」。
2.3.5 遍历(顺序随机)
1 | for k, v := range m { |
Go 故意打乱 map 遍历顺序,不要依赖遍历次序。
2.3.6 是引用类型
map 作为函数参数时,传的是引用(hash 表指针)。函数内增删改会影响原 map:
1 | func add(m map[string]int, k string, v int) { m[k] = v } |
2.3.7 nil Map vs 空 Map
1 | var m map[string]int // nil map |
nil map 只能读(返回零值)不能写;空 map(m := map[string]int{})可读可写。
⚠️ 注意:这是初学者最常踩的 panic——没
make就往 map 里写。
2.4 结构体(Struct)
2.4.1 字段集合
结构体把若干不同类型的字段聚合成一个自定义类型,是 Go 面向对象(数据建模)的基础:
1 | type Point struct { |
2.4.2 定义与实例化
1 | type User struct { |
2.4.3 字段访问与零值
1 | u := User{Name: "Tom", Age: 18} |
2.4.4 结构体比较
当所有字段都可比较时,结构体可用 ==/!= 整体比较;含 slice、map、func 等不可比较字段的结构体则不能比较:
1 | a := User{Name: "Tom", Age: 18} |
2.4.5 结构体嵌入
Go 用「匿名字段」实现组合(类似继承但非继承),外层可直接访问内层「提升字段」:
1 | type Animal struct { Name string } |
2.4.6 作为函数参数
结构体是值类型,传参会整体复制。想修改原结构体或省拷贝,传指针:
1 | func grow(u *User) { u.Age++ } // 指针接收,原地改 |
2.4.7 结构体标签 tag
tag 是附在字段后的元数据字符串,常被 encoding/json、gorm 等反射库读取:
1 | type User struct { |
序列化时字段名会按 tag 映射为 name / age。
🔑 速记:结构体是值类型,传参想改原值就传
*User;含 slice/map 字段的结构体不能用==比较;tag 给反射库用。
2.5 综合自测题
① 切片去重
给定 s := []int{1, 2, 2, 3, 3, 3},用 map[int]bool 辅助,返回去重后的切片 [1 2 3](顺序不限)。
② 结构体切片求和
定义 type Item struct { Price int },用 []Item{{10},{20},{30}} 求总价 60。
③ 统计词频
给定 words := []string{"go","go","python","go","java"},用 map[string]int 统计每个词出现次数,输出 go:3 python:1 java:1。
④ 共享陷阱辨析
写出下面代码的输出并解释原因:
1 | a := make([]int, 2, 4) |
⑤ 数组 vs 切片
判断正误并说明:
- (a)
[3]int与[4]int可以互相赋值。 - (b) 切片传参比同长度数组传参更省内存。
- (c)
nilmap 可以读取不存在的 key。
参考答案
①
1 | func unique(s []int) []int { |
②
1 | type Item struct{ Price int } |
③
1 | words := []string{"go", "go", "python", "go", "java"} |
④ 输出 [0 9]。b := a[:1] 与 a 共享底层数组(cap=4);append(b, 9) 时容量足够,未触发扩容,直接写入 a[1],因此原数组 a 被改为 [0 9]。
⑤
- (a) ❌ 错误。长度是类型的一部分,
[3]int与[4]int是不同类型,不能赋值。 - (b) ✅ 正确。切片头部仅指针+len+cap 三个机器字,数组传参会复制全部元素。
- (c) ✅ 正确。读 nil map 返回零值不 panic,只有写入才 panic。
2.6 本章小结
核心速记
数组 = 固定长度 + 长度进类型 + 值类型整体复制;切片 = 动态视图(ptr/len/cap)+ 引用类型 + 防共享污染;Map = 引用类型哈希表 + 必须先 make 才能写 + 遍历顺序随机;结构体 = 值类型 + 传指针改原值 + tag 给反射用。
值/引用一句话:数组、结构体是值类型(复制);切片、Map 是引用类型(共享底层)。
易错 Checklist
- 数组
b = a前先确认长度一致(不同类型会编译报错) - 切片
append一定接回返回值 - 切片 reslice 后修改,确认是否要
copy隔离,避免污染原切片 - Map 写入前必须
make或字面量初始化,否则 panic - 查 Map 用
v, ok := m[k]区分「零值」与「不存在」 - 不要依赖 Map 遍历顺序
- 结构体含 slice/map 字段时不能用
==比较 - 想改原结构体/省拷贝,函数参数传
*User而非User
四类型对比速查
| 类型 | 是否引用类型 | 长度 | 可比较 | 典型用途 |
|---|---|---|---|---|
| 数组 Array | 否(值类型) | 固定,属类型一部分 | 是(类型需相同) | 固定大小、底层结构 |
| 切片 Slice | 是(视图) | 动态,可 append | 否 | 绝大多数序列场景 |
| Map | 是(哈希表引用) | 动态 | 否 | 键值查找 |
| 结构体 Struct | 否(值类型) | 固定字段 | 字段全可比较时才能 | 数据建模、组合 |
2.7 常见疑问解答(FAQ)
本节汇总学习本章时最容易产生疑问的几个点,作为正文补充。
Q1.「nil 切片(可读不可写)」与空切片到底差在哪?
var n []int 是 nil 切片(n == nil,没有底层数组);e := []int{} 是空切片(非 nil,已分配底层数组)。两者 len 都是 0,range 都能正常遍历。
⚠️ 注意:「可读不可写」这个说法对切片不准确——它真正描述的是 nil map(写入才 panic)。切片无论 nil 还是空:都能
range读、都能append(append 对 nil 自动分配)、下标写s[0]=1两种都 panic(因长度 0,与是否 nil 无关)。
两者真正的区别只有两点:
== nil判断不同(nil 切片为true,空切片为false)。- JSON 序列化:nil →
"null",空切片 →"[]"(写 API 想返回[]时用[]int{}或make([]int, 0))。
Q2. 为什么类型后面要写 {}?
{} 是复合字面量,表示「就地创建一个该类型的值」。[]int 只是类型名(不能当值用),加 {} 才变成值:[]int{} 空切片、[]int{1,2,3} 带值。声明用 var s []int(零值 nil,不用 {});短声明 s := []int{} 必须 {} 才有值。数组 / map / struct 同理:[3]int{1,2,3}、map[string]int{}、struct{}{}。
Q3. 数组和切片声明有什么异同?有哪些坑?
声明对照见下表;本质差异三条:① 数组长度参与类型([3]int≠[4]int),切片类型只由元素类型决定(装多少都同类型);② 数组值类型整体拷贝,切片只拷贝头部、共享底层;③ 数组定长不能 append,切片可动态增长。
| 目的 | 数组 Array | 切片 Slice |
|---|---|---|
| 零值声明 | var a [3]int → [0 0 0] |
var s []int → nil |
| 字面量(空) | — | s := []int{} → 非 nil 空切片 |
| 字面量(带值) | a := [3]int{1,2,3} |
s := []int{1,2,3} |
| 编译器数长度 | a := [...]int{1,2,3} |
切片长度天然动态 |
| 指定容量 | 无 | s := make([]int, 3, 5) |
| 下标初始化 | a := [5]int{2:100} |
切片不支持 {2:100} |
高频踩坑:切片共享底层数组、改一个连累另一个(2.2.5);append 必须接回返回值(2.2.4);range 的 v 是副本、改不到原切片;大数组传参整体拷贝有性能隐患;nil/空切片 JSON 差异;切片截取「吊住」大数组导致内存泄漏(small = append([]int{}, big[:10]...) 断开)。
Q4. 切片类型只由元素类型决定吗?为什么 []int 与 []string 不能互赋,而长度却无关?
元素类型正是切片类型的一部分:[]int、[]string、[]float64 元素类型不同即不同类型,不能互赋。
之前「切片类型永远是
[]int」的说法有歧义——它只表示「长度不参与切片类型」:数组[3]int与[4]int因长度不同而类型不同,但[]int无论装 0 个还是 100 个元素都是同一类型。结论:切片类型必须带元素类型,只是长度不参与类型——这是和数组最大的区别。
Q5. map 取值 v, ok := m[k] 的 ok-idiom 是什么,为什么能区分「不存在」与「零值」?
问题根源:map 取一个不存在的 key 不会报错,而是返回 value 类型的零值。于是对 m := map[string]int{"age": 0},m["age"] 与 m["name"](name 不存在)都返回 0——你无法区分「key 存在、值恰为零值」和「key 不存在、拿零值凑数」。
ok-idiom 解法:Go 给 map 取值提供第二个返回值 ok(bool),表示 key 是否真实存在:
1 | m := map[string]int{"age": 0} |
速记:
value, ok :=这种「逗号 + bool 第二返回值」是 Go 通用的「成功/存在」惯用法,不只在 map 出现——类型断言v, ok := i.(T)、channel 接收v, ok := <-ch同款套路。map 里ok的含义是「key 是否存在」。
下一章(三)将进入「函数与错误处理」,讲多返回值、defer、error 与 panic/recover。
附:重点速记卡(交互预览)
下面是为本章配套的「重点速记卡」HTML——只提炼四类型定位、铁律、三大陷阱与对比速查表,适合复习速记;完整推导见上文正文。



