在 Go 语言里,除了整数、浮点数、布尔值这些基础数据类型之外,我们还会频繁使用一类更适合组织数据的类型——复合数据类型。所谓“复合”,可以简单理解为:它们不是只存一个值,而是能把多个值按照某种规则组合起来。
这一章我们重点学习四个非常核心的内容:数组、切片、映射,以及字符串在底层的表示方式。它们几乎贯穿了所有 Go 项目的日常开发。
如果你已经有其他编程语言经验,可以把它们先粗略类比为:
| 类型 | 可以类比成什么 | Go 中的特点 |
|---|---|---|
| Array | 定长数组 | 长度是类型的一部分,创建后不能改变长度 |
| Slice | 动态数组视图 | 最常用,基于底层数组实现,支持扩容 |
| Map | 哈希表 / 字典 | 键值对存储,查找效率高 |
| String | 字符序列 | 底层是字节序列,处理中文时要特别注意 rune |
理解这些类型,不只是为了“会用”,更重要的是知道它们为什么这样设计,以及在什么场景下应该选哪一种。
一、数组(Array):定长容器
数组是 Go 中最基础的复合数据类型之一。它表示一组长度固定、元素类型相同的数据。
数组的声明方式如下:
var arr [5]int
这表示定义了一个长度为 5、元素类型为 int 的数组。数组中的每个元素都会自动初始化为该类型的零值,这里就是 0。
1. 数组的特点
数组有几个关键点必须记住:
- 长度固定:一旦定义,长度不能改变。
- 元素类型一致:一个数组里所有元素必须是同一类型。
- 长度属于类型的一部分:
[3]int和[5]int是两种不同的类型。 - 数组是值类型:赋值或传参时会复制整个数组。
这和很多语言中的“数组”并不完全一样。在 Go 里,数组更偏向底层、稳定、明确的容器。
2. 数组的声明与初始化
下面的例子展示了数组的几种常见写法:
package main
import "fmt"
func main() {
var nums [5]int
fmt.Println("默认值数组:", nums)
letters := [3]string{"A", "B", "C"}
fmt.Println("完整初始化:", letters)
scores := [5]int{0: 100, 2: 95, 4: 88}
fmt.Println("按索引初始化:", scores)
auto := [...]int{10, 20, 30, 40}
fmt.Println("自动推导长度:", auto)
fmt.Println("数组长度:", len(auto))
}
运行结果中,你会看到未赋值的位置自动补零,而 ... 可以让编译器根据初始化元素的个数推导数组长度。
3. 访问和遍历数组
数组支持通过下标访问元素,下标从 0 开始。也可以使用 for 和 range 遍历。
package main
import "fmt"
func main() {
nums := [4]int{10, 20, 30, 40}
fmt.Println("第一个元素:", nums[0])
nums[1] = 99
fmt.Println("修改后的数组:", nums)
fmt.Println("使用普通 for 遍历:")
for i := 0; i < len(nums); i++ {
fmt.Printf("index=%d, value=%d\n", i, nums[i])
}
fmt.Println("使用 range 遍历:")
for index, value := range nums {
fmt.Printf("index=%d, value=%d\n", index, value)
}
}
4. 数组是值类型
这是初学 Go 时非常容易忽略的一点。数组赋值时,不是“共用同一份数据”,而是完整复制一份。
package main
import "fmt"
func main() {
a := [3]int{1, 2, 3}
b := a
b[0] = 100
fmt.Println("a:", a)
fmt.Println("b:", b)
}
输出结果会说明:修改 b 不会影响 a。
这说明数组适合长度明确、数据规模较小、并且希望复制行为明确的场景。但在实际开发中,数组直接使用得并不算多,更多时候我们会使用切片。
5. 数组适合什么场景
数组虽然不如切片灵活,但并不是“没用”。它适合:
- 长度固定且明确的数据
- 需要编译期确定大小的数据结构
- 作为切片底层存储的基础
- 某些性能敏感、内存布局明确的场景
二、切片(Slice):动态数组的底层原理与常用操作
切片是 Go 中最常用的复合数据类型。你可以把它理解成对数组的一层灵活封装。它提供了类似“动态数组”的使用体验,但底层仍然依赖数组。
Go 日常开发里,大部分“数组式操作”其实都在用切片。
1. 切片是什么
切片本身并不直接存储所有元素,它更像一个“描述符”,内部记录了三部分信息:
- 指向底层数组的指针
- 当前长度
len - 当前容量
cap
其中:
len表示当前切片中可直接访问的元素个数cap表示从切片起始位置到底层数组末尾,最多还能容纳多少元素
可以先看一张对比表:
| 对比项 | 数组 | 切片 |
|---|---|---|
| 长度是否固定 | 固定 | 不固定 |
| 是否可扩容 | 不可扩容 | 可通过 append 扩容 |
| 是否常用 | 相对较少 | 非常常用 |
| 底层存储 | 自己直接存数据 | 引用底层数组 |
2. 切片的创建方式
常见方式有三种:
方式一:基于数组切出一段
package main
import "fmt"
func main() {
arr := [5]int{10, 20, 30, 40, 50}
s1 := arr[1:4]
fmt.Println("s1:", s1)
fmt.Println("len(s1):", len(s1))
fmt.Println("cap(s1):", cap(s1))
}
这里 s1 := arr[1:4] 表示取下标 1 到 3 的元素,也就是左闭右开区间。
len(s1) 为 3,因为切片中有 3 个元素。
cap(s1) 为 4,因为从下标 1 开始,到底层数组末尾一共有 4 个位置可用:20、30、40、50。
方式二:直接字面量创建
package main
import "fmt"
func main() {
nums := []int{1, 2, 3, 4}
fmt.Println(nums)
fmt.Println("len:", len(nums))
fmt.Println("cap:", cap(nums))
}
这种写法最常见,也是实际开发中最推荐的基础写法。
方式三:使用 make
package main
import "fmt"
func main() {
s1 := make([]int, 3)
s2 := make([]int, 3, 5)
fmt.Println("s1:", s1, "len=", len(s1), "cap=", cap(s1))
fmt.Println("s2:", s2, "len=", len(s2), "cap=", cap(s2))
}
make([]int, 3) 表示创建一个长度为 3、容量也为 3 的切片。
make([]int, 3, 5) 表示创建一个长度为 3、容量为 5 的切片。
当你预估后续会追加元素时,提前设置合适的容量,能减少扩容次数。
3. 切片与底层数组的关系
这是理解切片的关键。
多个切片可能引用同一个底层数组,因此修改一个切片中的元素,可能会影响另一个切片,甚至影响原数组。
package main
import "fmt"
func main() {
arr := [5]int{10, 20, 30, 40, 50}
s1 := arr[1:4]
s2 := arr[2:5]
s1[1] = 999
fmt.Println("arr:", arr)
fmt.Println("s1:", s1)
fmt.Println("s2:", s2)
}
为什么会这样?
因为 s1[1] 实际对应的是 arr[2]。而 s2 也引用到了这个位置,所以它看到的值也会变化。
这就是切片“共享底层数组”的特性。它带来了高效,也带来了需要小心的地方。
4. len 和 cap 的直观理解
我们通过一个例子进一步观察:
package main
import "fmt"
func main() {
data := [6]int{10, 20, 30, 40, 50, 60}
s := data[2:4]
fmt.Println("slice:", s)
fmt.Println("len:", len(s))
fmt.Println("cap:", cap(s))
}
这里:
s的内容是[30 40]len(s)是 2cap(s)是 4
原因是:切片从 data[2] 开始,所以容量从位置 2 一直算到底层数组末尾,一共有 4 个元素:30、40、50、60。
可以总结为:
len看“当前能直接用多少个元素”cap看“在不换底层数组的前提下,最多还能扩展到多大”
5. append:向切片追加元素
切片最常见的操作就是 append。
package main
import "fmt"
func main() {
nums := []int{1, 2, 3}
nums = append(nums, 4)
nums = append(nums, 5, 6)
fmt.Println(nums)
fmt.Println("len:", len(nums))
fmt.Println("cap:", cap(nums))
}
注意:append 返回的是新的切片,因此通常要写成:
nums = append(nums, 4)
而不是只写 append(nums, 4)。
6. append 背后的扩容机制
这是切片部分最重要的知识点之一。
当切片追加元素时,如果当前容量够用,Go 会直接把新元素放到原底层数组后面;如果容量不够,就会申请一块更大的新数组,把旧数据复制过去,再返回指向新数组的切片。
也就是说,扩容前后,切片底层数组可能会变,也可能不会变。
下面的代码可以帮助你观察这一点:
package main
import "fmt"
func main() {
s := make([]int, 0, 2)
fmt.Printf("初始: len=%d cap=%d %v\n", len(s), cap(s), s)
s = append(s, 10)
fmt.Printf("追加1个后: len=%d cap=%d %v\n", len(s), cap(s), s)
s = append(s, 20)
fmt.Printf("追加2个后: len=%d cap=%d %v\n", len(s), cap(s), s)
s = append(s, 30)
fmt.Printf("追加3个后: len=%d cap=%d %v\n", len(s), cap(s), s)
s = append(s, 40, 50)
fmt.Printf("继续追加后: len=%d cap=%d %v\n", len(s), cap(s), s)
}
在不同 Go 版本中,容量增长的具体细节可能会有差异,但对初学者来说,最重要的是理解下面这几点:
- 容量不足时会触发扩容。
- 扩容后通常会创建新的底层数组。
- 旧数据会被复制到新数组中。
- 因此,原切片和新切片可能不再共享同一块底层数据。
所以我们平时不应该依赖“扩容一定翻倍”这种具体实现细节,而应该抓住本质:切片扩容的目标是平衡性能与内存使用。
7. 切片共享与扩容导致的差异
下面这个例子非常值得认真看,它能帮助你真正理解“共享底层数组”和“扩容后分离”之间的区别。
package main
import "fmt"
func main() {
base := []int{1, 2, 3, 4}
a := base[:2]
b := base[:2]
a = append(a, 100)
fmt.Println("第一次 append 后")
fmt.Println("base:", base)
fmt.Println("a:", a)
fmt.Println("b:", b)
a = append(a, 200, 300)
fmt.Println("第二次 append 后")
fmt.Println("base:", base)
fmt.Println("a:", a)
fmt.Println("b:", b)
}
在第一次 append 时,如果容量还够,a 可能直接修改原底层数组,因此 base 也会受到影响。
而在后续继续追加、容量不足时,a 可能触发扩容,转而指向新的底层数组。此后再修改 a,就不一定会影响 base 和 b 了。
这也是为什么在团队开发里,如果多个切片共享底层数组却没有意识到,就很容易引发难排查的问题。
8. 切片的常用操作
追加元素
package main
import "fmt"
func main() {
nums := []int{1, 2, 3}
nums = append(nums, 4, 5)
fmt.Println(nums)
}
拷贝切片
如果你不希望多个切片共享同一个底层数组,可以使用 copy。
package main
import "fmt"
func main() {
src := []int{10, 20, 30}
dst := make([]int, len(src))
copy(dst, src)
dst[0] = 999
fmt.Println("src:", src)
fmt.Println("dst:", dst)
}
删除元素(常见写法)
Go 没有内置的“删除切片某个元素”函数,通常用拼接来完成。
package main
import "fmt"
func main() {
nums := []int{10, 20, 30, 40, 50}
index := 2
nums = append(nums[:index], nums[index+1:]...)
fmt.Println(nums)
}
这里删除了下标为 2 的元素,也就是 30。
截取子切片
package main
import "fmt"
func main() {
nums := []int{10, 20, 30, 40, 50}
fmt.Println(nums[1:4])
fmt.Println(nums[:3])
fmt.Println(nums[2:])
}
9. nil 切片与空切片
这也是面试和实际开发中经常会遇到的点。
package main
import "fmt"
func main() {
var s1 []int
s2 := []int{}
fmt.Println("s1 == nil:", s1 == nil)
fmt.Println("s2 == nil:", s2 == nil)
fmt.Println("len(s1):", len(s1))
fmt.Println("len(s2):", len(s2))
}
它们看起来都“没有元素”,但并不完全一样:
| 类型 | 是否为 nil | 长度 | 常见场景 |
|---|---|---|---|
var s []int |
是 | 0 | 未初始化切片 |
[]int{} |
否 | 0 | 明确表示空集合 |
在 JSON 编码、接口返回值等场景下,这种差异可能会产生实际影响。
三、映射(Map):哈希表的使用与注意事项
Map 是一种键值对结构,可以根据键快速找到对应的值。它的底层是哈希表,因此非常适合做查找、统计、去重、索引等操作。
1. Map 的基本概念
Map 的定义形式如下:
var m map[string]int
这表示:
- 键类型是
string - 值类型是
int
可以把它理解成“从字符串映射到整数”的一张表。
2. Map 的创建方式
声明 map 后,如果没有初始化,它的零值是 nil,这时可以读取,但不能直接写入。
先看正确创建方式:
package main
import "fmt"
func main() {
ages := make(map[string]int)
ages["Alice"] = 18
ages["Bob"] = 20
fmt.Println(ages)
scores := map[string]int{
"Tom": 90,
"Jerry": 95,
}
fmt.Println(scores)
}
3. nil map 不能直接写入
这是 map 最常见的坑之一。
package main
import "fmt"
func main() {
var m map[string]int
fmt.Println("m == nil:", m == nil)
value := m["not_exist"]
fmt.Println("读取 nil map 中不存在的键:", value)
// m["test"] = 1
// 上面这行如果取消注释,会发生 panic:assignment to entry in nil map
}
也就是说:
- 读 nil map 可以,返回值类型的零值
- 写 nil map 不可以,会直接 panic
4. Map 的增删改查
package main
import "fmt"
func main() {
m := make(map[string]int)
m["apple"] = 3
m["banana"] = 5
fmt.Println("初始 map:", m)
m["apple"] = 10
fmt.Println("修改后:", m)
fmt.Println("banana 的值:", m["banana"])
delete(m, "banana")
fmt.Println("删除 banana 后:", m)
}
5. 判断键是否存在
直接通过 m[key] 取值时,如果键不存在,会返回值类型的零值。但有时你无法区分“键不存在”和“键存在但值刚好是零值”。
这时要使用 Go 的惯用写法:
package main
import "fmt"
func main() {
scores := map[string]int{
"Tom": 90,
"Lucy": 0,
}
v, ok := scores["Tom"]
fmt.Println("Tom:", v, ok)
v, ok = scores["Lucy"]
fmt.Println("Lucy:", v, ok)
v, ok = scores["Jack"]
fmt.Println("Jack:", v, ok)
}
这里:
v是取到的值ok表示键是否真实存在
这是使用 map 时非常重要的习惯。
6. 遍历 Map
Go 支持使用 range 遍历 map。
package main
import "fmt"
func main() {
m := map[string]int{
"apple": 3,
"banana": 5,
"orange": 2,
}
for key, value := range m {
fmt.Printf("key=%s, value=%d\n", key, value)
}
}
需要注意的是:map 遍历结果是无序的。你不能依赖它的遍历顺序。
如果你确实需要稳定顺序,通常应该先把键取出来放进切片,再排序后遍历。
7. Map 的使用注意事项
注意一:键必须是可比较类型
Go 的 map 键必须支持 == 和 != 比较,因此像切片、map、函数这类类型不能作为键。
可作为键的常见类型包括:
- 整数
- 字符串
- 布尔值
- 指针
- 数组
- 结构体(前提是其中字段都可比较)
注意二:Map 是引用语义
虽然 map 不是“引用类型”这个官方术语下的严格表述,但从使用效果上看,把一个 map 赋值给另一个变量,二者会指向同一份底层数据。
package main
import "fmt"
func main() {
m1 := map[string]int{"a": 1, "b": 2}
m2 := m1
m2["a"] = 100
fmt.Println("m1:", m1)
fmt.Println("m2:", m2)
}
注意三:并发读写 map 需要额外处理
普通 map 在并发场景下不是线程安全的。如果多个 goroutine 同时读写 map,可能会导致运行时错误。
这部分我们在后续并发章节还会详细讲。当前阶段你只需要记住:
- 单线程或受控访问场景下,普通 map 很方便
- 并发场景下要加锁,或者使用专门的并发安全结构
8. Map 的典型应用:词频统计
下面是一个非常经典的例子:统计单词出现次数。
package main
import "fmt"
func main() {
words := []string{"go", "java", "go", "python", "go", "java"}
counter := make(map[string]int)
for _, word := range words {
counter[word]++
}
fmt.Println(counter)
}
这个例子很好地体现了 map 在“统计”和“分组”类问题中的优势。
四、字符串底层:byte vs rune
字符串是我们几乎每天都要使用的类型,但 Go 中的字符串有一个非常值得重视的特点:字符串底层存储的是字节序列。
这会直接影响你对英文、数字、符号,尤其是中文的处理方式。
1. 字符串本质上是什么
在 Go 中,字符串可以理解为一段只读的字节序列。也就是说:
- 字符串中的内容底层是字节
- 字符串创建后不能被原地修改
- 对字符串做下标访问,拿到的是某个字节,不一定是一个完整字符
例如:
package main
import "fmt"
func main() {
s := "hello"
fmt.Println("长度:", len(s))
fmt.Println("s[1]:", s[1])
fmt.Printf("s[1] 对应字符: %c\n", s[1])
}
这里 len(s) 返回的是字节数,而不是“字符个数”。对于纯英文字符串,这两者通常相同;但遇到中文时就不同了。
2. byte 和 rune 分别是什么
在 Go 中:
byte是uint8的别名,用来表示一个字节rune是int32的别名,用来表示一个 Unicode 码点
可以简单理解为:
| 类型 | 本质 | 常见用途 |
|---|---|---|
byte |
单个字节 | 处理二进制数据、ASCII、网络数据 |
rune |
单个 Unicode 字符编码点 | 处理中文、多语言字符 |
3. 为什么中文不能按 byte 来理解
UTF-8 编码中,一个中文字符通常由多个字节组成。因此如果你按字节遍历字符串,就可能看到“字符被拆开”的情况。
package main
import "fmt"
func main() {
s := "Go语言"
fmt.Println("字节长度:", len(s))
for i := 0; i < len(s); i++ {
fmt.Printf("index=%d, byte=%d\n", i, s[i])
}
}
这段代码能运行,但它遍历的是字节,不是字符。
4. 使用 range 按 rune 遍历字符串
Go 提供了非常方便的方式来按字符遍历字符串,那就是 range。
package main
import "fmt"
func main() {
s := "Go语言"
for index, r := range s {
fmt.Printf("index=%d, rune=%c, code=%d\n", index, r, r)
}
}
这里:
index是当前字符在原字符串中的字节起始位置r是当前解析出来的rune
这也是 Go 处理 Unicode 字符串最常见、最安全的方式。
5. 字符串转为 []byte 和 []rune
根据不同需求,字符串可以转换成不同形式:
- 转成
[]byte:适合按字节处理 - 转成
[]rune:适合按字符处理
package main
import "fmt"
func main() {
s := "Go语言"
bs := []byte(s)
rs := []rune(s)
fmt.Println("[]byte:", bs)
fmt.Println("[]rune:", rs)
fmt.Println("字符个数:", len(rs))
}
如果你想正确统计“中文字符串有几个字符”,通常应该转成 []rune 后再看长度。
6. 修改字符串的正确方式
由于字符串是只读的,不能直接修改某个位置的内容。
下面这种写法是错误的:
// s[0] = 'H' // 编译错误:字符串不可变
正确做法是先转换成可修改的切片,再转换回字符串。
package main
import "fmt"
func main() {
s := "golang"
bs := []byte(s)
bs[0] = 'G'
s = string(bs)
fmt.Println(s)
name := "语言"
rs := []rune(name)
rs[0] = '术'
name = string(rs)
fmt.Println(name)
}
为什么英文示例可以用 []byte,而中文示例更适合 []rune?
因为英文字符通常是单字节,直接按字节修改不会破坏编码;而中文通常由多个字节组成,按字节改很容易把 UTF-8 编码弄坏,所以更适合先转成 []rune 再处理。
7. 一个综合示例:正确统计中英文字符
package main
import "fmt"
func main() {
s := "Hello, 世界"
fmt.Println("按字节长度:", len(s))
fmt.Println("按字符长度:", len([]rune(s)))
fmt.Println("按 rune 遍历:")
for i, r := range s {
fmt.Printf("byteIndex=%d, char=%c\n", i, r)
}
}
这个例子很典型:一个字符串既包含英文,也包含中文。你会看到“字节长度”和“字符长度”并不一致。
五、本章小结
这一章介绍了 Go 中几种最重要的复合数据类型,它们在真实开发里出现频率极高。
我们可以做一个整体回顾:
| 知识点 | 核心关键词 | 你需要重点掌握什么 |
|---|---|---|
| 数组 | 定长、值类型 | 理解长度属于类型的一部分,知道数组会整体复制 |
| 切片 | 底层数组、len、cap、append | 重点掌握共享底层数组、扩容机制和常用操作 |
| Map | 键值对、哈希表 | 会创建、增删改查、判断键是否存在,理解 nil map 限制 |
| 字符串 | byte、rune、UTF-8 | 明白字符串按字节存储,处理中文优先考虑 rune |
如果说数组是基础,那么切片就是 Go 日常开发的主力;如果说 map 解决的是高效组织和查找数据的问题,那么字符串的 byte 与 rune 则直接决定了你能否正确处理中文和多语言文本。
学完这一章后,你应该至少能做到:
- 知道数组和切片的本质区别
- 理解切片为什么会“共享数据”以及为什么会“扩容”
- 熟练使用 map 做查找和统计
- 处理字符串时分清什么时候用
byte,什么时候用rune
下一步继续深入 Go 时,这些内容会成为你理解函数参数传递、结构体设计、并发数据处理的重要基础。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!