摘要:本文是 Go 语言学习系列的第二篇,覆盖**第 2 章(复合数据类型)**的全部四种类型——数组(Array)切片(Slice)映射(Map)结构体(Struct)。每种类型都从「是什么、怎么声明与初始化、底层语义(值类型还是引用类型)、怎么遍历、怎么当函数参数、有哪些常见陷阱」展开,配有可运行代码、对比表格、结构图示与速记钩子。读完你应能正确、地道地选用这四种类型,并看清它们彼此的关系。文末附核心速记、易错 Checklist 与自测题。

目录


第 2 章 复合数据类型


2.1 数组(Array)

2.1.1 三条铁律

数组(Array)是同一种数据类型的、固定长度的序列。它与 Python/JS 里的「列表」有本质区别:

  1. 长度固定:定义后长度不可改变。
  2. 类型单一:所有元素必须是同一类型。
  3. 长度是类型的一部分[5]int[10]int两种完全不同的类型,不能互相赋值、不能当同一个函数的参数。
1
var a [5]int   // 声明一个长度为 5 的 int 数组,元素自动初始化为 0

这里的 5 必须是常量,不能是运行时变量。

🔑 速记:数组 = 固定长度 + 同类型 + 长度进类型。记住这三条,后面所有反直觉行为都从这里来。

2.1.2 四种初始化

1
2
3
4
5
var arr0 = [5]int{1, 2, 3}              // [1 2 3 0 0],未赋值补零
var arr2 = [...]int{1, 2, 3, 4, 5, 6} // 用 ... 推断长度,类型 [6]int
var c = [5]int{2: 100, 4: 200} // 指定下标:[0 0 100 0 200]
a := [3]int{1, 2} // 局部短声明:[1 2 0]
b := [...]int{1, 2, 3, 4} // [1 2 3 4],类型 [4]int

要点:未显式赋值的元素获得零值(int=0、string=””、bool=false、指针=nil);... 是语法糖,让编译器数长度;{2: 100} 适合稀疏初始化。

2.1.3 长度是类型的一部分

1
2
3
var a [5]int
var b [10]int
b = a // ❌ 编译错误:[5]int 与 [10]int 类型不同

这意味着数组不能动态扩容,是它「不如切片常用」的根本原因。

2.1.4 零值

声明时所有元素自动初始化为零值,不会出现「未定义」:

1
2
var a [3]int
fmt.Println(a) // [0 0 0]

好处:数组总是干净的,可直接用 == 比较(见 2.1.7)。

2.1.5 下标与越界

1
2
3
a := [5]int{10, 20, 30, 40, 50}
fmt.Println(a[0], a[4]) // 10 50
fmt.Println(a[5]) // ❌ panic: index out of range

下标从 0len(a)-1,越界在运行时 panic

⚠️ 注意:越界未必在编译期报错,务必自己守住边界。

2.1.6 值类型:整体复制

赋值或传参会复制整份数组,而非传引用:

1
2
3
4
5
6
func test(x [2]int) { x[1] = 1000 }
func main() {
a := [2]int{1, 2}
test(a)
fmt.Println(a) // [1 2],原数组没变
}

大数组拷贝开销大,实际开发优先用切片或数组指针 *[5]int

2.1.7 数组比较

数组内存总被初始化过,可整体比较:

1
2
3
a := [3]int{1, 2, 3}
b := [3]int{1, 2, 3}
fmt.Println(a == b) // true

要求类型完全相同(长度也要一致),否则编译报错。

2.1.8 指针数组 vs 数组指针

1
2
var p [3]*int    // 指针数组:每个元素是 *int
var q *[3]int // 数组指针:指向整个 [3]int 的指针

想修改原数组,传 *[5]int 即可避免拷贝:

1
2
3
4
5
6
func modify(arr *[5]int) { arr[0] = 999 }
func main() {
a := [5]int{1, 2, 3, 4, 5}
modify(&a)
fmt.Println(a) // [999 2 3 4 5]
}

🔑 速记[3]*int 装的是「一堆指针」;*[3]int 是「指向整个数组的指针」——看星号贴着谁。

2.1.9 多维数组

