Go 语言核心数据类型详解(二):切片、字典与指针深度剖析
Go 语言中另外三个极其重要且常用的复合数据类型:切片 (Slice)、字典 (Map) 和 指针 (Pointer)。理解它们的特性和底层机制对于编写高效、健壮的 Go 程序至关重要。
Go 语言常见数据类型回顾:
- 整型 (Integer)
- 浮点型 (Floating-point)
- 布尔型 (Boolean)
- 字符串 (String)
- 数组 (Array)
- 切片 (Slice) - 本文重点
- 字典 (Map) - 本文重点
- 指针 (Pointer) - 本文重点
- 结构体 (Struct)
- 接口 (Interface)
- 通道 (Channel)
- ...等
1. 切片 (Slice) - 动态数组的威力
切片是对数组一个连续片段的引用(或称视图)。它提供了比数组更强大、更灵活的数据序列操作能力,是 Go 中最常用的数据结构之一。你可以把它理解为一个动态数组。
核心概念: 每个切片变量实际上是一个包含三个字段的结构体:
// Go 运行时中切片的内部表示(概念性)
type slice struct {
array unsafe.Pointer // 指向底层数组的指针 (Pointer to underlying array)
len int // 切片的当前长度 (Length of the slice)
cap int // 底层数组的容量 (Capacity, from the start of the slice)
}

