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

Go 语言切片(Slice)的创建与初始化方法及性能对比

2021-02-144.3k 阅读

Go 语言切片基础概念

在深入探讨 Go 语言切片的创建与初始化方法及性能对比之前,我们先来回顾一下切片的基本概念。在 Go 语言中,切片(Slice)是一种动态数组,与数组(Array)不同,切片的长度是可以动态变化的。切片是基于数组类型做的一层封装,它提供了比数组更强大、灵活的功能。

切片的结构在 Go 语言的源码中定义如下(简化版):

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

其中,array 指向底层数组,len 表示切片当前的长度,cap 表示切片的容量,即底层数组的大小。

切片的创建方法

使用 make 函数创建切片

make 函数是 Go 语言中用于创建切片、映射(map)和通道(channel)的内置函数。使用 make 函数创建切片的语法如下:

make([]T, length, capacity)

其中,T 是切片元素的类型,length 是切片的初始长度,capacity 是切片的初始容量。capacity 参数是可选的,如果不指定,默认与 length 相同。

下面是一些使用 make 函数创建切片的示例:

package main

import "fmt"

func main() {
    // 创建一个长度为 5,容量为 10 的 int 类型切片
    s1 := make([]int, 5, 10)
    fmt.Printf("s1: length = %d, capacity = %d\n", len(s1), cap(s1))

    // 创建一个长度和容量都为 3 的 string 类型切片
    s2 := make([]string, 3)
    fmt.Printf("s2: length = %d, capacity = %d\n", len(s2), cap(s2))
}

在上述示例中,s1 是一个长度为 5,容量为 10 的 int 类型切片,s2 是一个长度和容量都为 3 的 string 类型切片。

基于数组创建切片

由于切片是基于数组的封装,我们可以通过对数组进行切片操作来创建切片。语法如下:

array[start:end]

其中,start 是切片的起始索引(包含),end 是切片的结束索引(不包含)。如果 start 省略,默认从 0 开始;如果 end 省略,默认到数组的末尾。

示例代码如下:

package main

import "fmt"

func main() {
    // 定义一个数组
    arr := [5]int{1, 2, 3, 4, 5}

    // 基于数组创建切片
    s1 := arr[1:3]
    fmt.Printf("s1: length = %d, capacity = %d\n", len(s1), cap(s1))

    s2 := arr[:3]
    fmt.Printf("s2: length = %d, capacity = %d\n", len(s2), cap(s2))

    s3 := arr[3:]
    fmt.Printf("s3: length = %d, capacity = %d\n", len(s3), cap(s3))
}

在这个示例中,s1 是从 arr 数组的索引 1 开始到索引 3(不包含)的切片,长度为 2,容量为 4(因为从索引 1 到数组末尾的长度为 4);s2 是从数组开头到索引 3(不包含)的切片,长度为 3,容量为 5;s3 是从索引 3 到数组末尾的切片,长度为 2,容量为 2。

使用字面量创建切片

使用字面量创建切片是一种简洁的方式,语法与数组字面量类似,但不需要指定长度。示例如下:

package main

import "fmt"

func main() {
    // 使用字面量创建切片
    s1 := []int{1, 2, 3}
    fmt.Printf("s1: length = %d, capacity = %d\n", len(s1), cap(s1))

    s2 := []string{"apple", "banana", "cherry"}
    fmt.Printf("s2: length = %d, capacity = %d\n", len(s2), cap(s2))
}

在上述代码中,s1s2 分别是通过字面量创建的 int 类型和 string 类型的切片,它们的长度和容量都等于初始化时元素的个数。

切片的初始化方法

初始化切片元素

当使用 make 函数创建切片时,可以对切片元素进行初始化。例如:

package main

import "fmt"

func main() {
    // 创建并初始化一个长度为 3 的 int 类型切片
    s1 := make([]int, 3)
    s1[0] = 1
    s1[1] = 2
    s1[2] = 3
    fmt.Println(s1)

    // 创建并初始化一个长度为 2 的 string 类型切片
    s2 := make([]string, 2)
    s2[0] = "hello"
    s2[1] = "world"
    fmt.Println(s2)
}

在这个示例中,我们先使用 make 函数创建了切片,然后通过索引对切片元素进行初始化。

使用初始化列表初始化切片

使用字面量创建切片时,可以直接在初始化列表中指定元素值,这种方式在初始化少量元素时非常方便。例如:

package main

import "fmt"

func main() {
    // 使用初始化列表初始化切片
    s1 := []int{1, 2, 3, 4, 5}
    fmt.Println(s1)

    s2 := []string{"red", "green", "blue"}
    fmt.Println(s2)
}

这里,s1s2 分别通过初始化列表初始化了 int 类型和 string 类型的切片。

切片创建与初始化方法的性能对比

不同创建方法的性能分析

  1. 使用 make 函数:当使用 make 函数创建切片时,Go 语言会根据指定的长度和容量分配底层数组的内存。如果容量足够,后续的元素追加操作不会导致内存重新分配,从而提高性能。例如,预先分配足够容量的切片在进行大量元素追加时,性能会优于每次追加都导致内存重新分配的情况。
package main

import (
    "fmt"
    "time"
)

func main() {
    start := time.Now()
    s1 := make([]int, 0, 1000000)
    for i := 0; i < 1000000; i++ {
        s1 = append(s1, i)
    }
    elapsed := time.Since(start)
    fmt.Printf("Using make: %s\n", elapsed)
}