1
2
3
a := [2][3]int{{1, 2, 3}, {4, 5, 6}}
b := [...][2]int{{1, 1}, {2, 2}, {3, 3}} // ✅ 第一维可用 ...
// c := [2][...]int{{1,2},{3,4}} // ❌ 第二维不能用 ...

只有第一维可用 ... 推断长度。

2.1.10 遍历

1
2
3
4
a := [5]int{10, 20, 30, 40, 50}
for i := 0; i < len(a); i++ { fmt.Println(i, a[i]) } // 经典 for
for i, v := range a { fmt.Println(i, v) } // range
for _, v := range a { fmt.Println(v) } // 只要值用 _ 占位

2.1.11 作为函数参数

1
2
3
4
5
6
7
8
func printArr(arr *[5]int) {            // 写法 A:数组指针
arr[0] = 10
}
func sumSlice(s []int) int { // 写法 B:更常见——切片
total := 0
for _, v := range s { total += v }
return total
}

2.2 切片(Slice)

2.2.1 底层结构

切片是数组的「动态视图」,本身不是数组,而是个引用类型。它的底层是一个三元组结构体:

1
2
3
4
5
type slice struct {
ptr *Element // 指向底层数组的某个位置
len int // 当前长度(可访问元素数)
cap int // 容量(从 ptr 到底层数组末尾的元素数)
}

len 决定你「能看到多少」,cap 决定你「还能 append 多少而不会另起炉灶」。

1
2
3
4
5
6
7
8
9
10
flowchart LR
subgraph Head["切片头部(值类型,很小)"]
P["ptr"]
L["len = 3"]
C["cap = 5"]
end
subgraph Arr["底层数组(匿名,被引用)"]
E0["a"]; E1["b"]; E2["c"]; E3["d"]; E4["e"]
end
P --> E0

🔑 速记:切片 = 指针 + 长度 + 容量。你拿到的切片变量只是「头部」,真正的数据在别处的底层数组里。

2.2.2 三种创建方式

1
2
3
s1 := make([]int, 3, 5)   // len=3, cap=5,元素为零值
s2 := []int{1, 2, 3} // 字面量,len=cap=3
s3 := arr[1:4] // 从数组/切片切取,共享底层数组

2.2.3 长度与容量

1
2
3
4
s := []int{1, 2, 3, 4, 5}
fmt.Println(len(s), cap(s)) // 5 5
t := s[1:3] // [2 3]
fmt.Println(len(t), cap(t)) // 2 4(cap 从 t[0] 到底层数组末尾)

重新切片(reslice)会扩大 cap 的可视窗,但底层数组仍是同一块。

2.2.4 append 与扩容

1
2
3
4
5
6
s := []int{1, 2, 3}
s = append(s, 4) // [1 2 3 4]
s = append(s, 5, 6) // [1 2 3 4 5 6]

var x []int // nil 切片也能直接 append
x = append(x, 1) // [1]

append 可能触发扩容:当 len == cap 时,Go 会分配一块更大的底层数组(通常以约 2 倍或 1.25 倍增长),原切片指向新数组,旧底层数组若无人引用则被回收

💡 提示append 一定要接回返回值(s = append(s, ...)),因为扩容后 s 的头部可能已指向新数组。

2.2.5 共享底层数组的陷阱

切片之间可能共享底层数组,误改会「牵一发而动全身」:

1
2
3
4
a := []int{1, 2, 3, 4}
b := a[1:3] // b = [2 3],与 a 共享底层
b[0] = 99
fmt.Println(a) // [1 99 3 4] ← a 被改了!

更隐蔽的 append 陷阱:容量足够时不分配新数组,会污染原切片——

1
2
3
4
a := make([]int, 2, 4)  // [0 0],cap=4
b := a[:1] // [0],cap=4
b = append(b, 9) // 容量够,共享底层
fmt.Println(a) // [0 9] ← 原数组被改!
1
2
3
4
5
6
7
8
9
flowchart LR
subgraph Arr["底层数组 [0 0 0 0]"]
e0["0"]; e1["0"]; e2["0"]; e3["0"]
end
A["a: ptr→e0, len=2, cap=4"]
B["b = a[:1]: ptr→e0, len=1, cap=4"]
A --> e0
B --> e0
note["append(b,9) 后写入 e1,a 跟着变"]

