1.5 数据类型:数组与切片
在 Go 语言中,数组和切片是两种非常重要且常用的数据结构,用于存储同类型元素的序列。理解它们的差异和联系,是掌握 Go 语言的关键。
一、数组 (Array)
数组(Array)是一种固定长度的、存储相同类型元素的序列。一个数组可以由零个或多个元素组成。因为数组的长度是固定的,所以在Go语言中很少直接使用数组。
1. 数组的核心特性
| 特性 | 描述 |
|---|---|
| 长度固定 | 数组一旦定义,其长度就不能改变。 |
| 类型组成 | 数组的长度是其类型的一部分。例如,[5]int 和 [10]int 是两种完全不同的类型。 |
| 值类型 | 数组是值类型。当一个数组赋值给另一个数组或作为函数参数传递时,会复制整个数组。改变副本不会影响原始数组。 |
| 索引访问 | 通过索引访问元素,索引从 0 开始。访问越界会触发运行时错误 (panic)。 |
2. 数组的定义与初始化
数组长度必须是常量。
方式一:指定长度
// 声明一个长度为 5 的整型数组,元素默认为 0
var arr1 [5]int
arr1[0] = 1方式二:使用 ... 让 Go 自动推导长度
// 数组长度由初始化元素的个数决定
arr2 := [...]int{1, 2, 3, 4, 5, 6}
// arr2 的类型是 [6]int方式三:按索引号初始化
// 初始化长度为 5 的数组,指定索引 2 的值为 100
arr3 := [5]int{2: 100, 4: 200}
// arr3: [0 0 100 0 200]
months := [...]string{1: "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"}
for index, month := range months {
fmt.Printf("索引 %d: %s\n", index, month)
}
// 索引 0:
// 索引 1: January
// 索引 2: February
// 索引 3: March
// 索引 4: April
// 索引 5: May
// 索引 6: June
// 索引 7: July
// 索引 8: August
// 索引 9: September
// 索引 10: October
// 索引 11: November
// 索引 12: December3. 遍历数组
a. 传统 for 循环(基于索引)
arr := [4]string{"apple", "banana", "cherry", "date"}
for i := 0; i < len(arr); i++ {
// 访问 arr[i]
fmt.Printf("索引 %d: %s\n", i, arr[i])
}b. for range 循环(基于值)
同时返回索引和对应的值。
for index, value := range arr {
// 处理 index 和 value
}
// 如果不需要索引,使用下划线 _ 忽略
for _, value := range arr {
// ...
}4. 数组的常见操作
a. 比较数组是否相等
package main
import "fmt"
func main() {
arr1 := [3]int{1, 2, 3}
arr2 := [3]int{1, 2, 3}
arr3 := [3]int{1, 2, 4}
// 可以直接使用 == 比较相同类型的数组
fmt.Println(arr1 == arr2) // true
fmt.Println(arr1 == arr3) // false
}b. 数组求和
package main
import "fmt"
func arraySum(arr [5]int) int {
sum := 0
for _, value := range arr {
sum += value
}
return sum
}
func main() {
numbers := [5]int{10, 20, 30, 40, 50}
total := arraySum(numbers)
fmt.Printf("数组元素之和: %d\n", total) // 150
}5. 数组的底层实现详解
理解数组的底层实现,有助于我们更好地掌握其特性和性能表现。
(1) 数组在内存中的存储方式
数组是一段连续的内存空间,用于存储相同类型的元素。数组的所有元素在内存中是紧密排列的,没有任何间隙。
内存布局示意图:
数组:arr := [5]int{10, 20, 30, 40, 50}
内存地址 元素值
0xc0000b4000 10 ← arr[0]
0xc0000b4008 20 ← arr[1]
0xc0000b4010 30 ← arr[2]
0xc0000b4018 40 ← arr[3]
0xc0000b4020 50 ← arr[4]
每个 int 占用 8 字节(64位系统)
数组总大小 = 5 × 8 = 40 字节(2) 数组的内存特性
| 特性 | 描述 |
|---|---|
| 连续性 | 所有元素在内存中连续存储,相邻元素地址相差一个元素大小。 |
| 固定大小 | 数组大小在编译期确定,运行时不可改变。 |
| 直接访问 | 通过索引可以 O(1) 时间复杂度访问任意元素:address = base_address + index × element_size |
| 栈分配 | 小数组通常分配在栈上(速度快),大数组可能分配在堆上。 |
(3) 数组是值类型的底层原因
数组在 Go 中被设计为值类型,这意味着:
a. 赋值时完整复制内存
package main
import "fmt"
func main() {
arr1 := [3]int{1, 2, 3}
arr2 := arr1 // 完整复制 arr1 的所有字节到 arr2
fmt.Printf("arr1 地址: %p, 值: %v\n", &arr1, arr1)
fmt.Printf("arr2 地址: %p, 值: %v\n", &arr2, arr2)
arr2[0] = 999
fmt.Printf("修改后 arr1: %v\n", arr1) // [1 2 3] - 未改变
fmt.Printf("修改后 arr2: %v\n", arr2) // [999 2 3]
// 输出示例:
// arr1 地址: 0xc0000b4000, 值: [1 2 3]
// arr2 地址: 0xc0000b4018, 值: [1 2 3]
// 修改后 arr1: [1 2 3]
// 修改后 arr2: [999 2 3]
}b. 底层复制过程
复制前:
arr1 (0xc0000b4000): [1] [2] [3]
arr2 (未分配)
赋值操作:arr2 := arr1
↓
1. 为 arr2 分配 3×8=24 字节内存空间
2. 逐字节复制 arr1 的内容到 arr2
3. arr2 获得独立的内存副本
复制后:
arr1 (0xc0000b4000): [1] [2] [3] ← 独立内存
arr2 (0xc0000b4018): [1] [2] [3] ← 独立内存(4) 数组大小计算
数组占用的内存大小可以通过 unsafe.Sizeof() 获取:
package main
import (
"fmt"
"unsafe"
)
func main() {
var arr1 [5]int
var arr2 [10]int
var arr3 [3]float64
var arr4 [100]byte
fmt.Printf("[5]int 大小: %d 字节\n", unsafe.Sizeof(arr1)) // 40 字节 (5×8)
fmt.Printf("[10]int 大小: %d 字节\n", unsafe.Sizeof(arr2)) // 80 字节 (10×8)
fmt.Printf("[3]float64 大小: %d 字节\n", unsafe.Sizeof(arr3)) // 24 字节 (3×8)
fmt.Printf("[100]byte 大小: %d 字节\n", unsafe.Sizeof(arr4)) // 100 字节 (100×1)
// 公式:数组大小 = 元素数量 × 单个元素大小
}(5) 数组与切片的底层对比
package main
import (
"fmt"
"unsafe"
)
func main() {
// 数组
arr := [5]int{1, 2, 3, 4, 5}
fmt.Printf("数组大小: %d 字节\n", unsafe.Sizeof(arr)) // 40 字节 (5×8)
// 切片
slice := []int{1, 2, 3, 4, 5}
fmt.Printf("切片大小: %d 字节\n", unsafe.Sizeof(slice)) // 24 字节 (固定)
// 切片的结构体包含三个字段:
// - Data: 指针 (8 字节)
// - Len: 整数 (8 字节)
// - Cap: 整数 (8 字节)
// 总计: 24 字节(与元素数量无关)
}对比总结:
数组 [5]int:
+----+----+----+----+----+
| 1 | 2 | 3 | 4 | 5 | ← 完整的数据(40 字节)
+----+----+----+----+----+
切片 []int:
+---------+-------+-------+
| Data * | Len=5 | Cap=5 | ← 切片头(24 字节)
+---------+-------+-------+
↓
+----+----+----+----+----+
| 1 | 2 | 3 | 4 | 5 | ← 底层数组
+----+----+----+----+----+(6) 性能影响分析
a. 大数组的复制开销
package main
import (
"fmt"
"time"
)
// 值传递:复制整个数组
func sumByValue(arr [10000]int) int {
sum := 0
for _, v := range arr {
sum += v
}
return sum
}
// 指针传递:只复制指针(8 字节)
func sumByPointer(arr *[10000]int) int {
sum := 0
for _, v := range arr {
sum += v
}
return sum
}
func main() {
var bigArr [10000]int
for i := 0; i < 10000; i++ {
bigArr[i] = i
}
// 测试值传递
start := time.Now()
for i := 0; i < 10000; i++ {
sumByValue(bigArr)
}
fmt.Printf("值传递耗时: %v\n", time.Since(start))
// 测试指针传递
start = time.Now()
for i := 0; i < 10000; i++ {
sumByPointer(&bigArr)
}
fmt.Printf("指针传递耗时: %v\n", time.Since(start))
// 值传递会慢得多,因为每次调用都要复制 80KB 数据
}b. 访问性能优势
由于数组元素在内存中连续存储,具有优秀的缓存局部性(Cache Locality),访问相邻元素时 CPU 缓存命中率高,性能优异。
// 数组的连续访问效率高
for i := 0; i < len(arr); i++ {
arr[i] = i // 顺序访问,缓存友好
}二、切片 (Slice)
在 Go 语言里,切片(Slice)是极为常用的动态数组结构。它是对底层数组中连续片段的引用,属于引用类型。
和数组一样,切片能容纳若干相同类型的元素。但与数组不同,从切片类型无法确定其元素数量。每个切片都以一个数组作为底层数据结构,此数组即切片的底层数组。
切片引用的连续片段,可以是整个底层数组,也可以由起始和终止索引标识部分元素,需注意,这是左闭右开区间,终止索引对应的元素不在切片内。
1. 切片的核心特性
| 特性 | 描述 |
|---|---|
| 长度可变 | 长度动态变化,支持 append 增加元素。 |
| 引用类型 | 切片是引用类型。赋值和传参时,底层数组共享。 |
| 底层数组 | 每个切片都关联一个数组作为其底层数据结构。 |
| 零值 | 切片的零值是 nil(没有底层数组引用)。 |
2. 创建切片的方法
(1) 通过数组进行切片 (Slicing)
切片操作是 array[low:high],区间为左闭右开 [low, high)。
myArr := [5]int{1, 2, 3, 4, 5}
// 创建一个包含元素 [2, 3, 4] 的切片
mySlice := myArr[1:4]
// len: 3, cap: 4 (从索引 1 开始到数组末尾)
fmt.Printf("%d 的类型是: %T", mySlice, mySlice)
// [2 3 4] 的类型是: []int在 Go 中,切片支持多种切片表达式:
| 表达式 | 描述 |
|---|---|
s[n] | 切片 s 中索引位置为 n 的项 |
s[:] | 从切片 s 的索引位置 0 到 len(s)-1 处所获得的切片 |
s[low:] | 从切片 s 的索引位置 low 到 len(s)-1 处所获得的切片 |
s[:high] | 从切片 s 的索引位置 0 到 high 处所获得的切片,len=high |
s[low:high] | 从切片 s 的索引位置 low 到 high 处所获得的切片,len=high-low |
s[low:high:max] | 从切片 s 的索引位置 low 到 high 处所获得的切片,len=high-low,cap=max-low |
例如:
arr := []int{1, 2, 3, 4, 5}
slice1 := arr[1:3] // [2, 3]
slice2 := arr[:4] // [1, 2, 3, 4]
slice3 := arr[2:] // [3, 4, 5](2) 使用 make() 函数构造
格式:make([]Type, size, cap)
一个切片具备的三个要素:类型(Type),长度(size),容量(cap)
// 创建一个长度 len=2,容量 cap=10 的整型切片
s := make([]int, 2, 10)
// s: [0 0](3) 直接声明和初始化
// 直接声明并初始化
s := []int{10, 20, 30}
// 声明一个空切片
var nilSlice []int
// nilSlice == nil 为 true3. 切片的基本操作
(1) 添加元素:append()
append() 用于向切片末尾添加元素。
s := []int{1, 2}
s = append(s, 3, 4) // 添加多个元素
s = append(s, []int{5, 6}...) // 拼接另一个切片 (使用 ... 解包)(2) 插入元素(利用 append)
将切片分割并重新拼接以实现插入:
s := []int{1, 2, 5, 6}
// 在索引 2 处插入 [3, 4]
s = append(s[:2], append([]int{3, 4}, s[2:]...)...)
// s: [1 2 3 4 5 6](3) 切片的长度 (len) 与 容量 (cap)
计算切片的长度 (len) 与 容量 (cap) :
s := []int{1, 2, 3, 4, 5}
fmt.Println("切片的长度:", len(s)) // 5
fmt.Println("切片的容量:", cap(s)) // 5注意事项:
- 访问越界: 访问元素时,索引必须小于
len。 - 容量限定: 通过切片操作
array[low:high:max]创建新切片时,长度为high - low,容量为max - low。
4. 切片的进阶操作
(1) 复制切片:copy()
内置函数 copy(dst, src) 用于将源切片 (src) 的内容复制到目标切片 (dst) 中。
copy函数只会复制 $min(len(dst), len(src))$ 个元素。- 复制的目标切片必须预先分配空间(即已经存在)。
package main
import "fmt"
func main() {
src := []int{10, 20, 30, 40}
dst1 := make([]int, 2) // 目标切片长度为 2
// 只复制前 2 个元素
n1 := copy(dst1, src)
fmt.Printf("dst1: %v, 复制了 %d 个元素\n", dst1, n1) // dst1: [10 20], 复制了 2 个元素
dst2 := make([]int, 6) // 目标切片长度为 6
// 复制全部 4 个元素
n2 := copy(dst2, src)
fmt.Printf("dst2: %v, 复制了 %d 个元素\n", dst2, n2) // dst2: [10 20 30 40 0 0], 复制了 4 个元素
}(2) 删除元素(利用 append)
Go 语言没有专门的 delete 函数来操作切片,通常是通过 append 操作来“跳过”要删除的元素,从而达到删除的目的。
package main
import "fmt"
func main() {
s := []string{"A", "B", "C", "D", "E"}
// 1. 删除中间元素:删除索引为 2 的元素 ("C")
// s[:2] -> [A B]
// s[3:] -> [D E]
// append(s[:2], s[3:]...) -> [A B D E]
s = append(s[:2], s[3:]...)
fmt.Println("删除中间元素后:", s) // [A B D E]
// 2. 删除尾部元素:删除最后一个元素 ("E")
s = s[:len(s)-1]
fmt.Println("删除尾部元素后:", s) // [A B D]
// 3. 删除头部元素:删除第一个元素 ("A")
s = s[1:]
fmt.Println("删除头部元素后:", s) // [B D]
}注意: 这种删除操作可能导致被删除元素后面的内存区域无法被立即回收(因为切片可能仍保留对底层数组的引用),这在大数据量操作时需要注意内存泄漏问题。
5. 切片的底层实现详解
虽然切片在 Go 语法上表现为「引用类型」,但它本身的数据结构其实是一个小的 struct(切片头),由 3 个部分组成:
- 指针 (Data): 指向切片引用的底层数组的起始位置。
- 长度 (len): 切片中当前元素的个数。
- 容量 (cap): 从切片的起始引用点开始,到底层数组末尾的元素个数。
type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 切片长度
cap int // 切片容量
}内存布局示意图:
切片头(slice header) 底层数组
+-----------------+
| array ----------|-------> [0] [1] [2] [3] [4] [5]
| len = 3 | ^ ^ ^
| cap = 6 | | | |
+-----------------+ | | |
| | |
切片视图 s[0:3] 可见范围 ------+---+---+(1) 长度 (len) 与 容量 (cap) 的区别和注意事项
| 属性 | 长度 (len) | 容量 (cap) |
|---|---|---|
| 含义 | 当前元素数 | 底层数组可容纳的最大元素数 |
| 用途 | 迭代范围 | 决定是否需要扩容 |
注意事项:
- 访问越界: 访问元素时,索引必须小于
len。 - 共享底层: 多个切片可以引用同一个底层数组。修改其中一个切片的元素,会影响到所有引用相同底层数组的切片。
- 容量限定: 通过切片操作
array[low:high:max]创建新切片时,长度为high - low,容量为max - low。
(2) 切片的赋值和传递
在进行切片赋值时,是将切片头结构体做了值拷贝,切片自身的 Len 和 Cap 会复制,但底层数组不会复制,两个切片的数据彼此关联。
示例:
func demoSliceCopy() {
a := []int{1, 2, 3}
b := a // 切片头值拷贝,底层数组共享
b[0] = 99 // 修改 b 的第一个元素
fmt.Println("a:", a) // 输出: a: [99 2 3]
fmt.Println("b:", b) // 输出: b: [99 2 3]
//理解切片头的内存地址和底层数组地址
fmt.Printf("%p,%p\n", &a, &b) // 打印切片 a 和 b 结构体的内存地址
fmt.Printf("%p,%p\n", a, b) // 打印切片 a 和 b 所指向的底层数组的起始地址
fmt.Printf("%p,%p", &a[0], &b[0]) // 打印切片 a 和 b 的第一个元素的内存地址
}这种设计,使得切片的复制和传递高效且行为清晰。
(3) 底层扩容机制
当使用 append() 导致切片长度达到容量 (len == cap) 时,Go 会触发扩容:
分配一个新的、更大的底层数组。
将原数组元素复制到新数组。
切片的指针 (
Data) 指向新数组。扩容过程: 当我们使用
append向切片添加元素,如果容量不足,Go 的扩容过程如下:
// 演示切片扩容过程
func demonstrateAppend() {
s := make([]int, 3, 5)
s[0], s[1], s[2] = 1, 2, 3
fmt.Printf("切片s: %v, 长度: %d, 容量: %d, 地址: %p\n",
s, len(s), cap(s), &s[0])
// 添加元素,但不超过容量
s = append(s, 4)
fmt.Printf("添加一个元素后 - 切片s: %v, 长度: %d, 容量: %d, 地址: %p\n",
s, len(s), cap(s), &s[0])
// 添加元素,超过容量
s = append(s, 5, 6)
fmt.Printf("添加两个元素后 - 切片s: %v, 长度: %d, 容量: %d, 地址: %p\n",
s, len(s), cap(s), &s[0])
}扩容过程:
- 分配新的、更大的底层数组
- 将原数组内容复制到新数组
- 创建新的切片结构体,指向新数组
- 返回新的切片
6. 切片作为函数参数
切片是引用类型,这意味着它作为函数参数传递时,传递的是切片头(Data、Len、Cap)的副本。虽然切片头本身是值拷贝,但由于它内部的 Data 指针指向底层数组,因此对切片元素值的修改会影响到原始切片。
package main
import "fmt"
func modifySlice(s []int) {
s[0] = 999 // 修改元素值,会影响原始切片
s = append(s, 100) // 使用 append,如果发生扩容,s的Data指针会改变,不影响原始切片
fmt.Println("函数内切片:", s)
}
func main() {
original := []int{1, 2, 3}
fmt.Println("原始切片 (前):", original) // [1 2 3]
modifySlice(original)
fmt.Println("原始切片 (后):", original) // [999 2 3]
// 结论:
// 1. 在函数内通过索引修改元素值,**会**影响外部原始切片。
// 2. 在函数内对切片进行 append,如果导致扩容,切片会指向新的底层数组,**不会**影响外部原始切片(除非将返回的新切片赋值给外部)。
}7. 切片的实际应用案例
(1) 单词分割
package main
import (
"fmt"
"strings"
)
func splitWords(s string) []string {
// 将字符串按空格分割成切片
words := strings.Fields(s)
return words
}
func main() {
text := "hello world golang"
words := splitWords(text)
fmt.Println(words) // [hello world golang]
}(2) 整数切片排序
package main
import (
"fmt"
"sort"
)
func main() {
// 对整数切片排序
numbers := []int{5, 2, 8, 1, 9}
sort.Ints(numbers)
fmt.Println("排序后:", numbers) // [1 2 5 8 9]
// 降序排序
sort.Sort(sort.Reverse(sort.IntSlice(numbers)))
fmt.Println("降序:", numbers) // [9 8 5 2 1]
}(3) 字符串切片排序
package main
import (
"fmt"
"sort"
)
func main() {
// 对字符串切片排序
fruits := []string{"banana", "apple", "orange"}
sort.Strings(fruits)
fmt.Println("排序后:", fruits) // [apple banana orange]
// 按字符串长度排序
sort.Slice(fruits, func(i, j int) bool {
return len(fruits[i]) < len(fruits[j])
})
fmt.Println("按长度:", fruits)
}(4) 数字去重
package main
import "fmt"
func removeDuplicates(nums []int) []int {
seen := make(map[int]bool)
result := make([]int, 0)
for _, num := range nums {
if !seen[num] {
seen[num] = true
result = append(result, num)
}
}
return result
}
func main() {
numbers := []int{1, 2, 2, 3, 3, 4}
unique := removeDuplicates(numbers)
fmt.Println(unique) // [1 2 3 4]
}三、值类型和引用类型的区别
数组是值类型,切片是引用类型。他们的区别代表这不同的内存分配方式和数据传递方式。
四、数组与切片的区别总结
| 特性 | 数组 (Array) | 切片 (Slice) |
|---|---|---|
| 长度 | 固定不变。长度是类型的一部分。 | 动态可变。长度不是类型的一部分 ([]int)。 |
| 类型 | 值类型 (Value Type)。 | 引用类型 (Reference Type)。 |
| 赋值/传参 | 复制整个数组内容(全量复制)。 | 复制切片头(指针、长度、容量),底层数组共享。 |
| 操作 | 长度固定,不支持 append 等动态操作。 | 支持 append、copy 等操作,可动态增长。 |
| 零值 | 元素的零值。 | nil。 |
| 使用场景 | 长度已知且固定,性能要求高的底层结构。 | 所有需要序列容器的场景,更灵活、常用。 |