切片有哪些注意事項是一定要知道的呢

2023-06-10 21:00:49

1. 引言

在之前我寫了一篇 切片比陣列好用在哪 的文章,仔細介紹了切片相比於陣列的優點。但切片事實上也隱藏著一些潛在的陷阱和需要注意的細節,瞭解和掌握切片的使用注意事項,可以避免意外的程式行為。本文將深入探討Go語言切片常見的注意事項,從而能夠更好得使用切片。

2. 注意事項

2.1 注意一個陣列可以同時被多個切片參照

當建立一個切片時,它實際上是對一個底層陣列的參照。這意味著對切片的修改會直接影響到底層陣列以及其他參照該陣列的切片。這種參照關係可能導致一些意想不到的結果,下面是一個範例程式碼來說明這個問題:

package main

import "fmt"

func main() {
        array := [5]int{1, 2, 3, 4, 5}
        firstSlice := array[1:4] // 建立一個切片,參照了底層陣列的索引1到3的元素
        secondSlice := array[1:3]
        fmt.Println("Original array:", firstSlice)  // 輸出第一個切片 [2 3 4]
        fmt.Println("Original slice:", secondSlice) // 輸出第二個切片 [2 3]

        // 修改切片的第一個元素
        firstSlice[0] = 10

        fmt.Println("Modified array:", firstSlice)  // 輸出第一個切片 [10 3 4]
        fmt.Println("Modified slice:", secondSlice) // 輸出第二個切片 [10 3]
}

在上述程式碼中,我們建立了一個長度為5的陣列array和兩個參照該陣列的切片firstSlicesecondSlice。當我們修改第一個切片的第一個元素為10時,底層陣列的對應位置的元素也被修改了。這裡導致了陣列和其他參照該陣列的切片的內容也會受到影響。

如果我們有多個切片同時參照了同一個底層陣列,同時我們並不想由於對某個切片的修改,影響到另外一個切片的資料,此時我們可以新建立一個切片,使用內建的copy函數來複制原切片元素的值。範例程式碼如下:

package main

import "fmt"

func main() {
        array := [5]int{1, 2, 3, 4, 5}
        slice := array[1:4]

        // 複製切片建立一個獨立的底層陣列
        newSlice := make([]int, len(slice))
        copy(newSlice, slice)

        fmt.Println("Original array:", array) // 輸出原始陣列 [1 2 3 4 5]
        fmt.Println("Original slice:", slice) // 輸出初始切片 [2 3 4]
        fmt.Println("New slice:", newSlice)  // 輸出新建立的切片 [2 3 4]
        
        // 修改newSlice的第一個元素
        newSlice[0] = 10

        fmt.Println("Modified array:", array)// 輸出修改後的陣列 [1 2 3 4 5]
        fmt.Println("Original slice:", slice)// 輸出初始切片 [2 3 4]
        fmt.Println("New slice:", newSlice)// 輸出修改後的切片 [10 3 4]
}

通過建立了一個新的切片newSlice,它擁有獨立的底層陣列,同時使用copy函數複製原切片的值,我們現在修改newSlice不會影響原始陣列或原始切片。

2.2 注意自動擴容可能帶來的效能問題

在Go語言中,切片的容量是指底層陣列的大小,而長度是切片當前包含的元素數量。當切片的長度超過容量時,Go語言會自動擴容切片。擴容操作涉及到重新分配底層陣列,並將原有資料複製到新的陣列中。下面先通過一個範例程式碼,演示切片的自動擴容機制:

package main

import "fmt"

func main() {
        slice := make([]int, 3, 5) // 建立一個初始長度為3,容量為5的切片

        fmt.Println("Initial slice:", slice)        // 輸出初始切片 [0 0 0]
        fmt.Println("Length:", len(slice))          // 輸出切片長度 3
        fmt.Println("Capacity:", cap(slice))        // 輸出切片容量 5

        slice = append(slice, 1, 2, 3)              // 新增3個元素到切片,長度超過容量

        fmt.Println("After appending:", slice)      // 輸出擴容後的切片 [0 0 0 1 2 3]
        fmt.Println("Length:", len(slice))          // 輸出切片長度 6
        fmt.Println("Capacity:", cap(slice))        // 輸出切片容量 10
}

在上述程式碼中,我們使用make函數建立了一個初始長度為3,容量為5的切片slice。然後,我們通過append函數新增了3個元素到切片,導致切片的長度超過了容量。此時,Go語言會自動擴容切片,建立一個新的底層陣列,並將原有資料複製到新的陣列中。最終,切片的長度變為6,容量變為10。

但是切片的自動擴容機制,其實是存在效能開銷的,需要建立一個新的陣列,同時將資料全部拷貝到新陣列中,切片再參照新的陣列。下面先通過基準測試,展示沒有設定初始容量和設定了初始容量兩種情況下的效能差距:

package main

import (
        "fmt"
        "testing"
)

func BenchmarkSliceAppendNoCapacity(b *testing.B) {
        for i := 0; i < b.N; i++ {
                var slice []int
                for j := 0; j < 1000; j++ {
                        slice = append(slice, j)
                }
        }
}