避免污染的办法:用 copy 产出独立切片(见 2.2.8),或用全切片表达式 a[i:j:k] 收紧 cap。

⚠️ 注意:这是 Go 切片最高频的 bug 来源,面试必问。

2.2.6 遍历

1
2
3
s := []int{10, 20, 30}
for i, v := range s { fmt.Println(i, v) }
for _, v := range s { fmt.Println(v) }

2.2.7 作为函数参数

切片头部很小(指针+len+cap),传参几乎零拷贝,日常首选

1
2
3
4
5
func sum(s []int) int {
t := 0
for _, v := range s { t += v }
return t
}

注意:函数内 append 导致扩容时,原调用方的切片不会跟着变(因为是新底层数组);要改调用方的切片,需返回 append 的结果。

2.2.8 深拷贝 copy

copy(dst, src)src 的元素复制到 dst,返回复制的个数;复制多少算多少,不会自动扩容:

1
2
3
src := []int{1, 2, 3}
dst := make([]int, len(src))
n := copy(dst, src) // n = 3

这样 dst 是独立的,改它不影响 src

2.2.9 nil 切片与空切片

1
2
3
var n []int     // nil 切片:ptr 为 nil,len=cap=0
e := []int{} // 空切片:非 nil,len=cap=0
fmt.Println(n == nil, e == nil) // true false

两者 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
2
3
4
5
m1 := make(map[string]int)        // 空 map,可写
m2 := map[string]int{ // 字面量
"a": 1,
"b": 2,
}

2.3.3 增删改查

1
2
3
4
m := make(map[string]int)
m["x"] = 10 // 增/改
v := m["x"] // 查,v = 10
delete(m, "x") // 删

查不存在的 key 不会报错,返回 V 的零值(如 int 为 0),需用 ok-idiom 区分。

2.3.4 键存在性(ok-idiom)

1
2
3
4
5
6
v, ok := m["y"]
if ok {
fmt.Println("存在,值 =", v)
} else {
fmt.Println("不存在,v 是零值", v) // 0
}

🔑 速记:查 map 永远拿到「值 + 是否存在的布尔」,用 v, ok := m[k] 区分「真的是 0」和「键不存在」。

2.3.5 遍历(顺序随机)

1
2
3
for k, v := range m {
fmt.Println(k, v)
}

Go 故意打乱 map 遍历顺序,不要依赖遍历次序。

2.3.6 是引用类型

map 作为函数参数时,传的是引用(hash 表指针)。函数内增删改会影响原 map:

1
2
3
4
5
6
func add(m map[string]int, k string, v int) { m[k] = v }
func main() {
m := map[string]int{}
add(m, "a", 1)
fmt.Println(m) // map[a:1]
}

2.3.7 nil Map vs 空 Map

1
2
3
4
var m map[string]int    // nil map
// m["x"] = 1 // ❌ panic: assignment to entry in nil map
m = make(map[string]int)
m["x"] = 1 // ✅ 初始化后才能写

nil map 只能读(返回零值)不能写;空 map(m := map[string]int{})可读可写。

⚠️ 注意:这是初学者最常踩的 panic——没 make 就往 map 里写。


2.4 结构体(Struct)

2.4.1 字段集合

结构体把若干不同类型的字段聚合成一个自定义类型,是 Go 面向对象(数据建模)的基础:

1
2
3
4
type Point struct {
X int
Y int
}

2.4.2 定义与实例化

1
2
3
4
5
6
7
8
9
10
type User struct {
Name string
Age int
}

u1 := User{Name: "Tom", Age: 18} // 字段名初始化(推荐)
u2 := User{"Tom", 18} // 按顺序初始化
u3 := User{} // 零值:Name="", Age=0
var u4 User // 同样零值
p := &User{Name: "Tom"} // 取指针

2.4.3 字段访问与零值

1
2
3
4
5
u := User{Name: "Tom", Age: 18}
fmt.Println(u.Name) // Tom
u.Age = 20 // 修改
p := &u
fmt.Println(p.Age) // 20,指针也能用 . 访问(自动解引用)

