2020年12月30日

[掘竅] Golang - 使用 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。

參考

0 意見:

張貼留言