跳至主要内容

[掘竅] 使用 slice of pointers 或 slice of structs

TL;DR

如果這個 slice 或 slice 中的元素有可能會被操作修改,那麼就用 slice of pointers;如果這個 slice 單純只是拿來讀取,那麼就用 slice of structs;或者也可以無腦的使用 slice of pointers 以避免後續不小心而產生的 bug。

為什麼會看到 slice of pointers 的用法

在 Golang 中常會看到 slice 中存放的是 slice of pointers,使用 slice of pointers 或 slice of structs 有幾個很重要的差別。先來看看下面的程式碼。

假設我們先定義一個名為 Vertex 的 struct:

type Vertex struct {
X int
Y int
}

接者在建立 slice of struct Vertex:

  • 當我們把「slice 中的 struct」取出來的時候,實際上是把 slice[1] 複製一份到 point1 裡面,因此即使修改了 point1 內的 X 和 Y,在輸出 slice 時並不會看到改變
func main() {
var slice []Vertex
for i := 0; i < 4; i++ {
slice = append(slice, Vertex{i * 10, i * 100})
}

// before change: [{X:0 Y:0} {X:10 Y:100} {X:20 Y:200} {X:30 Y:300}]
fmt.Printf("before change: %+v \n", slice)


point1 := slice[1] // 取出的是 slice of struct
point1.X += 1
point1.Y += 1

// slice[1] 並沒有被修改: [{X:0 Y:0} {X:10 Y:100} {X:20 Y:200} {X:30 Y:300}]
fmt.Printf("after change: %+v \n", slice)
}
  • 但若我們取出來的是「slice of pointer」,這時候才會參照回原本 slice 中的 struct,因此當我們修改 point1 內的 X 和 Y 時,會看到輸出的 slice 產生對應的改變
func main() {
var slice []Vertex
for i := 0; i < 4; i++ {
slice = append(slice, Vertex{i * 10, i * 100})
}

// before change: [{X:0 Y:0} {X:10 Y:100} {X:20 Y:200} {X:30 Y:300}]
fmt.Printf("before change: %+v \n", slice)

point1 := &slice[1] // 差別在這:取出的是 slice of pointer
point1.X += 1
point1.Y += 1

// slice[1] 有被修改: [{X:0 Y:0} {X:11 Y:101} {X:20 Y:200} {X:30 Y:300}]
fmt.Printf("after change: %+v \n", slice)
}
  • 除此之外,當 slice 因為長度改變時,會產生「記憶體重新配置(memory relocate)」,這時候的 slice 和原本一開始的 slice 已經是指稱到底層不同的 array,進而導致修改 point2 內的 X, Y 後,在最終輸出的 slice 並無法看到改變
func main() {
var slice []Vertex
for i := 0; i < 4; i++ {
slice = append(slice, Vertex{i * 10, i * 100})
}

// before change: [{X:0 Y:0} {X:10 Y:100} {X:20 Y:200} {X:30 Y:300}]
fmt.Printf("before change: %+v \n", slice)

point1 := &slice[1]
point2 := &slice[2]
point1.X += 1
point1.Y += 1

// slice[1] 有被修改:[{X:0 Y:0} {X:11 Y:101} {X:20 Y:200} {X:30 Y:300}]
fmt.Printf("after change: %+v \n", slice)

// 改變 slice 的 len,使其超過原本的 cap,將導致 reallocate,
// 這時候的 slice 和原本的 slice 已經指稱到不同的記憶體位置
slice = append(slice, Vertex{4, 40})

// 這時候對 point2 修改無法修改到原本 slice 中的 {X:2, Y:20}
point2.X += 1
point2.Y += 1

// slice[2] 並沒有被修改: [{X:0 Y:0} {X:11 Y:101} {X:20 Y:200} {X:30 Y:300} {X:4 Y:40}]
fmt.Printf("after reallocate: %+v \n", slice)
}

解決參照到不同 slice 的作法

要解決這樣的問題,有幾種不同的做法:

方法一:避免 slice 超過其 cap 導致重新 relocate 外

可以的話,在一開始定義 slice 時,就把 slice 的 length 設好,且避免 relocate:

func main() {
// 方法一:先把 slice 的 len 設好,並避免 relocate
slice := make([]Vertex, 8)
for i := 0; i < 4; i++ {
slice[i] = Vertex{i * 10, i * 100}
}

// ...
}

方法二:使用 slice of pointers

// 使用 2. slice of pointers
func main() {
var slice []*Vertex // 這裡使用 slice of pointers
for i := 0; i < 4; i++ {
slice = append(slice, &Vertex{i * 10, i * 100})
}

printAll("before change", slice) // [{X:0 Y:0} {X:10 Y:100} {X:20 Y:200} {X:30 Y:300}]
point1 := slice[1]
point2 := slice[2]
point1.X += 1
point1.Y += 1

printAll("after change", slice) // [{X:0 Y:0} {X:11 Y:101} {X:20 Y:200} {X:30 Y:300}]

// 改變 slice 的 len,使其超過原本的 cap,將導致 reallocate,
// 這時候的 slice 和原本的 slice 已經指稱到不同的記憶體位置
slice = append(slice, &Vertex{4, 40})

point2.X += 1
point2.Y += 1

// 透過 slice 可以看到修改後的結果
printAll("after reallocate", slice) // [{X:0 Y:0} {X:11 Y:101} {X:21 Y:201} {X:30 Y:300} {X:4 Y:40}]
}

