MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Go 语言数组与切片的区别与适用场景

2021-08-143.1k 阅读

一、Go 语言数组

  1. 数组的定义与声明 在 Go 语言中,数组是具有固定长度且类型相同的元素序列。其声明方式如下:
var arr [5]int

上述代码声明了一个名为 arr 的数组,它可以容纳 5 个 int 类型的元素。数组的长度是其类型的一部分,这意味着 [5]int[10]int 是不同的类型。

也可以在声明数组的同时进行初始化:

var arr1 = [3]int{1, 2, 3}
arr2 := [4]string{"a", "b", "c", "d"}

还可以使用 ... 让编译器自动推断数组的长度:

arr3 := [...]int{4, 5, 6}

这里 arr3 的长度会被推断为 3。

  1. 数组的内存布局 数组在内存中是连续存储的。以 [5]int 数组为例,假设每个 int 类型占用 8 个字节(在 64 位系统下),那么这个数组将占用 40 个字节的连续内存空间。数组的第一个元素位于数组内存地址的起始位置,后续元素依次紧密排列。

这种连续存储的方式使得通过索引访问数组元素非常高效,因为可以通过简单的内存地址计算快速定位到目标元素。例如,对于数组 arr,访问 arr[i] 时,其内存地址为数组起始地址加上 i * sizeof(int)

  1. 数组的操作
    • 访问元素:通过索引来访问数组中的元素,索引从 0 开始。例如:
arr := [3]int{10, 20, 30}
fmt.Println(arr[1]) 

上述代码将输出 20。

- **修改元素**:可以通过索引对数组元素进行修改。
arr := [3]int{10, 20, 30}
arr[2] = 40
fmt.Println(arr[2]) 

此时输出将变为 40。

  1. 数组作为函数参数 当数组作为函数参数传递时,传递的是数组的副本,而不是数组的引用。这意味着在函数内部对数组的修改不会影响到函数外部的原数组。例如:
package main

import "fmt"

func modifyArray(arr [3]int) {
    arr[0] = 100
}

func main() {
    originalArr := [3]int{1, 2, 3}
    modifyArray(originalArr)
    fmt.Println(originalArr[0]) 
}

在上述代码中,modifyArray 函数内部对 arr 的修改不会影响到 originalArr,所以输出仍然是 1。

二、Go 语言切片

  1. 切片的定义与声明 切片是对数组的一个连续片段的引用,它提供了一种灵活且动态的数组操作方式。切片的声明方式如下:
var sl []int

上述代码声明了一个名为 sl 的切片,此时它的值为 nil,即空切片。也可以基于已有的数组或切片创建新的切片:

arr := [5]int{1, 2, 3, 4, 5}
sl1 := arr[1:3] 

这里 sl1 是基于数组 arr 创建的切片,它包含 arr 中索引 1 到 2 的元素(不包含索引 3 的元素),即 [2, 3]

还可以直接创建切片并初始化:

sl2 := []int{6, 7, 8}
  1. 切片的数据结构 切片在 Go 语言内部是一个结构体,其定义大致如下:
type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

array 是指向底层数组的指针,len 表示切片当前的长度,即切片中实际包含的元素个数,cap 表示切片的容量,即底层数组从切片起始位置到末尾的元素个数。

例如,基于 [5]int{1, 2, 3, 4, 5} 创建的切片 arr[1:3],其 array 指向 arr 数组中索引为 1 的元素,len 为 2,cap 为 4(因为从索引 1 到数组末尾有 4 个元素)。

  1. 切片的操作
    • 访问元素:和数组一样,通过索引访问切片元素,索引从 0 开始。
sl := []int{10, 20, 30}
fmt.Println(sl[1]) 

输出为 20。

- **修改元素**:同样可以通过索引修改切片元素的值。
sl := []int{10, 20, 30}
sl[2] = 40
fmt.Println(sl[2]) 

此时输出为 40。

- **追加元素**:使用 `append` 函数可以向切片中追加元素。如果当前切片的容量不足以容纳新元素,会自动重新分配内存,创建一个新的底层数组,并将原切片的内容复制到新数组中。
sl := []int{1, 2, 3}
sl = append(sl, 4)
fmt.Println(sl) 

输出为 [1 2 3 4]

- **扩展切片**:可以使用 `make` 函数创建具有指定长度和容量的切片。
sl := make([]int, 3, 5) 

这里创建了一个长度为 3,容量为 5 的切片,初始元素值为 0。

  1. 切片作为函数参数 当切片作为函数参数传递时,传递的是切片结构体的副本,其中包含指向底层数组的指针。这意味着在函数内部对切片元素的修改会影响到函数外部,因为它们共享底层数组。例如:
package main

import "fmt"

func modifySlice(sl []int) {
    sl[0] = 100
}

func main() {
    originalSl := []int{1, 2, 3}
    modifySlice(originalSl)
    fmt.Println(originalSl[0]) 
}

