Skip to content

《Go语言实战》入门实战系列 04:数据结构详解——数组、切片与映射的底层原理与实战用法

约 3677 字大约 12 分钟

《Go语言实战》入门实战系列云原生

2026-04-01

开篇引导

在上一篇文章中,我们完成了项目结构、包管理和工具链的学习。从本章开始,我们将深入Go语言的核心数据结构——数组、切片和映射。这三种结构是管理集合数据的基石,无论是处理数据库查询结果、解析JSON数组,还是构建缓存系统,都离不开它们。

通过本篇学习,你将掌握数组的内存布局与性能优势,理解切片如何基于数组实现动态扩容,学会映射的哈希原理与高效查询方法。我们还会深入分析切片扩容策略、指针传递的陷阱,以及如何安全地在函数间传递这些数据结构。所有内容都将结合底层实现,让你不仅“会用”,更懂得“为何如此设计”。

【本篇核心收获】

  • 理解数组的连续内存布局及值语义特性
  • 掌握切片的内部结构(指针、长度、容量)及动态扩容机制
  • 熟练使用appendlencap操作切片
  • 掌握切片的三种索引语法,避免共享底层数组带来的副作用
  • 学会使用range安全迭代切片和映射
  • 理解映射的哈希表实现、桶的概念及键的限制条件
  • 掌握在函数间传递切片和映射的高效方式

1. 数组——固定长度的连续内存块

在Go语言中,数组是一个长度固定的数据类型,用于存储一段具有相同类型的连续元素。数组的类型包括元素类型和长度,例如[5]int[10]int是不同的类型。

1.1 内部实现

图1:数组的内部实现

数组在内存中是一段连续的块。由于内存连续,CPU可以缓存更久的数据,索引计算也极其快速。每个元素可以通过唯一的索引(下标)访问。

1.2 声明和初始化

声明数组时必须指定元素类型和长度:

var array [5]int   // 所有元素初始化为int的零值0

图2:声明数组变量后数组的值

可以使用数组字面量快速初始化:

// 指定所有元素的值
array := [5]int{10, 20, 30, 40, 50}

// 让编译器自动计算长度
array := [...]int{10, 20, 30, 40, 50}

// 指定索引赋值,其他保持零值
array := [5]int{1: 10, 2: 20}

图3:声明之后数组的值

1.3 使用数组

通过[]操作符访问和修改元素:

array := [5]int{10, 20, 30, 40, 50}
array[2] = 35

图4:修改索引为2的值之后数组的值

数组的元素可以是任何类型,包括指针:

// 指针数组
array := [5]*int{0: new(int), 1: new(int)}
*array[0] = 10
*array[1] = 20

图5:指向整数的指针数组

数组是值类型:数组变量代表整个数组,赋值时会复制所有元素。

var array1 [5]string
array2 := [5]string{"Red", "Blue", "Green", "Yellow", "Pink"}
array1 = array2   // 复制所有元素

图6:复制之后的两个数组

只有类型(长度+元素类型)完全相同的数组才能互相赋值。以下代码会编译错误:

var array1 [4]string
array2 := [5]string{"Red", "Blue", "Green", "Yellow", "Pink"}
array1 = array2   // 编译错误:类型不匹配

复制指针数组时,复制的是指针值(地址),不会复制指向的数据:

var array1 [3]*string
array2 := [3]*string{new(string), new(string), new(string)}
*array2[0] = "Red"
*array2[1] = "Blue"
*array2[2] = "Green"
array1 = array2   // 两个数组共享同一组字符串

图7:两组指向同样字符串的数组

1.4 多维数组

通过组合多个数组可以创建多维数组:

var array [4][2]int
array := [4][2]int{{10, 11}, {20, 21}, {30, 31}, {40, 41}}
array := [4][2]int{1: {20, 21}, 3: {40, 41}}
array := [4][2]int{1: {0: 20}, 3: {1: 41}}

图8:二维数组及其外层数组和内层数组的值

访问元素时使用多个[]

var array [2][2]int
array[0][0] = 10
array[0][1] = 20
array[1][0] = 30
array[1][1] = 40

多维数组也可以赋值,只要类型完全一致:

var array1 [2][2]int
array2 := [2][2]int{{10,20},{30,40}}
array1 = array2   // 复制整个二维数组

1.5 在函数间传递数组

由于数组是值类型,直接传递数组会复制整个数组,开销巨大。例如一个100万int的数组(8MB):

var array [1e6]int
foo(array)   // 复制8MB到栈上

func foo(array [1e6]int) {
    // ...
}