- 指针 (array): 指向一个底层数组中切片第一个元素的内存地址。
- 长度 (len): 切片中当前包含的元素个数。
len(slice)不能超过cap(slice)。 - 容量 (cap): 从切片的起始元素到底层数组末尾的元素个数。它决定了在不重新分配内存的情况下,切片可以增长的最大长度。
关键特性:
- 动态增长: 当使用
append函数向切片添加元素,且当前容量不足时,Go 会自动分配一个新的、更大的底层数组,将原有元素复制过去,然后添加新元素。这个过程称为扩容。 - 扩容策略: 通常情况下,当容量需要扩展时,新容量会是旧容量的两倍(如果旧容量小于 1024)。当旧容量超过 1024 时,增长因子会降至约 1.25 倍(即增加 1/4),以避免过快消耗内存。
- 共享底层数组: 多个切片可能引用同一个底层数组。对一个切片元素的修改会影响到其他引用同一数组相应位置的切片。
1.1 创建切片
有多种方式可以创建切片:
package main
import "fmt"
func main() {
// 1. 声明一个 nil 切片 (值为 nil, len=0, cap=0)
var nums []int
fmt.Printf("nums: %v, len=%d, cap=%d, is nil? %t\n", nums, len(nums), cap(nums), nums == nil) // nums: [], len=0, cap=0, is nil? true
// 2. 使用切片字面量 (literal) 创建并初始化
data := []int{11, 22, 33} // len=3, cap=3
fmt.Printf("data: %v, len=%d, cap=%d\n", data, len(data), cap(data))
// 3. 使用 make 创建切片
// make([]T, length, capacity)
// capacity 参数可选,如果省略,则 capacity = length
users := make([]int, 3, 5) // 创建一个长度为 3,容量为 5 的 int 切片,初始值为 [0 0 0]
fmt.Printf("users: %v, len=%d, cap=%d\n", users, len(users), cap(users))
emptySlice := make([]int, 0, 5) // 创建一个空切片,但有初始容量
fmt.Printf("emptySlice: %v, len=%d, cap=%d\n", emptySlice, len(emptySlice), cap(emptySlice)) // emptySlice: [], len=0, cap=5
defaultCapSlice := make([]int, 3) // 容量默认为长度, len=3, cap=3
fmt.Printf("defaultCapSlice: %v, len=%d, cap=%d\n", defaultCapSlice, len(defaultCapSlice), cap(defaultCapSlice))
// 4. 切片的指针类型
// 使用 new 创建的是指向切片头的指针,切片本身是 nil
var v1 = new([]int)
fmt.Printf("v1 type: %T, value: %v, is nil? %t\n", v1, v1, *v1 == nil) // v1 type: *[]int, value: &[], is nil? true
// *v1 = append(*v1, 1) // 需要先初始化切片才能 append
// 声明一个切片指针,默认为 nil
var v2 *[]int
fmt.Printf("v2 type: %T, is nil? %t\n", v2, v2 == nil) // v2 type: *[]int, is nil? true
}
make vs new for Slices:
make([]T, len, cap): 推荐用于创建切片。它分配并初始化底层数组,并返回一个已经初始化好的切片结构(包含指针、长度、容量)。new([]T): 只分配存储切片结构本身的内存(三个字段),并返回指向这个结构的指针 (*[]T)。切片结构中的指针字段为nil,长度和容量为 0。在使用前,你通常需要手动给*v1赋值一个实际的切片(如*v1 = make([]int, 0, 10))。
1.2 自动扩容机制详解
理解 append 函数的行为对于掌握切片至关重要。
package main
import "fmt"
func main() {
// 场景1: append 时容量足够,不发生扩容
v1 := make([]int, 1, 3) // len=1, cap=3, v1=[0]
fmt.Printf("Initial v1: Addr=%p, len=%d, cap=%d, data=%v\n", v1, len(v1), cap(v1), v1)
v2 := append(v1, 66) // 容量足够 (3 > 1), 直接在 v1 底层数组的下一个位置写入 66
// v2 的 len=2, cap=3, 指针与 v1 相同
fmt.Printf("v2 after append(v1, 66): Addr=%p, len=%d, cap=%d, data=%v\n", v2, len(v2), cap(v2), v2)
fmt.Printf("v1 after append: Addr=%p, len=%d, cap=%d, data=%v\n", v1, len(v1), cap(v1), v1) // v1 本身不变 (len=1)
// 修改 v1[0] 会影响 v2,因为它们共享底层数组
v1[0] = 999
fmt.Println("After v1[0]=999:")
fmt.Println("v1:", v1) // [999]
fmt.Println("v2:", v2) // [999 66] (!!! v2 的第一个元素也被改变了)
// 常见用法: 将 append 的结果赋值回原变量
v3 := make([]int, 1, 3) // [0]
v3 = append(v3, 888) // v3 变为 [0 888], len=2, cap=3
fmt.Println("v3 after append:", v3)
fmt.Println("---")
// 场景2: append 时容量不足,发生扩容
a1 := []int{11, 22, 33} // len=3, cap=3
fmt.Printf("Initial a1: Addr=%p, len=%d, cap=%d, data=%v\n", a1, len(a1), cap(a1), a1)
a2 := append(a1, 44) // 容量不足 (3 >= 3), 需要扩容
// Go 会分配一个新数组 (容量通常翻倍,这里变为 6), 复制 a1 的元素,再添加 44
// a2 会指向这个新数组,len=4, cap=6
fmt.Printf("a2 after append(a1, 44): Addr=%p, len=%d, cap=%d, data=%v\n", a2, len(a2), cap(a2), a2)
fmt.Printf("a1 after append: Addr=%p, len=%d, cap=%d, data=%v\n", a1, len(a1), cap(a1), a1) // a1 保持不变,仍指向旧数组
// 修改 a1[0] 不会影响 a2,因为它们指向不同的底层数组
a1[0] = 999
fmt.Println("After a1[0]=999:")
fmt.Println("a1:", a1) // [999 22 33]
fmt.Println("a2:", a2) // [11 22 33 44] (a2 不受影响)
}
关键结论:
append函数可能返回一个新的切片(当发生扩容时),也可能返回指向原底层数组的切片(当容量足够时)。- 最佳实践: 始终将
append的结果赋值回原切片变量:slice = append(slice, element)。 - 当多个切片共享底层数组时,一个切片的修改(通过索引赋值)会影响其他共享该数组的切片。
append操作在不扩容时也会修改共享的底层数组(超出原切片长度但在容量内的部分)。
1.3 常见操作
-
长度和容量:
v1 := []int{11, 22, 33} fmt.Println("Length:", len(v1), "Capacity:", cap(v1)) // Length: 3 Capacity: 3 -
索引: 访问和修改元素(索引必须在
0 <= index < len(slice)范围内)。v1 := []string{"alex", "李杰", "老男孩"} fmt.Println(v1[1]) // 李杰 v1[0] = "Alexander" fmt.Println(v1) // [Alexander 李杰 老男孩] v2 := make([]int, 2, 5) // len=2, cap=5, v2=[0 0] v2[0] = 999 // fmt.Println(v2[2]) // 运行时错误: index out of range [2] with length 2 -
切片 (Slicing): 从现有切片或数组创建新切片
slice[low:high]或slice[low:high:max]。low: 起始索引(包含)。high: 结束索引(不包含)。max: 设置新切片的容量cap = max - low(可选,用于限制容量)。
v1 := []int{11, 22, 33, 44, 55, 66} // len=6, cap=6 v2 := v1[1:3] // 元素: v1[1], v1[2]. len=3-1=2, cap=6-1=5. v2 = [22 33] fmt.Printf("v2: %v, len=%d, cap=%d\n", v2, len(v2), cap(v2)) v3 := v1[4:] // 从索引 4 到末尾. len=6-4=2, cap=6-4=2. v3 = [55 66] fmt.Printf("v3: %v, len=%d, cap=%d\n", v3, len(v3), cap(v3)) v4 := v1[:3] // 从开头到索引 3 (不含). len=3-0=3, cap=6-0=6. v4 = [11 22 33] fmt.Printf("v4: %v, len=%d, cap=%d\n", v4, len(v4), cap(v4)) v5 := v1[1:3:4] // 元素 v1[1], v1[2]. len=3-1=2, cap=4-1=3. v5 = [22 33] fmt.Printf("v5: %v, len=%d, cap=%d\n", v5, len(v5), cap(v5)) // 重要: 通过切片操作创建的新切片与原切片共享底层数组! v2[0] = 222 // 修改 v2 会影响 v1 fmt.Println("After v2[0]=222, v1 is:", v1) // v1 is: [11 222 33 44 55 66] -
追加 (Append): 向切片末尾添加一个或多个元素。
v1 := []int{11, 22, 33} // 追加单个元素 v2 := append(v1, 44) fmt.Println("Append one:", v2) // [11 22 33 44] // 追加多个元素 v3 := append(v1, 55, 66, 77) fmt.Println("Append multiple:", v3) // [11 22 33 55 66 77] // 追加另一个切片的所有元素 (使用 ... 操作符) anotherSlice := []int{100, 200} v4 := append(v1, anotherSlice...) fmt.Println("Append slice:", v4) // [11 22 33 100 200] -
删除元素 (按索引): Go 没有内置的删除函数,通常使用
append结合切片操作实现。v1 := []int{11, 22, 33, 44, 55, 66} deleteIndex := 2 // 删除索引为 2 的元素 (33) // result := append(v1[:deleteIndex], v1[deleteIndex+1:]...) // fmt.Println("After delete:", result) // [11 22 44 55 66] // fmt.Println("Original v1 (might be modified!):", v1) // 注意:v1 底层数组可能被修改 // 更安全(如果关心原切片)或需要保持顺序的删除方式: copy(v1[deleteIndex:], v1[deleteIndex+1:]) // 将后面的元素向前移动覆盖被删除的元素 v1 = v1[:len(v1)-1] // 缩短切片长度 fmt.Println("After safe delete:", v1) // [11 22 44 55 66]注意: 使用
append实现删除虽然简洁,但可能会修改原切片的底层数组(在被删除元素之后的部分)。如果需要频繁删除,或者关心底层数组的副作用,考虑使用copy或其他数据结构(如链表)。 -
插入元素 (按索引): 同样使用
append实现。v1 := []int{11, 22, 33, 44, 55, 66} insertIndex := 3 valueToInsert := 99 // 方式一:需要确保容量足够或接受扩容 v1 = append(v1[:insertIndex], append([]int{valueToInsert}, v1[insertIndex:]...)...) fmt.Println("After insert (method 1):", v1) // [11 22 33 99 44 55 66] // 方式二:先创建一个足够大的新切片 (如果知道最终大小) vOriginal := []int{11, 22, 33, 44, 55, 66} result := make([]int, len(vOriginal)+1) copy(result[:insertIndex], vOriginal[:insertIndex]) result[insertIndex] = valueToInsert copy(result[insertIndex+1:], vOriginal[insertIndex:]) fmt.Println("After insert (method 2):", result) // [11 22 33 99 44 55 66]注意: 插入操作通常效率较低,因为它涉及元素的移动。对于频繁插入的场景,链表等数据结构可能更合适。
-
循环:
v1 := []int{11, 22, 33} // 使用 for 循环 (按索引) for i := 0; i < len(v1); i++ { fmt.Printf("Index %d, Value %d\n", i, v1[i]) } // 使用 for range 循环 (推荐,更简洁安全) for index, value := range v1 { fmt.Printf("Index %d, Value %d\n", index, value) }
1.4 切片嵌套
可以创建元素为切片的切片,形成类似多维数组的结构,但每行的长度可以不同。
package main
import "fmt"
func main() {
// 元素类型为 []int 的切片
v2 := [][]int{[]int{11, 22, 33}, []int{44, 55}}
fmt.Println("v2:", v2) // [[11 22 33] [44 55]]
// 元素类型为 [2]int 的切片 (每行长度固定为2)
v3 := [][2]int{{1, 2}, {4, 5}}
fmt.Println("v3:", v3) // [[1 2] [4 5]]
// 修改嵌套切片/数组的元素
v2[0][1] = 222 // 修改 v2[0] 这个切片的第 1 个元素
v3[1][0] = 444 // 修改 v3[1] 这个数组的第 0 个元素
fmt.Println("Modified v2:", v2) // [[11 222 33] [44 55]]
fmt.Println("Modified v3:", v3) // [[1 2] [444 5]]
}
1.5 变量赋值与共享内存
回顾一下不同类型的赋值行为:
- 基本类型 (int, float, bool, string) 和数组: 赋值是值拷贝。新变量拥有独立内存,修改一个不影响另一个(字符串虽共享底层字节,但因其不可变性,效果类似值拷贝)。
- 切片: 赋值是切片头(包含指针、长度、容量)的拷贝。两个切片变量会指向同一个底层数组。
package main
import "fmt"
func main() {
// 切片赋值
v1 := []int{6, 9} // len=2, cap=2
v2 := v1 // v2 得到 v1 切片头的副本,指向同一底层数组
fmt.Printf("v1 Addr=%p, Data=%v\n", v1, v1)
fmt.Printf("v2 Addr=%p, Data=%v\n", v2, v2)
// 注意 &v1 和 &v2 不同,它们是两个不同的切片变量(头结构)本身的地址
fmt.Printf("Addr of v1 header: %p\n", &v1)
fmt.Printf("Addr of v2 header: %p\n", &v2)
// 修改 v1 的元素会影响 v2
v1[0] = 11111
fmt.Println("After v1[0]=11111:")
fmt.Println("v1:", v1) // [11111 9]
fmt.Println("v2:", v2) // [11111 9] (也变了!)
// 如果对 v1 进行 append 操作导致扩容
v1 = append(v1, 999) // v1 容量不足,发生扩容,v1 指向新数组
fmt.Printf("\nAfter v1 append (reallocation): Addr=%p, Data=%v\n", v1, v1) // v1 指向新地址
fmt.Printf("v2 after v1 append: Addr=%p, Data=%v\n", v2, v2) // v2 仍指向旧地址
// 此时修改 v1 不再影响 v2
v1[0] = 888
fmt.Println("\nAfter v1[0]=888 (post-realloc):")
fmt.Println("v1:", v1) // [888 9 999]
fmt.Println("v2:", v2) // [11111 9] (保持不变)
}
总结: 切片的赋值行为(共享底层数组)是其强大但也容易出错的地方。务必理解何时修改会相互影响,何时 append 会导致切片“分叉”指向不同的底层数组。
1.6 练习题回顾
(这里将练习题整合为对知识点的检验)
-
简述切片和数组的区别?
- 长度: 数组长度固定且是类型的一部分;切片长度可变。
- 类型:
[3]int和[4]int是不同类型;[]int是统一的切片类型。 - 传递/赋值: 数组是值类型,传递/赋值时复制整个数组;切片是引用类型(传递的是头结构副本,共享底层数组),传递/赋值成本低,修改会相互影响。
- 灵活性: 切片支持动态增删 (
append) 和更灵活的切片操作。
-
简述
new和make的区别?new(T): 分配类型T的零值所需内存,返回指向该内存的指针 (*T)。适用于所有类型。make(T, ...): 仅用于创建切片、字典和通道。它不仅分配内存,还初始化这些类型的内部数据结构(如切片的头、字典的哈希表),返回的是初始化后的类型本身([]T,map[K]V,chan T),而不是指针。
-
代码结果
v1 := make([]int,2,5); fmt.Println(v1[0],len(v1),cap(v1))- 输出:
0 2 5(v1[0] 是零值 0,长度 2,容量 5)
- 输出:
-
代码结果 (append 不扩容)
v1 := make([]int,2,5) // len=2, cap=5, [0 0] v2 := append(v1,123) // len=3, cap=5, 指向 v1 同一数组, [0 0 123] fmt.Println(len(v1),cap(v1)) // 输出: 2 5 fmt.Println(len(v2),cap(v2)) // 输出: 3 5 fmt.Println(v1) // 输出: [0 0] fmt.Println(v2) // 输出: [0 0 123] -
代码结果 (修改共享数组)
v1 := make([]int,2,5) v2 := append(v1,123) // v1, v2 共享数组 v1[0] = 999 fmt.Println(v1) // 输出: [999 0] fmt.Println(v2) // 输出: [999 0 123] (v2 的第一个元素也变了) -
代码结果 (append 导致扩容)
v1 := make([]int,2,2) // len=2, cap=2 v2 := append(v1,123) // 容量不足,v2 指向新数组 [0 0 123] v1[0] = 999 // 修改 v1 不影响 v2 fmt.Println(v1) // 输出: [999 0] fmt.Println(v2) // 输出: [0 0 123] -
代码结果 (切片操作共享数组)
v1 := make([]int,2,2) v2 := v1[0:2] // v2 是 v1 的一个视图,共享数组 v1[0] = 111 fmt.Println(v1) // 输出: [111 0] fmt.Println(v2) // 输出: [111 0] -
代码结果 (修改嵌套切片)
v1 := [][]int{ []int{11, 22, 33, 44}, []int{44, 55} } v1[0][2] = 999 // 直接修改内部切片的元素 fmt.Println(v1) // 输出: [[11 22 999 44] [44 55]] -
代码结果 (通过中间变量修改)
v1 := [][]int{[]int{11, 22, 33, 44}, []int{44, 55}} v2 := v1[0] // v2 和 v1[0] 指向同一个内部切片 (共享其底层数组) v2[2] = 69 // 修改 v2 的元素,即修改了 v1[0] 指向的切片的元素 fmt.Println(v1) // 输出: [[11 22 69 44] [44 55]] -
代码结果 (append 内部切片可能扩容)
v1 := [][]int{[]int{11, 22, 33, 44}, []int{44, 55}} // v1[0] len=4, cap=4 v2 := append(v1[0], 99) // 对 v1[0] append, 容量不足, v2 指向新数组 v2[2] = 69 // 修改 v2 不影响 v1[0] fmt.Println(v1) // 输出: [[11 22 33 44] [44 55]] -
代码结果 (append 内部切片不扩容)
v1 := [][]int{ make([]int, 2, 5), make([]int, 2, 3) } // v1[0] len=2, cap=5 v2 := append(v1[0], 99) // 对 v1[0] append, 容量足够, v2 与 v1[0] 共享数组 fmt.Println(v1) // 输出: [[0 0] [0 0]] (v1[0] 长度仍为 2) fmt.Println(v2) // 输出: [0 0 99] v2[0] = 69 // 修改 v2[0] 会影响 v1[0] fmt.Println(v1) // 输出: [[69 0] [0 0]] (v1[0] 的第一个元素变了) fmt.Println(v2) // 输出: [69 0 99]
2. 字典类型 (Map) - 高效的键值存储
字典(Map)是 Go 语言中用于存储键值对 (key-value pairs) 集合的内置类型。它提供了基于键的快速数据检索、添加和删除操作。
核心特性:
- 键值对: 每个元素由一个唯一的键 (key) 和一个对应的值 (value) 组成。
- 无序: Map 中的元素是无序的。遍历 map 时,元素的顺序是不保证的,每次遍历可能都不同。
- 快速查找: 底层通常基于哈希表 (hash table) 实现,使得根据键查找值的时间复杂度接近 O(1)(平均情况)。
- 键的要求:
- 键必须是可比较 (comparable) 的类型(支持
==和!=运算)。 - 常见的可比较类型包括:布尔型、数值类型(整型、浮点型)、字符串、指针、通道以及只包含可比较类型的数组和结构体。
- 切片、字典和函数类型是不可比较的,不能作为 map 的键。
- 键必须是可比较 (comparable) 的类型(支持
- 动态大小: Map 会根据需要自动增长。
2.1 声明与初始化
package main
import "fmt"
func main() {
// 1. 使用 map 字面量创建并初始化
userInfo := map[string]string{
"name": "张翼德",
"age": "18", // 值可以是任意类型,只要符合声明
}
fmt.Println("userInfo:", userInfo)
// 2. 使用 make 创建 map
// make(map[KeyType]ValueType, initialCapacity)
// initialCapacity 是可选的,用于提示初始大小,有助于性能优化
data := make(map[int]int, 10) // 创建一个 key 为 int, value 为 int 的 map
data[100] = 998
data[200] = 999
fmt.Println("data:", data)
emptyMap := make(map[string]int) // 创建一个空 map
fmt.Println("emptyMap:", emptyMap)
// 3. 声明一个 nil map
var row map[string]int // row 的值为 nil
fmt.Printf("row is nil? %t\n", row == nil) // true
// row["key"] = 1 // 运行时错误: assignment to entry in nil map
// 必须先初始化才能使用
row = make(map[string]int)
row["initialized"] = 1
fmt.Println("Initialized row:", row)
// 4. 使用 new 创建 map 指针 (不常用)
valuePtr := new(map[string]int) // valuePtr 是 *map[string]int 类型, *valuePtr 是 nil map
fmt.Printf("valuePtr is nil? %t, *valuePtr is nil? %t\n", valuePtr == nil, *valuePtr == nil) // false, true
// (*valuePtr)["k1"] = 123 // 运行时错误: assignment to entry in nil map
// 需要先初始化指向的 map
*valuePtr = make(map[string]int)
(*valuePtr)["k1"] = 123
fmt.Println("Initialized *valuePtr:", *valuePtr)
// 5. 键必须是可比较类型
// map[ [2]int ]float32 是合法的,因为数组是可比较的 (如果元素类型可比较)
arrayKeyMap := make(map[[2]int]float32)
arrayKeyMap[[2]int{1, 1}] = 1.6
arrayKeyMap[[2]int{1, 2}] = 3.4
fmt.Println("arrayKeyMap:", arrayKeyMap)
// map[ []int ]int // 编译错误: invalid map key type []int (slice is not comparable)
// map[ map[int]int ]int // 编译错误: invalid map key type map[int]int (map is not comparable)
}
2.2 常用操作
-
获取长度 (Length):
len(m)返回 map 中键值对的数量。Map 没有容量 (cap) 的概念。data := map[string]string{"n1": "张翼德", "n2": "alex"} fmt.Println("Length:", len(data)) // 2 -
添加或修改元素: 使用
m[key] = value语法。如果key已存在,则更新其对应的value;如果key不存在,则添加新的键值对。data := map[string]string{"n1": "张翼德"} data["n2"] = "alex" // 添加 data["n1"] = "eric" // 修改 fmt.Println("Updated data:", data) // map[n1:eric n2:alex] (顺序不定) -
删除元素: 使用内置的
delete(m, key)函数。如果key不存在,该操作无效果,也不会报错。data := map[string]string{"n1": "张翼德", "n2": "alex"} delete(data, "n2") fmt.Println("After delete:", data) // map[n1:张翼德] delete(data, "nonexistent_key") // 安全,无操作 -
查找/读取元素: 使用
value := m[key]。- 如果
key存在,value得到对应的值。 - 如果
key不存在,value会得到该值类型的零值(例如,int的 0,string的 "",bool的false,指针的nil等)。 - 判断键是否存在: 为了区分是值本身就是零值还是键不存在,应使用 "comma ok" idiom:
data := map[string]string{"n1": "张翼德", "n2": ""} // n2 的值是空字符串 // 方式一:直接访问 (无法区分空字符串是值还是键不存在) val1 := data["n1"] val2 := data["n2"] val3 := data["n3"] // n3 不存在 fmt.Printf("val1=%q, val2=%q, val3=%q\n", val1, val2, val3) // val1="张翼德", val2="", val3="" // 方式二:使用 "comma ok" idiom (推荐) value1, ok1 := data["n1"] // key 存在 value2, ok2 := data["n2"] // key 存在,值是 "" value3, ok3 := data["n3"] // key 不存在 fmt.Printf("n1: value=%q, ok=%t\n", value1, ok1) // n1: value="张翼德", ok=true fmt.Printf("n2: value=%q, ok=%t\n", value2, ok2) // n2: value="", ok=true fmt.Printf("n3: value=%q, ok=%t\n", value3, ok3) // n3: value="", ok=false - 如果
-
遍历 (Looping): 使用
for range遍历 map。每次迭代返回一个键值对。遍历顺序不固定!data := map[string]string{"n1": "张翼德", "n2": "alex", "n3": "eric"} fmt.Println("Iterating key-value pairs:") for key, value := range data { fmt.Printf(" Key: %s, Value: %s\n", key, value) } fmt.Println("\nIterating keys only:") for key := range data { fmt.Println(" Key:", key) } fmt.Println("\nIterating values only:") for _, value := range data { fmt.Println(" Value:", value) } -
嵌套 (Nesting): Map 的值可以是任何类型,包括另一个 map 或切片等。
// 值是切片 mapOfSlices := make(map[string][]int) mapOfSlices["groupA"] = []int{1, 2, 3} mapOfSlices["groupB"] = []int{4, 5} fmt.Println("mapOfSlices:", mapOfSlices) // map[groupA:[1 2 3] groupB:[4 5]] // 值是另一个 map mapOfMaps := make(map[string]map[int]string) mapOfMaps["user1"] = make(map[int]string) // 内部 map 需要初始化 mapOfMaps["user1"][101] = "Alice" mapOfMaps["user1"][102] = "Bob" fmt.Println("mapOfMaps:", mapOfMaps) // map[user1:map[101:Alice 102:Bob]]
2.3 变量赋值
与切片类似,将一个 map 赋值给另一个变量时,复制的是 map 头(包含指向底层哈希表结构的指针)。两个变量将引用同一个底层哈希表。对一个 map 变量的修改(添加、删除、更新元素)会通过另一个变量反映出来。
package main
import "fmt"
func main() {
v1 := map[string]string{"n1": "张翼德", "n2": "alex"}
v2 := v1 // v2 和 v1 指向同一个底层 map
fmt.Println("Initial v1:", v1) // map[n1:张翼德 n2:alex]
fmt.Println("Initial v2:", v2) // map[n1:张翼德 n2:alex]
v1["n1"] = "wupeiqi" // 修改 v1
delete(v2, "n2") // 通过 v2 删除
fmt.Println("After modification:")
fmt.Println("v1:", v1) // map[n1:wupeiqi] (n2 也被删了)
fmt.Println("v2:", v2) // map[n1:wupeiqi] (n1 也被改了)
// 即使 map 内部发生扩容,所有引用该 map 的变量仍然指向同一个(可能已更新的)底层结构。
}
2.4 Map 底层原理剖析 (hmap 与 bmap)
Go 语言的 map 实现比简单的“取模+拉链”要复杂和高效。其核心数据结构是 hmap(代表整个哈希表)和 bmap(代表哈希表中的一个“桶” bucket)。

hmap结构体: 包含 map 的元信息,如元素数量 (count)、哈希表的“大小参数” (B)、哈希种子 (hash0)、指向桶数组的指针 (buckets)、指向旧桶数组的指针(扩容时使用oldbuckets)等。bmap(桶) 结构体:tophash[8]: 存储该桶中 8 个 key 的哈希值的高 8 位 (top hash)。用于快速定位可能的 key。keys[8]: 存储 8 个 key 本身。values[8]: 存储 8 个 value 本身。overflow: 指向下一个溢出桶 (overflow bucket) 的指针。当一个桶存满 8 个键值对后,后续发生哈希碰撞的键值对会存放到溢出桶中,形成链表结构。
核心流程概览:
-
初始化
make(map[K]V, hint):- 创建一个
hmap对象。 - 生成随机哈希种子
hash0。 - 根据
hint(预期元素数量) 估算需要的桶数量,确定参数B(桶数量大致为2^B)。 - 分配主桶数组 (
buckets),大小为2^B(或稍大,包含预分配的溢出桶)。
- 创建一个
-
写入数据
m[key] = value:- 使用
hash0和key计算出一个完整的哈希值。 - 取哈希值的低 B 位,确定该
key应该放入哪个主桶 (buckets数组的索引)。 - 计算哈希值的高 8 位 (
tophash)。 - 在目标桶中查找空位:
- 先比较
tophash数组,快速过滤。 - 如果
tophash匹配,再完整比较key是否相同(处理哈希碰撞)。 - 如果找到空位或匹配的
key,则将tophash,key,value存入或更新对应位置。 - 如果桶已满,则查找或创建溢出桶,在溢出桶中重复查找/插入过程。
- 先比较
- 更新
hmap的count。 - 检查是否需要扩容 (load factor 或 overflow bucket 数量)。
- 使用
-
读取数据
value, ok := m[key]:- 计算
key的哈希值和tophash。 - 根据哈希值的低 B 位找到目标主桶。
- 在桶及其溢出桶链表中查找:
- 先用
tophash快速匹配。 tophash匹配后再完整比较key。
- 先用
- 找到则返回对应的
value和ok=true。 - 未找到则返回
value的零值和ok=false。
- 计算
-
扩容 (Grow):
- 触发条件:
- 负载因子 (Load Factor) 过高:
count / (2^B) > 6.5。触发翻倍扩容 (Double Grow)。 - 溢出桶过多: 使用了太多溢出桶(具体阈值与
B相关)。触发等量扩容 (Same-Size Grow)。
- 负载因子 (Load Factor) 过高:
- 扩容过程:
- 分配新桶: 创建一个新的桶数组 (
newbuckets)。翻倍扩容时大小为2^(B+1),等量扩容时大小仍为2^B。 - 标记状态:
hmap的oldbuckets指向旧桶,buckets指向新桶。设置迁移状态 (nevacuate等)。 - 渐进式迁移 (Incremental Migration): 扩容不是一次性完成的。数据的迁移发生在后续的写入、读取和删除操作中。每次操作时,会负责迁移一两个旧桶的数据到新桶。
- 翻倍扩容迁移: 旧桶
i的数据会根据其哈希值的第B位(从低位数起)分裂到新桶i或i + 2^B中。 - 等量扩容迁移: 旧桶
i的数据(包括溢出桶)会重新组织并尽可能紧凑地放入新桶i中,目的是减少溢出桶的使用。
- 翻倍扩容迁移: 旧桶
- 迁移完成: 当所有旧桶数据迁移完毕后,
oldbuckets设为nil。
- 分配新桶: 创建一个新的桶数组 (
- 触发条件:
理解底层原理有助于:
- 明白 map 为何无序。
- 理解 map 操作的大致性能特征。
- 知道选择合适的
initialCapacity对性能有帮助(减少扩容次数)。 - 了解 map 在高并发读写场景下需要锁来保护(Go 1.9 之后有
sync.Map针对特定场景优化)。
3. 指针 (Pointer) - 内存地址的持有者
指针是一种特殊的数据类型,它存储的是另一个变量的内存地址。
3.1 基本概念
- 类型: 对于任意类型
T,其指针类型为*T。例如,int的指针类型是*int,string的指针类型是*string。 - 零值: 指针的零值是
nil。一个nil指针不指向任何内存地址。 - 获取地址 (
&): 使用取地址操作符&可以获取一个变量的内存地址。&variable的结果是一个指向variable的指针。 - 解引用 (
*): 使用解引用操作符*可以访问指针所指向地址处存储的值。*pointer读取或修改该地址的值。
package main
import "fmt"
func main() {
var name string = "武沛齐"
var age int = 18
// 声明指针变量
var pName *string // 指向 string 的指针, 初始为 nil
var pAge *int // 指向 int 的指针, 初始为 nil
fmt.Printf("pName: %v, pAge: %v\n", pName, pAge) // pName: <nil>, pAge: <nil>
// 获取地址并赋值给指针
pName = &name
pAge = &age
fmt.Printf("Address of name: %p, Value of pName: %p\n", &name, pName) // 地址相同
fmt.Printf("Address of age: %p, Value of pAge: %p\n", &age, pAge) // 地址相同
// 解引用指针获取值
fmt.Println("Value pointed by pName:", *pName) // 张翼德
fmt.Println("Value pointed by pAge:", *pAge) // 18
// 通过指针修改原变量的值
*pName = "Alex"
*pAge = 28
fmt.Println("After modification via pointer:")
fmt.Println("name:", name) // Alex
fmt.Println("age:", age) // 28
}
3.2 指针的意义与应用场景
指针的核心价值在于间接引用 (indirection),它带来了几个好处:
-
允许函数修改调用方的变量:
Go 函数参数默认是值传递 (pass-by-value)。如果直接传递变量,函数内对参数的修改不会影响原始变量。通过传递变量的指针,函数可以解引用指针来修改原始变量的值。package main import "fmt" func changeValue(val int) { // 值传递 val = 100 } func changeValueByPointer(ptr *int) { // 指针传递 *ptr = 100 } func main() { x := 10 changeValue(x) fmt.Println("After changeValue:", x) // 输出: 10 (未改变) y := 10 changeValueByPointer(&y) fmt.Println("After changeValueByPointer:", y) // 输出: 100 (已改变) } -
提高效率 (对于大型数据结构):
传递大型结构体或数组的指针,可以避免复制整个数据结构的开销,只需复制一个指针(通常 4 或 8 字节)。 -
实现共享数据: 多个指针可以指向同一块内存,实现对数据的共享访问和修改。
-
表示可选或缺失值: 指针可以是
nil,可以用来表示一个值可能不存在的情况(尽管 Go 更推荐使用 "comma ok" idiom 或其他方式)。 -
与 C 库交互或底层操作: 在某些需要直接操作内存的场景(如使用
unsafe包或 Cgo)会用到指针。
fmt.Scanf 中的应用:
fmt.Scanf("%s", &username) 需要传入 &username (username 的地址),是因为 Scanf 需要知道将读取到的用户输入存放到哪个内存位置。它通过指针来修改 username 变量的值。
3.3 指针的指针 (Multi-level Pointers)
可以创建指向指针的指针,甚至更多层级。
*T: 指向类型 T 的指针。**T: 指向类型*T(即指向 T 的指针) 的指针。***T: 指向类型**T的指针。- ...
package main
import "fmt"
func main() {
name := "武沛齐"
var p1 *string = &name // p1 指向 name
var p2 **string = &p1 // p2 指向 p1
var p3 ***string = &p2 // p3 指向 p2
fmt.Printf("name: %s @ %p\n", name, &name)
fmt.Printf("p1: %p -> %s\n", p1, *p1)
fmt.Printf("p2: %p -> %p -> %s\n", p2, *p2, **p2)
fmt.Printf("p3: %p -> %p -> %p -> %s\n", p3, *p3, **p3, ***p3)
// 通过多级指针修改原始值
***p3 = "我靠" // 需要三次解引用才能到达 name 存储的值
fmt.Println("After modification via p3:", name) // 输出: 我靠
}
解引用时,需要使用与指针层级相同数量的 * 操作符才能访问到最原始的值。
3.4 指针操作 (unsafe 包 - 谨慎使用!)
Go 语言本身不支持指针算术(像 C/C++ 那样直接对指针进行加减运算来移动到相邻内存单元)。这是为了保证内存安全。
但是,通过 unsafe 包,可以绕过 Go 的类型系统进行一些底层的指针操作,包括指针类型转换和类似指针算术的效果。强烈建议避免使用 unsafe 包,除非你非常清楚自己在做什么,并且有充分的理由(如性能极致优化、与 C 库交互等)。使用 unsafe 会破坏 Go 的内存安全保证,可能导致难以调试的错误,并且代码可能变得不可移植。
示例 (仅为演示概念,不推荐实际应用):
假设我们想通过指针算术访问数组的下一个元素。
package main
import (
"fmt"
"unsafe" // 导入 unsafe 包
)
func main() {
dataList := [3]int8{11, 22, 33} // int8 占 1 字节
// 1. 获取第一个元素的地址 (*int8 类型)
firstElementPtr := &dataList[0]
// 2. 将 *int8 转换为通用指针 unsafe.Pointer
// unsafe.Pointer 是一种特殊的指针类型,可以和任何指针类型以及 uintptr 相互转换。
genericPtr := unsafe.Pointer(firstElementPtr)
// 3. 将 unsafe.Pointer 转换为 uintptr
// uintptr 是一个整数类型,足以存储指针的值。可以对其进行算术运算。
// 我们要访问下一个元素 (int8 占 1 字节),所以地址加 1。
nextElementAddr := uintptr(genericPtr) + unsafe.Sizeof(dataList[0]) // 使用 Sizeof 获取元素大小
// 4. 将算术运算后的地址 (uintptr) 转回 unsafe.Pointer
nextElementPtrUnsafe := unsafe.Pointer(nextElementAddr)
// 5. 将 unsafe.Pointer 转回具体的指针类型 (*int8)
nextElementPtrTyped := (*int8)(nextElementPtrUnsafe)
// 6. 解引用得到值
fmt.Println("Value of next element:", *nextElementPtrTyped) // 输出: 22
// 再次强调:以上操作非常危险,应尽量避免!
}
关键点:
- 数组地址
&dataList是*[3]int8类型。 - 第一个元素地址
&dataList[0]是*int8类型。它们类型不同,但值(内存地址)相同。 - 指针计算必须通过
unsafe.Pointer和uintptr进行转换。
总结
今天我们深入探讨了 Go 语言中三个非常关键的数据类型:
- 切片 (Slice): 提供了灵活、动态的数组视图,是 Go 中处理序列数据的首选。理解其长度、容量、底层数组共享以及
append的扩容机制至关重要。 - 字典 (Map): 实现了高效的键值对存储和查找,底层基于哈希表。需要注意键的类型必须可比较,且 map 是无序的。赋值操作共享底层数据。
- 指针 (Pointer): 存储变量的内存地址,允许间接访问和修改数据,是实现函数修改外部变量、优化大结构传递、数据共享等场景的关键。
掌握这些数据类型及其特性,特别是它们在赋值和传递时的行为(值拷贝 vs 引用共享),是编写出正确、高效 Go 代码的基础。希望这篇详解能帮助大家更好地理解和运用它们!
浙公网安备 33010602011771号