Skip to content

1.5 数据类型:数组与切片

在 Go 语言中,数组和切片是两种非常重要且常用的数据结构,用于存储同类型元素的序列。理解它们的差异和联系,是掌握 Go 语言的关键。

一、数组 (Array)

数组(Array)是一种固定长度的、存储相同类型元素的序列。一个数组可以由零个或多个元素组成。因为数组的长度是固定的,所以在Go语言中很少直接使用数组。

1. 数组的核心特性

特性描述
长度固定数组一旦定义,其长度就不能改变。
类型组成数组的长度是其类型的一部分。例如,[5]int[10]int 是两种完全不同的类型。
值类型数组是值类型。当一个数组赋值给另一个数组或作为函数参数传递时,会复制整个数组。改变副本不会影响原始数组。
索引访问通过索引访问元素,索引从 0 开始。访问越界会触发运行时错误 (panic)。

2. 数组的定义与初始化

数组长度必须是常量。

方式一:指定长度

go
// 声明一个长度为 5 的整型数组,元素默认为 0
var arr1 [5]int 
arr1[0] = 1

方式二:使用 ... 让 Go 自动推导长度

go
// 数组长度由初始化元素的个数决定
arr2 := [...]int{1, 2, 3, 4, 5, 6}
// arr2 的类型是 [6]int

方式三:按索引号初始化

go
// 初始化长度为 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: December

3. 遍历数组

a. 传统 for 循环(基于索引)

go
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 循环(基于值)

同时返回索引和对应的值。

go
for index, value := range arr {
    // 处理 index 和 value
}

// 如果不需要索引,使用下划线 _ 忽略
for _, value := range arr {
    // ...
}

4. 数组的常见操作

a. 比较数组是否相等

go
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. 数组求和

go
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. 赋值时完整复制内存

go
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() 获取:

go
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) 数组与切片的底层对比

go
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. 大数组的复制开销

go
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 缓存命中率高,性能优异。

go
// 数组的连续访问效率高
for i := 0; i < len(arr); i++ {
    arr[i] = i // 顺序访问,缓存友好
}

二、切片 (Slice)

在 Go 语言里,切片(Slice)是极为常用的动态数组结构。它是对底层数组中连续片段的引用,属于引用类型。

和数组一样,切片能容纳若干相同类型的元素。但与数组不同,从切片类型无法确定其元素数量。每个切片都以一个数组作为底层数据结构,此数组即切片的底层数组。

切片引用的连续片段,可以是整个底层数组,也可以由起始和终止索引标识部分元素,需注意,这是左闭右开区间,终止索引对应的元素不在切片内。

1. 切片的核心特性

特性描述
长度可变长度动态变化,支持 append 增加元素。
引用类型切片是引用类型。赋值和传参时,底层数组共享
底层数组每个切片都关联一个数组作为其底层数据结构。
零值切片的零值是 nil(没有底层数组引用)。

2. 创建切片的方法

(1) 通过数组进行切片 (Slicing)

切片操作是 array[low:high],区间为左闭右开 [low, high)

go
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

例如:

go
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)

go
// 创建一个长度 len=2,容量 cap=10 的整型切片
s := make([]int, 2, 10) 
// s: [0 0]

(3) 直接声明和初始化

go
// 直接声明并初始化
s := []int{10, 20, 30}
// 声明一个空切片
var nilSlice []int 
// nilSlice == nil 为 true

3. 切片的基本操作

(1) 添加元素:append()

append() 用于向切片末尾添加元素。

go
s := []int{1, 2}
s = append(s, 3, 4)       // 添加多个元素
s = append(s, []int{5, 6}...) // 拼接另一个切片 (使用 ... 解包)

(2) 插入元素(利用 append

将切片分割并重新拼接以实现插入:

go
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) :

go
s := []int{1, 2, 3, 4, 5}
fmt.Println("切片的长度:", len(s)) // 5
fmt.Println("切片的容量:", cap(s)) // 5

注意事项:

  1. 访问越界: 访问元素时,索引必须小于 len
  2. 容量限定: 通过切片操作 array[low:high:max] 创建新切片时,长度为 high - low容量为 max - low

4. 切片的进阶操作

(1) 复制切片:copy()

内置函数 copy(dst, src) 用于将源切片 (src) 的内容复制到目标切片 (dst) 中。

  • copy 函数只会复制 $min(len(dst), len(src))$ 个元素。
  • 复制的目标切片必须预先分配空间(即已经存在)。
go
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 操作来“跳过”要删除的元素,从而达到删除的目的。

go
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 个部分组成:

  1. 指针 (Data): 指向切片引用的底层数组的起始位置。
  2. 长度 (len): 切片中当前元素的个数。
  3. 容量 (cap): 从切片的起始引用点开始,到底层数组末尾的元素个数。
go
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)
含义当前元素数底层数组可容纳的最大元素数
用途迭代范围决定是否需要扩容

注意事项:

  1. 访问越界: 访问元素时,索引必须小于 len
  2. 共享底层: 多个切片可以引用同一个底层数组。修改其中一个切片的元素,会影响到所有引用相同底层数组的切片。
  3. 容量限定: 通过切片操作 array[low:high:max] 创建新切片时,长度为 high - low容量为 max - low

(2) 切片的赋值和传递

在进行切片赋值时,是将切片头结构体做了值拷贝,切片自身的 LenCap 会复制,但底层数组不会复制,两个切片的数据彼此关联。

示例:

go
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 会触发扩容

  1. 分配一个新的、更大的底层数组

  2. 将原数组元素复制到新数组。

  3. 切片的指针 (Data) 指向新数组。

  4. 扩容过程: 当我们使用 append 向切片添加元素,如果容量不足,Go 的扩容过程如下:

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])
}

扩容过程:

  1. 分配新的、更大的底层数组
  2. 将原数组内容复制到新数组
  3. 创建新的切片结构体,指向新数组
  4. 返回新的切片

6. 切片作为函数参数

切片是引用类型,这意味着它作为函数参数传递时,传递的是切片头(Data、Len、Cap)的副本。虽然切片头本身是值拷贝,但由于它内部的 Data 指针指向底层数组,因此对切片元素值的修改会影响到原始切片。

go
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) 单词分割

go
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) 整数切片排序

go
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) 字符串切片排序

go
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) 数字去重

go
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 等动态操作。支持 appendcopy 等操作,可动态增长。
零值元素的零值。nil
使用场景长度已知且固定,性能要求高的底层结构。所有需要序列容器的场景,更灵活、常用。