更高效的方式是传递指针,只需复制8字节的地址:

foo(&array)   // 只复制指针

func foo(array *[1e6]int) {
    // ...
}

但传递指针意味着函数内对数组的修改会影响到原数组。使用切片可以更好地处理这类需求。

模块小结:数组是固定长度、连续内存、值类型的数据结构。适用于长度固定且需要高性能的场景,但复制成本高,通常用切片替代。

2. 切片——动态数组的抽象

切片是一种轻量级数据结构,围绕动态数组构建,可以按需自动增长和缩小。

2.1 内部实现

切片包含三个字段(共24字节):

  • 指针:指向底层数组的起始位置
  • 长度:当前切片包含的元素个数
  • 容量:从指针位置到底层数组末尾的元素个数

图9:切片内部实现:底层数组

2.2 创建和初始化

使用make创建

// 长度和容量均为5
slice := make([]string, 5)

// 长度3,容量5
slice := make([]int, 3, 5)

注意:容量不能小于长度,否则编译错误。

使用切片字面量

// 长度和容量均为5
slice := []string{"Red", "Blue", "Green", "Yellow", "Pink"}

// 长度和容量均为3
slice := []int{10, 20, 30}

// 指定索引创建,长度100,容量100,第100个元素初始化为空字符串
slice := []string{99: ""}

区分数组和切片[]内没有数字是切片,有数字是数组。

array := [3]int{10, 20, 30}   // 数组
slice := []int{10, 20, 30}    // 切片

nil切片和空切片

声明但不初始化得到nil切片:

var slice []int   // nil切片

图10:nil切片的表示

使用make或字面量创建空切片:

slice := make([]int, 0)   // 空切片
slice := []int{}          // 空切片

图11:空切片的表示

nil切片和空切片在调用appendlencap时效果相同。

2.3 使用切片

赋值和切片操作

通过索引修改元素:

slice := []int{10, 20, 30, 40, 50}
slice[1] = 25

通过切片操作创建新切片(共享底层数组):

slice := []int{10, 20, 30, 40, 50}
newSlice := slice[1:3]   // 长度2,容量4(从索引1到末尾共4个元素)

图12:共享同一底层数组的两个切片

计算长度和容量

  • 长度 = j - i
  • 容量 = k - i(k为底层数组容量,未指定时默认为底层数组容量)
// 对底层数组容量为5的slice[1:3]
长度 = 3-1 = 2
容量 = 5-1 = 4

修改新切片会反映到原切片:

newSlice[1] = 35   // 同时修改了slice[2]的值

图13:赋值操作之后的底层数组

注意:不能访问超出长度的元素,即使容量足够,否则引发panic。

newSlice[3] = 45   // panic: index out of range

切片增长:append

使用append向切片追加元素。如果容量足够,直接使用剩余容量;如果容量不足,创建新的底层数组,容量按一定策略增长(小于1000时翻倍,之后增长因子1.25)。

容量足够时

slice := []int{10, 20, 30, 40, 50}
newSlice := slice[1:3]   // [20,30], 容量4
newSlice = append(newSlice, 60)   // 使用原底层数组的剩余容量

图14:append操作之后的底层数组

容量不足时

slice := []int{10, 20, 30, 40}
newSlice := append(slice, 50)   // 创建新底层数组,容量加倍

图15:append操作之后的新的底层数组

创建切片时的三个索引

使用[i:j:k]语法可以同时控制新切片的长度和容量,限制容量可避免意外修改共享的底层数组。

source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"}
slice := source[2:3:4]   // 长度1,容量2(从索引2到索引3,不含4)

图16:字符串切片的表示

图17:操作之后的新切片的表示

长度 = j-i,容量 = k-i。如果k超过底层数组容量,引发panic。

限制容量的好处:当长度等于容量时,第一次append就会创建新底层数组,与原数组分离,避免意外修改。

slice := source[2:3:3]   // 长度1,容量1
slice = append(slice, "Kiwi")   // 创建新数组

图18:append操作之后的新切片的表示

追加多个元素

s1 := []int{1, 2}
s2 := []int{3, 4}
result := append(s1, s2...)   // [1,2,3,4]

迭代切片

使用for range迭代切片:

slice := []int{10, 20, 30, 40}
for index, value := range slice {
    fmt.Printf("Index: %d Value: %d\n", index, value)
}

图19:使用range迭代切片会创建每个元素的副本

重要value是元素的副本,其地址始终相同。要获取元素地址,应使用&slice[index]