在这个示例中,我们预先使用 make 函数创建了一个容量为 1000000 的切片 s1,然后向其中追加 1000000 个元素。由于预先分配了足够的容量,在追加过程中不会频繁进行内存重新分配,从而提高了性能。

  1. 基于数组创建切片:基于数组创建切片的性能主要取决于底层数组的大小和切片的范围。由于切片操作本身不涉及内存分配,只是对数组的引用,因此这种方式在性能上相对高效,特别是当切片范围较小时。但是,如果底层数组很大,而切片只需要使用其中一小部分,可能会造成内存浪费。
package main

import (
    "fmt"
    "time"
)

func main() {
    start := time.Now()
    arr := make([]int, 1000000)
    for i := 0; i < 1000000; i++ {
        arr[i] = i
    }
    s1 := arr[0:100]
    elapsed := time.Since(start)
    fmt.Printf("Based on array: %s\n", elapsed)
}

在这个示例中,我们先创建了一个大小为 1000000 的数组 arr,然后基于该数组创建了一个切片 s1,只使用了数组的前 100 个元素。虽然切片操作本身性能较高,但创建大数组的过程可能会消耗较多时间和内存。

  1. 使用字面量创建切片:使用字面量创建切片在初始化少量元素时非常方便且性能较好,因为编译器可以在编译时确定切片的大小并分配内存。然而,当需要初始化大量元素时,使用字面量可能会导致代码冗长,并且性能可能不如预先分配足够容量的 make 方式。
package main

import (
    "fmt"
    "time"
)

func main() {
    start := time.Now()
    s1 := []int{}
    for i := 0; i < 1000000; i++ {
        s1 = append(s1, i)
    }
    elapsed := time.Since(start)
    fmt.Printf("Using literal: %s\n", elapsed)
}

在这个示例中,我们先使用空字面量创建了一个切片 s1,然后通过循环追加 1000000 个元素。由于开始时切片容量为 0,每次追加元素都可能导致内存重新分配,因此性能相对较差。

不同初始化方法的性能分析

  1. 逐个初始化元素:当使用 make 函数创建切片后逐个初始化元素,这种方式在元素数量较少时性能尚可,但如果元素数量较多,由于每次初始化都需要通过索引访问切片,会有一定的性能开销。
package main

import (
    "fmt"
    "time"
)

func main() {
    start := time.Now()
    s1 := make([]int, 1000000)
    for i := 0; i < 1000000; i++ {
        s1[i] = i
    }
    elapsed := time.Since(start)
    fmt.Printf("Initializing one by one: %s\n", elapsed)
}

在这个示例中,我们先创建了一个长度为 1000000 的切片 s1,然后通过循环逐个初始化元素。由于需要多次通过索引访问切片,性能会受到一定影响。

  1. 使用初始化列表:使用初始化列表初始化切片在初始化少量元素时性能较好,因为编译器可以在编译时优化内存分配。但对于大量元素,初始化列表会使代码变得冗长,并且可能会影响编译时间和内存使用。
package main

import (
    "fmt"
    "time"
)

func main() {
    start := time.Now()
    s1 := []int{}
    for i := 0; i < 1000000; i++ {
        s1 = append(s1, i)
    }
    elapsed := time.Since(start)
    fmt.Printf("Using initialization list: %s\n", elapsed)
}

在这个示例中,虽然我们使用的是通过循环追加元素的方式模拟大量元素的初始化列表情况,但可以看出,在元素数量较多时,性能相对较差。

切片容量增长策略对性能的影响

当切片的容量不足时,append 函数会重新分配内存,将原切片的内容复制到新的内存空间,并增加切片的容量。Go 语言中切片容量增长的策略大致如下:

  • 如果新的大小小于 1024 个元素,那么扩容时容量会翻倍。
  • 如果新的大小大于等于 1024 个元素,那么扩容时容量会增加原来的 1/4。

这种容量增长策略在大多数情况下能够较好地平衡内存使用和性能。例如,在需要频繁追加元素的场景中,如果预先分配的容量过小,会导致频繁的内存重新分配和数据复制,从而降低性能;而如果预先分配的容量过大,又会浪费内存。

下面是一个演示切片容量增长的示例:

package main

import "fmt"

func main() {
    s1 := make([]int, 0, 5)
    for i := 0; i < 20; i++ {
        s1 = append(s1, i)
        fmt.Printf("Length: %d, Capacity: %d\n", len(s1), cap(s1))
    }
}

在这个示例中,我们可以看到随着元素的追加,切片的容量是如何按照上述策略增长的。

优化切片创建与初始化性能的建议

  1. 预先分配足够的容量:在已知切片大致大小的情况下,使用 make 函数预先分配足够的容量,可以避免在追加元素时频繁进行内存重新分配,从而提高性能。例如,在处理大量数据时,如果预计会有 10000 个元素,可以这样创建切片:
s1 := make([]int, 0, 10000)
  1. 避免不必要的切片操作:尽量减少基于大数组创建小切片的操作,以避免内存浪费。如果只需要数组的部分元素,可以考虑其他数据结构或算法,而不是直接基于大数组创建切片。
  2. 合理使用字面量初始化:对于少量元素的切片,使用字面量初始化既方便又高效。但对于大量元素,应优先考虑预先分配容量的 make 方式。

总结

在 Go 语言中,切片的创建与初始化方法有多种,每种方法在性能上都有其特点。通过深入理解不同方法的原理和性能表现,我们可以根据具体的应用场景选择最合适的方式,以优化程序的性能。在实际开发中,要充分考虑切片的大小、元素数量以及操作的频率等因素,合理选择切片的创建与初始化方法,从而编写出高效、稳定的 Go 程序。同时,要注意切片容量增长策略对性能的影响,通过预先分配合适的容量来减少内存重新分配的次数,提高程序的运行效率。