2.4.4 结构体比较

所有字段都可比较时,结构体可用 ==/!= 整体比较;含 slice、map、func 等不可比较字段的结构体则不能比较:

1
2
3
a := User{Name: "Tom", Age: 18}
b := User{Name: "Tom", Age: 18}
fmt.Println(a == b) // true

2.4.5 结构体嵌入

Go 用「匿名字段」实现组合(类似继承但非继承),外层可直接访问内层「提升字段」:

1
2
3
4
5
6
7
type Animal struct { Name string }
type Dog struct {
Animal // 匿名嵌入
Age int
}
d := Dog{Animal{"旺财"}, 3}
fmt.Println(d.Name) // "旺财"(提升字段,等价于 d.Animal.Name)

2.4.6 作为函数参数

结构体是值类型,传参会整体复制。想修改原结构体或省拷贝,传指针:

1
2
3
4
5
6
func grow(u *User) { u.Age++ }   // 指针接收,原地改
func main() {
u := User{Name: "Tom", Age: 18}
grow(&u)
fmt.Println(u.Age) // 19
}

2.4.7 结构体标签 tag

tag 是附在字段后的元数据字符串,常被 encoding/jsongorm 等反射库读取:

1
2
3
4
type User struct {
Name string `json:"name"`
Age int `json:"age"`
}

序列化时字段名会按 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
2
3
4
a := make([]int, 2, 4)
b := a[:1]
b = append(b, 9)
fmt.Println(a)

⑤ 数组 vs 切片
判断正误并说明:

  • (a) [3]int[4]int 可以互相赋值。
  • (b) 切片传参比同长度数组传参更省内存。
  • (c) nil map 可以读取不存在的 key。
参考答案

1
2
3
4
5
6
7
8
9
10
11
func unique(s []int) []int {
seen := make(map[int]bool)
out := []int{}
for _, v := range s {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}

1
2
3
4
5
type Item struct{ Price int }
items := []Item{{10}, {20}, {30}}
total := 0
for _, it := range items { total += it.Price }
fmt.Println(total) // 60

1
2
3
4
words := []string{"go", "go", "python", "go", "java"}
count := make(map[string]int)
for _, w := range words { count[w]++ }
for k, v := range count { fmt.Printf("%s:%d ", k, v) }

④ 输出 [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 []intnil 切片n == nil,没有底层数组);e := []int{}空切片(非 nil,已分配底层数组)。两者 len 都是 0,range 都能正常遍历。

⚠️ 注意:「可读不可写」这个说法对切片不准确——它真正描述的是 nil map(写入才 panic)。切片无论 nil 还是空:都能 range 读、都能 append(append 对 nil 自动分配)、下标写 s[0]=1 两种都 panic(因长度 0,与是否 nil 无关)。

两者真正的区别只有两点

  1. == nil 判断不同(nil 切片为 true,空切片为 false)。
  2. 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 []intnil
字面量(空) 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);rangev 是副本、改不到原切片;大数组传参整体拷贝有性能隐患;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
2
3
4
5
6
7
8
9
10
11
m := map[string]int{"age": 0}

v1 := m["age"] // v1 = 0(分不清是存在还是不存在)
v, ok := m["age"] // v=0, ok=true → key 存在,值就是 0
v, ok := m["name"] // v=0, ok=false → key 不存在

if v, ok := m["age"]; ok {
fmt.Println("age 存在,值为", v)
} else {
fmt.Println("age 不存在,使用默认值")
}

速记value, ok := 这种「逗号 + bool 第二返回值」是 Go 通用的「成功/存在」惯用法,不只在 map 出现——类型断言 v, ok := i.(T)、channel 接收 v, ok := <-ch 同款套路。map 里 ok 的含义是「key 是否存在」。

下一章(三)将进入「函数与错误处理」,讲多返回值、defer、error 与 panic/recover。


附:重点速记卡(交互预览)

下面是为本章配套的「重点速记卡」HTML——只提炼四类型定位、铁律、三大陷阱与对比速查表,适合复习速记;完整推导见上文正文。