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

Go数组与切片的区别

2023-04-205.2k 阅读

Go 数组

在 Go 语言中,数组是一种固定长度的同类型元素序列。数组在声明时就确定了其长度,之后长度不可改变。

数组的声明与初始化

  1. 声明 最简单的数组声明方式如下:
var a [5]int

这声明了一个名为 a 的数组,它包含 5 个 int 类型的元素。数组的下标从 0 开始,因此可以通过 a[0]a[4] 来访问这些元素。

  1. 初始化 可以在声明数组的同时进行初始化:
var b [3]string = [3]string{"apple", "banana", "cherry"}

也可以使用简短声明并初始化:

c := [4]int{1, 2, 3, 4}

如果初始化时提供的值少于数组的长度,未指定的元素将被初始化为其类型的零值。例如:

d := [5]int{1, 2}
// d 为 [1, 2, 0, 0, 0]

还可以通过指定索引来初始化特定元素:

e := [5]int{2: 100, 4: 200}
// e 为 [0, 0, 100, 0, 200]

数组的内存布局

数组在内存中是连续存储的。例如,对于 [5]int 类型的数组,假设每个 int 类型占用 4 个字节(在 32 位系统上),那么这个数组将占用 20 个字节的连续内存空间。这种连续存储的特性使得数组在遍历和访问时效率较高,因为可以通过简单的指针运算快速定位到每个元素。

数组的大小与类型

数组的大小是其类型的一部分。[5]int[10]int 是不同的类型,即使它们的元素类型相同。这意味着不能将一个 [5]int 类型的数组赋值给一个 [10]int 类型的变量,反之亦然。

数组作为函数参数

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

package main

import "fmt"

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

func main() {
    a := [3]int{1, 2, 3}
    modifyArray(a)
    fmt.Println(a) // 输出 [1, 2, 3]
}

这种按值传递的方式在数组较大时可能会导致性能问题,因为复制整个数组需要消耗额外的时间和内存。

Go 切片

切片是对数组的一种动态封装,它提供了一种灵活且高效的方式来操作同类型元素序列。与数组不同,切片的长度是可变的。

切片的声明与初始化

  1. 基于数组创建切片 可以基于一个已有的数组来创建切片:
var arr [5]int = [5]int{1, 2, 3, 4, 5}
s1 := arr[1:3]

这里 s1 是从 arr 数组的索引 1(包含)到索引 3(不包含)创建的切片,s1 包含元素 23

  1. 使用 make 函数创建切片 make 函数用于创建切片,其语法为 make([]T, length, capacity),其中 T 是元素类型,length 是切片的初始长度,capacity 是切片的容量(可选,默认为 length)。例如:
s2 := make([]string, 3, 5)

这创建了一个长度为 3,容量为 5 的 string 类型切片。切片的初始元素值为其类型的零值,即这里的三个元素都是空字符串。

  1. 简短声明并初始化
s3 := []int{10, 20, 30}

这种方式创建并初始化了一个长度和容量都为 3 的 int 类型切片。

切片的结构

切片实际上是一个结构体,它包含三个字段:一个指向底层数组的指针、切片的长度和切片的容量。例如,对于切片 s := []int{1, 2, 3},其内部结构如下:

  • 指针:指向底层数组中切片起始元素的位置。
  • 长度(len):表示切片中当前元素的个数,这里为 3。
  • 容量(cap):表示从切片的起始位置到底层数组末尾的元素个数,这里也为 3。

切片的内存布局

切片本身并不存储数据,它只是对底层数组的一个视图。多个切片可以共享同一个底层数组。例如:

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

s1s2 共享底层数组 arrs1 的起始位置是 arr[1]s2 的起始位置是 arr[2]

切片的长度与容量

  1. 长度:通过 len 函数获取切片的长度,即切片中当前元素的个数。例如:
s := []int{1, 2, 3}
fmt.Println(len(s)) // 输出 3
  1. 容量:通过 cap 函数获取切片的容量,即从切片的起始位置到底层数组末尾的元素个数。例如:
s := make([]int, 3, 5)
fmt.Println(cap(s)) // 输出 5

当切片的长度等于容量时,如果再向切片中添加元素,就需要重新分配内存,将底层数组扩容。

切片的扩容机制

当向切片中添加元素导致长度超过容量时,切片会自动扩容。扩容的大致规则如下:

  1. 如果新的大小小于 1024 个元素,那么新容量会是原来容量的 2 倍。
  2. 如果新的大小大于或等于 1024 个元素,那么新容量会是原来容量的 1.25 倍。 例如:
s := make([]int, 0, 5)
for i := 0; i < 10; i++ {
    s = append(s, i)
    fmt.Printf("Length: %d, Capacity: %d\n", len(s), cap(s))
}

在这个例子中,随着元素的不断添加,切片的容量会按照上述规则进行扩容。

切片作为函数参数

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

package main

import "fmt"

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

func main() {
    s := []int{1, 2, 3}
    modifySlice(s)
    fmt.Println(s) // 输出 [100, 2, 3]
}

这种特性使得切片在函数间传递时非常高效,因为不需要复制整个数据集合。