for index, value := range slice {
    fmt.Printf("Value: %d Value-Addr: %p ElemAddr: %p\n", 
               value, &value, &slice[index])
}
// Value-Addr每次相同,ElemAddr每次不同

忽略索引使用下划线:

for _, value := range slice {
    fmt.Println(value)
}

也可以使用传统for循环:

for index := 2; index < len(slice); index++ {
    fmt.Println(slice[index])
}

2.4 多维切片

切片可以组合成多维结构:

slice := [][]int{{10}, {100, 200}}

图20:整型切片的切片的值

对内部切片操作同样适用:

slice[0] = append(slice[0], 20)

图21:append操作之后外层切片索引为0的元素的布局

2.5 在函数间传递切片

切片本身只占24字节,传递切片只是复制这24字节的数据,底层数组不复制,因此效率高。

slice := make([]int, 1e6)
foo(slice)   // 只复制24字节

func foo(slice []int) []int {
    return slice
}

图22:函数调用之后两个切片指向同一个底层数组

模块小结:切片是Go语言处理集合的首选。它基于数组,提供动态扩容,通过append增长,通过切片操作共享底层数组。理解容量和三个索引能避免常见的共享陷阱。

3. 映射——无序键值对集合

映射(map)用于存储键值对,基于哈希表实现,提供快速的键查找。

3.1 内部实现

图23:键值对的关系

映射的底层是一个哈希表,包含多个桶。当键值对数量增加时,桶的数量也会增加,以保持均匀分布。

图24:映射的内部结构的简单表示

图25:简单描述散列函数是如何工作的

3.2 创建和初始化

使用make创建:

dict := make(map[string]int)

使用字面量创建并初始化:

dict := map[string]string{"Red": "#da1337", "Orange": "#e95a22"}

键的限制:键必须支持==比较。切片、函数以及包含切片的类型不能作为键,否则编译错误。

// 错误示例
dict := map[[]string]int{}   // 编译错误

但切片可以作为值:

dict := map[int][]string{}   // 正确

3.3 使用映射

赋值:

colors := map[string]string{}
colors["Red"] = "#da1337"

nil映射:声明但未初始化(nil映射)不能赋值,否则panic。

var colors map[string]string   // nil映射
colors["Red"] = "#da1337"       // panic

检查键是否存在

value, exists := colors["Blue"]
if exists {
    fmt.Println(value)
}

或者仅取值,通过零值判断(注意:如果值的零值有实际含义,则需谨慎):

value := colors["Blue"]
if value != "" {
    fmt.Println(value)
}

迭代映射:使用for range,每次返回键和值,顺序不确定。

colors := map[string]string{
    "AliceBlue":   "#f0f8ff",
    "Coral":       "#ff7F50",
    "DarkGray":    "#a9a9a9",
    "ForestGreen": "#228b22",
}
for key, value := range colors {
    fmt.Printf("Key: %s Value: %s\n", key, value)
}

删除键值对:使用delete函数。

delete(colors, "Coral")

3.4 在函数间传递映射

映射是引用类型,传递时只复制引用(指针),因此函数内对映射的修改会影响原映射。

func main() {
    colors := map[string]string{
        "AliceBlue": "#f0f8ff",
        "Coral":     "#ff7F50",
    }
    removeColor(colors, "Coral")
    // colors中已无Coral
}

func removeColor(colors map[string]string, key string) {
    delete(colors, key)
}

模块小结:映射是哈希表实现的无序键值对集合,支持快速查找。键必须可比较,传递开销小。适用于缓存、配置、计数等场景。

4. 本篇核心知识点速记

  • 数组:固定长度、连续内存、值类型。声明[n]T,初始化可指定索引,赋值复制所有元素。传递大数组推荐用指针。
  • 切片:动态数组,包含指针、长度、容量。创建用make([]T, len, cap)或字面量[]T{...}。操作[i:j]生成新切片共享底层数组;[i:j:k]限制容量。append增长切片,容量不足时自动扩容。切片传递仅复制24字节,高效。
  • 映射:哈希表实现的无序键值对,创建用make(map[K]V)或字面量map[K]V{...}。键必须可比较(不能是切片、函数)。检查存在用v, ok := m[k]。删除用delete(m, k)。传递映射仅复制引用。

文末小结

本篇我们深入剖析了Go语言的核心数据结构。数组作为基础,提供了连续内存的高性能存储;切片在其之上构建了动态、灵活的集合管理;映射则通过哈希表实现了高效的键值对查找。理解这些结构的内部实现(特别是切片的容量与共享机制)是写出高效、安全Go代码的关键。