在上述代码中,modifySlice 函数内部对 sl 的修改会影响到 originalSl,所以输出为 100。

三、数组与切片的区别

  1. 长度的固定性

    • 数组:数组的长度在声明时就固定下来,一旦确定就不能改变。例如 [5]int 数组始终只能容纳 5 个元素,不能动态增加或减少其长度。
    • 切片:切片的长度是动态可变的,可以通过 append 等函数灵活地增加或减少元素,从而改变其长度。
  2. 内存分配与管理

    • 数组:数组在声明时就会分配固定大小的连续内存空间,其内存大小由数组的长度和元素类型决定。例如 [10]int 数组会一次性分配 80 字节(假设 int 为 8 字节)的内存。
    • 切片:切片本身是一个结构体,只占用少量的固定内存(包含指向底层数组的指针、长度和容量),其底层数组的内存分配是动态的。当使用 append 函数且当前容量不足时,会重新分配内存,将原切片内容复制到新的底层数组中。
  3. 数据类型本质

    • 数组:数组的类型包含其长度信息,例如 [3]int[5]int 是完全不同的类型,即使它们的元素类型相同。
    • 切片:切片的类型只由其元素类型决定,例如 []int,不同的切片只要元素类型相同就是同一类型,其长度和容量不影响类型的一致性。
  4. 传递方式

    • 数组:作为函数参数传递时,传递的是数组的副本,函数内部对数组的修改不会影响外部原数组,这可能导致在处理大数据量数组时性能问题,因为复制数组需要消耗额外的时间和内存。
    • 切片:作为函数参数传递时,传递的是切片结构体的副本,由于结构体中包含指向底层数组的指针,所以函数内部对切片元素的修改会影响外部,这种方式在处理大数据量时更高效,因为只复制了少量的结构体信息。
  5. 初始化方式

    • 数组:可以在声明时初始化全部元素,也可以使用 ... 让编译器推断长度进行初始化。
    • 切片:可以基于数组创建,也可以直接初始化,还可以使用 make 函数创建并指定长度和容量进行初始化。

四、数组与切片的适用场景

  1. 数组的适用场景
    • 需要固定大小的数据集合:当你确切知道数据的数量且不会改变时,数组是一个合适的选择。例如,在表示 RGB 颜色值时,通常使用 [3]int 数组,分别表示红、绿、蓝三个分量,因为 RGB 颜色值始终由三个固定的分量组成。
type RGB struct {
    color [3]int
}

func newRGB(red, green, blue int) RGB {
    return RGB{[3]int{red, green, blue}}
}
- **性能敏感且数据量小的场景**:由于数组的内存布局是连续的,通过索引访问元素非常高效。在一些对性能要求极高且数据量不大的场景下,使用数组可以避免切片动态内存分配和管理带来的额外开销。例如,在一些简单的游戏开发中,用于表示固定数量的游戏角色属性(如生命值、攻击力等),使用数组可以提高数据访问速度。

2. 切片的适用场景 - 数据量动态变化的场景:在大多数实际应用中,数据的数量是不确定的,会随着程序的运行而动态增加或减少。例如,在 Web 服务器中处理用户请求,需要动态存储请求的数据,切片就非常适合这种场景。可以不断使用 append 函数向切片中添加新的请求数据。

var requests []Request

func handleRequest(req Request) {
    requests = append(requests, req)
}
- **作为函数参数传递大量数据**:由于切片作为函数参数传递时只复制少量的结构体信息,而不是整个数据集合,所以在传递大量数据时性能更好。例如,在数据处理程序中,将大量的日志数据传递给分析函数时,使用切片可以避免数组复制带来的性能开销。
func analyzeLogs(logs []LogEntry) {
    // 分析日志数据
}

func main() {
    var logs []LogEntry
    // 收集日志数据并添加到logs切片中
    analyzeLogs(logs)
}
- **需要灵活操作数据集合的场景**:切片提供了丰富的操作函数,如 `append`、`copy` 等,使得对数据集合的操作更加灵活。例如,在实现一个简单的栈数据结构时,使用切片可以方便地实现入栈和出栈操作。
type Stack struct {
    data []int
}

func (s *Stack) Push(val int) {
    s.data = append(s.data, val)
}

func (s *Stack) Pop() int {
    if len(s.data) == 0 {
        panic("stack is empty")
    }
    top := s.data[len(s.data)-1]
    s.data = s.data[:len(s.data)-1]
    return top
}

综上所述,Go 语言中的数组和切片各有特点,在实际编程中应根据具体的需求和场景选择合适的数据结构,以达到最佳的性能和编程效率。在数据量固定且对性能要求较高的场景下,数组是不错的选择;而在数据量动态变化、需要灵活操作数据集合或传递大量数据的场景中,切片则更为适用。通过深入理解它们的区别和适用场景,开发者能够更加高效地编写 Go 语言程序。