数组与切片的区别总结

  1. 长度固定性

    • 数组:长度在声明时就固定,之后不能改变。
    • 切片:长度可变,可以根据需要动态增长或缩小。
  2. 内存存储

    • 数组:直接存储元素,占用连续的内存空间。
    • 切片:本身是一个结构体,包含指向底层数组的指针、长度和容量信息,数据存储在底层数组中。
  3. 类型特性

    • 数组:数组的大小是其类型的一部分,不同大小的同类型数组是不同的类型。
    • 切片:切片类型只由元素类型决定,与长度和容量无关。
  4. 函数参数传递

    • 数组:作为函数参数传递时,传递的是数组的副本,函数内部修改不会影响外部数组。
    • 切片:作为函数参数传递时,传递的是切片结构体的副本,由于共享底层数组,函数内部修改会影响外部切片。
  5. 初始化方式

    • 数组:可以通过声明后赋值、声明时初始化等多种方式,初始化时需要指定元素个数或通过索引指定特定元素。
    • 切片:可以基于数组创建、使用 make 函数创建或简短声明并初始化,初始化方式更为灵活。
  6. 内存管理

    • 数组:内存分配在声明时确定,一旦声明,其占用的内存大小就固定不变。
    • 切片:内存分配较为灵活,当容量不足时会自动扩容,重新分配内存并复制数据。
  7. 应用场景

    • 数组:适用于需要固定大小数据集合且性能要求较高,对内存使用要求精确控制的场景,例如图形处理中的固定大小像素数组。
    • 切片:适用于数据量不确定,需要动态增长或缩小数据集合的场景,例如网络编程中接收动态长度的数据。

实际应用中的选择

在实际编程中,应根据具体需求来选择使用数组还是切片。

  1. 如果数据大小固定且已知 例如在一些数学计算中,需要固定数量的数值进行运算,此时使用数组可以提高性能和内存使用效率。比如计算一个固定维度的矩阵乘法,使用数组来存储矩阵元素是一个不错的选择。
package main

import "fmt"

func matrixMultiply(a [3][3]int, b [3][3]int) [3][3]int {
    var result [3][3]int
    for i := 0; i < 3; i++ {
        for j := 0; j < 3; j++ {
            for k := 0; k < 3; k++ {
                result[i][j] += a[i][k] * b[k][j]
            }
        }
    }
    return result
}

func main() {
    a := [3][3]int{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}}
    b := [3][3]int{{9, 8, 7}, {6, 5, 4}, {3, 2, 1}}
    result := matrixMultiply(a, b)
    for _, row := range result {
        fmt.Println(row)
    }
}
  1. 如果数据大小不确定 比如在处理用户输入的字符串列表,由于用户输入的数量不确定,使用切片更为合适。
package main

import "fmt"

func main() {
    var input string
    var strList []string
    for {
        fmt.Print("Enter a string (leave blank to stop): ")
        fmt.Scanln(&input)
        if input == "" {
            break
        }
        strList = append(strList, input)
    }
    fmt.Println("You entered:", strList)
}

注意事项

  1. 数组越界 无论是数组还是基于数组的切片,访问超出其长度范围的索引都会导致运行时错误。例如:
arr := [5]int{1, 2, 3, 4, 5}
fmt.Println(arr[5]) // 运行时错误:index out of range
  1. 切片的空值 未初始化的切片值为 nilnil 切片可以直接使用 append 函数添加元素。例如:
var s []int
s = append(s, 1)
fmt.Println(s) // 输出 [1]
  1. 切片的浅拷贝 使用 copy 函数进行切片拷贝时,是浅拷贝,即只复制切片中的元素,而不会复制底层数组。如果两个切片共享同一个底层数组,在一个切片中修改元素可能会影响到另一个切片。例如:
s1 := []int{1, 2, 3}
s2 := make([]int, len(s1))
copy(s2, s1)
s1[0] = 100
fmt.Println(s2) // 输出 [1, 2, 3],不受 s1 修改影响
  1. 数组和切片的零值 数组的零值是所有元素都为其类型的零值。切片的零值是 nil。了解这些零值对于正确初始化和使用数组与切片非常重要。例如:
var arr [5]int
fmt.Println(arr) // 输出 [0, 0, 0, 0, 0]

var s []int
fmt.Println(s) // 输出 [],零值为 nil

总结

Go 语言中的数组和切片虽然都用于存储同类型元素序列,但它们在很多方面存在显著差异。理解这些差异对于编写高效、正确的 Go 代码至关重要。在实际编程中,应根据数据的特性和需求,合理选择使用数组或切片,以充分发挥 Go 语言的优势。通过掌握数组和切片的使用,开发者能够更好地处理各种数据处理任务,无论是简单的数据集合操作还是复杂的大型项目开发。同时,注意在使用过程中的各种细节和潜在问题,如数组越界、切片的空值处理、拷贝方式等,以确保程序的稳定性和可靠性。希望通过本文的介绍,读者能够对 Go 数组与切片的区别有更深入、全面的理解,并在实际开发中灵活运用。