func BenchmarkSliceAppendWithCapacity(b *testing.B) {
        for i := 0; i < b.N; i++ {
                slice := make([]int, 0, 1000)
                for j := 0; j < 1000; j++ {
                        slice = append(slice, j)
                }
        }
}

在上述程式碼中,我們定義了兩個基準測試函數:BenchmarkSliceAppendNoCapacityBenchmarkSliceAppendWithCapacity。其中,BenchmarkSliceAppendNoCapacity測試了在沒有設定初始容量的情況下,迴圈追加元素到切片的效能;BenchmarkSliceAppendWithCapacity測試了在設定了初始容量的情況下,迴圈追加元素到切片的效能。基準測試結果如下:

BenchmarkSliceAppendNoCapacity-4          280983              4153 ns/op           25208 B/op         12 allocs/op
BenchmarkSliceAppendWithCapacity-4       1621177              712.2 ns/op              0 B/op          0 allocs/op

其中ns/op 表示每次操作的平均執行時間,即函數執行的耗時。B/op 表示每次操作的平均記憶體分配量,即每次操作分配的記憶體大小。allocs/op 表示每次操作的平均記憶體分配次數。

可以看到,在設定了初始容量的情況下,效能要明顯優於沒有設定初始容量的情況。迴圈追加1000個元素到切片時,設定了初始容量的情況下平均每次操作耗時約為712.2納秒,而沒有設定初始容量的情況下平均每次操作耗時約為4153 納秒。這是因為設定了初始容量避免了頻繁的擴容操作,提高了效能。

所以,雖然切片的自動擴容好用,但是其也是存在代價的。更好得使用切片,應該避免頻繁的擴容操作,這裡可以在建立切片時預估所需的容量,並提前指定切片的容量,這樣可以減少擴容次數,提高效能。需要注意的是,如果你不知道切片需要多大的容量,可以使用適當的初始容量,然後根據需要動態擴容。

2.3 注意切片引數修改原始資料的陷阱

在Go語言中,切片是參照型別。當將切片作為引數傳遞給函數時,實際上是傳遞了底層陣列的參照。這意味著在函數內部修改切片的元素會影響到原始切片。下面是一個範例程式碼來說明這個問題:

package main

import "fmt"

func modifySlice(slice []int) {
     slice[0] = 10
     fmt.Println("Modified slice inside function:", slice)
}

func main() {
     originalSlice := []int{1, 2, 3}
     fmt.Println("Original slice:", originalSlice)
     modifySlice(originalSlice)
     fmt.Println("Original slice after function call:", originalSlice)
}

在上述程式碼中,我們定義了一個modifySlice函數,它接收一個切片作為引數,並在函數內部修改了切片的第一個元素,並追加了一個新元素。然後,在main函數中,我們建立了一個初始切片originalSlice,並將其作為引數傳遞給modifySlice函數。當我們執行程式碼時,輸出如下:

Original slice: [1 2 3]
Modified slice inside function: [10 2 3]
Original slice after function call: [10 2 3]

可以看到,在modifySlice函數內部,我們修改了切片的第一個元素並追加了一個新元素。這導致了函數內部切片的變化。然而,當函數返回後,原始切片originalSlice資料也受到影響。

如果我們希望函數內部的修改不影響原始切片,可以通過複製切片來解決。修改範例程式碼如下:

package main

import "fmt"

func modifySlice(slice []int) {
        newSlice := make([]int, len(slice))
        copy(newSlice, slice)

        newSlice[0] = 10
        fmt.Println("Modified slice inside function:", newSlice)
}

func main() {
        originalSlice := []int{1, 2, 3}
        fmt.Println("Original slice:", originalSlice)
        modifySlice(originalSlice)
        fmt.Println("Original slice after function call:", originalSlice)
}

通過使用make函數建立一個新的切片newSlice,並使用copy函數將原始切片複製到新切片中,我們確保了函數內部操作的是新切片的副本。這樣,在修改新切片時不會影響原始切片的值。當我們執行修改後的程式碼時,輸出如下:

Original slice: [1 2 3]
Modified slice inside function: [10 2 3]
Original slice after function call: [1 2 3]

可以看到,原始切片保持了不變,函數內部的修改隻影響了複製的切片。這樣我們可以避免在函數間傳遞切片時對原始切片造成意外修改。

3. 總結

本文深入探討了Go語言切片的一些注意事項,旨在幫助讀者更好地使用切片。

首先,切片是對底層陣列的參照。修改切片的元素會直接影響到底層陣列以及其他參照該陣列的切片。如果需要避免修改一個切片影響其他切片或底層陣列,可以使用copy函數建立一個獨立的底層陣列。

其次,切片的自動擴容可能帶來效能問題。當切片的長度超過容量時,Go語言會自動擴容切片,需要重新分配底層陣列並複製資料。為了避免頻繁的擴容操作,可以在建立切片時預估所需的容量,並提前指定切片的容量。

最後,需要注意切片作為引數傳遞給函數時,函數內部的修改會影響到原始切片。如果希望函數內部的修改不影響原始切片,可以通過複製切片來解決。

瞭解和掌握這些切片的注意事項和技巧,可以避免意外的程式行為。