func printAll(description string, slice []*Vertex) {
sliceStruct := make([]Vertex, len(slice))
for i, s := range slice {
sliceStruct[i] = *s
}
fmt.Printf("%s: %+v \n", description, sliceStruct)
}

方法三:使用 index 而非使用 Pointer

func main() {
var slice []Vertex
for i := 0; i < 4; i++ {
slice = append(slice, Vertex{i * 10, i * 100})
}

// before change: [{X:0 Y:0} {X:10 Y:100} {X:20 Y:200} {X:30 Y:300}]
fmt.Printf("before change: %+v \n", slice)

slice[1].X += 1
slice[1].Y += 1

// slice[1] 有被修改:[{X:0 Y:0} {X:11 Y:101} {X:20 Y:200} {X:30 Y:300}]
fmt.Printf("after change: %+v \n", slice)

// 改變 slice 的 len,使其超過原本的 cap,將導致 reallocate,
// 這時候的 slice 和原本的 slice 已經指稱到不同的記憶體位置
slice = append(slice, Vertex{4, 40})

// 透過 index 的方式修改 slice
slice[2].X += 1
slice[2].Y += 1

// slice[2] 有被修改:[{X:0 Y:0} {X:11 Y:101} {X:21 Y:201} {X:30 Y:300} {X:4 Y:40}]
fmt.Printf("after reallocate: %+v \n", slice)
}

不一定要用 slice of pointers?

從上面的例子中可以看到,如果會需要修改到 slice 中的元素,要避免參照到錯誤的 slice 或 slice 中的元素,使用 slice of pointers 可以算是最簡單的方式,但這麼做對效能和記憶體並不見得是最好的!一般來說使用 slice of structs 的效能比 slice of pointers 好(參考:Potential dangers of storing structs rather than pointers)。

然而除了效能之外,可維護性也很重要,未來如果有需要修改元素內的資料時,是否有人會忘記把取出來的 slice 加上 & ,或者因為添加了 slice 中的元素而導致 slice relocate 而導致不必要的錯誤呢?

以我個人來說,如果這個 slice 或 slice 中的元素有可能會被操作修改,那麼就用 slice of pointers;如果這個 slice 單純只是拿來讀取,那麼就用 slice of structs。

其他

slice of structs vs slice of pointers to structs

👍 Use Pointers in Golang Arrays

type Foo struct {}
sliceOfStructs := []Foo
sliceOfPointersOfStructs := []*Foo

在 slice 中使用 pointer 的好處在於未來如果有需要操作該 slice 中的 struct 時會方便許多(參考: Use Pointers in Golang Arrays),否則在該 slice 中的 struct 會比較麻煩。

使用 pointers to slices

Golang tips: why pointers to slices are useful and how ignoring them can lead to tricky bugs @ Medium

雖然我們知道 slice 本身就是 pass by reference,但為什麼我們經常會看到函式的參數是使用 pointers to slices 呢?

之所有會使用 pointer to slices,是因爲不使用 pointer to slices 的話,一旦 pointer 有改變(例如,copy, assign, append),這些改變在函式外面將不會被看到。也就是說,如果這個 function 本身會改變 slice 的結構、size、記憶體位置的話,函式外的變數並不會有對應的改變,因為除了 slices 的值是 pass by reference 之外,用來描述這個 slices 的 metadata 都會是新的複製

總結來說:如果你只是單純要修改元素的值,那麼可以 pass a slice by value;但若你可能會修改到 slice 的 length、position 等等,那就最好使用 pass a pointer to slice。

範例一

func main() {
slice := make([]string, 2, 3)
log(slice)

func(slice []string) {
// 先 append 的話 slice 因為 metadata 改變了,已經不同,
// 因此使用 slice[0], slice[1] 對於原本的 slice 進行改值將不會有作用
// slice = append(slice, "a")
slice[0] = "b"
slice[1] = "b"

// 後來才 append 的話,即可先修改到原本的 slice
// slice = append(slice, "a", "a")
log(slice)
}(slice)

log(slice)
}

func log(slice []string) {
fmt.Printf("%v ({len: %d, cap: %d}) \n", slice, len(slice), cap(slice))
fmt.Printf("addr of first element %p. add of slice %p\n", &slice[0], &slice)
}

範例二

func main() {
slice := make([]string, 1, 3)

func(slice []string) {
// 這個 slice 和原本的 slice 有不同的 metadata
// 因此無法使用 slice[0] 和 slice[1] 去改到外層的 slice
slice = slice[1:3]
slice[0] = "b"
slice[1] = "b"

log(slice)
}(slice)
log(slice)
}

參考