Slice trong Go: Tất tần tật, và những điều có thể bạn chưa biết

Hế lô các bạn, mình là tôi đi code… ấy nhầm intro của idol rồi.

Chào, mấy nay năng lượng sục sôi, mình lại tiếp tục muốn viết blog, và chủ đề tiếp mình muốn viết là về Go hay còn gọi là Golang. Nhưng lịch ra bài của series này chắc sẽ không được dày lắm, bởi vì mình còn muốn viết về backend nữa, khá là ôm đồm phải không, nhưng mà kệ 🤠.

Hãy bắt đầu chủ đề này với topic: "Slice tất tần tật"

first things first

Again, mình là Kiên, 1 developer với tay nghề khá thập cẩm, và bởi vì dạo này focus hơn vào backend, nên mình nghĩ viết bài về Go chính là một cách hay để mình tiếp tục học và củng cố kiến thức.

Nếu máy bạn chưa cài Go thì có thể cài đặt từ đây, bạn cũng có thể xem qua go tour để biết những điều basic về ngôn ngữ này.

Bắt đầu thôi!

Array trong Go

Trước khi nói về Slice, chúng ta phải nhắc tới người anh trai cùng cha khác họ của nó: Array.

Mặc dù ở các ngôn ngữ khác, array được sử dụng cực kì phổ biến, nhưng Go lại không như thế, bởi vì Array có vài điểm yếu rất bất tiện.

Khai báo 1 array

Như đa phần các ngôn ngữ khác, array là kiểu dữ liệu thường đi kèm với ngoặc vuông [].

Trong ví dụ sau mình khai báo 1 mảng integer có 5 phần tử, và gán cho phần tử số 4 giá trị mới là 99.

var a [5]int
a[4] = 99
fmt.Println(a)
// result: [0 0 0 0 99]

Trông không khác gì các ngôn ngữ "bình thường" phải không nào, các phần tử của array khi không được gán thì mang giá trị "default", ở Go gọi là zero value, zero value của một integer là 0.

Bạn cũng có thể khai báo 1 mảng với giá trị được chỉ định sẵn:

var fiveElements = [5]string{"Kim", "Mộc", "Thủy", "Hỏa", "Thổ"}
// Chỉ định luôn index nào có giá trị bao nhiêu:
var numbers = [5]int{0: 1, 4: 9}
// numbers: [1 0 0 0 9]

Tuy nhiên có 1 điểm đặc biệt bạn có thể thấy thú vị về array, chính là:

So sánh 2 array trong Go

Đúng rồi đấy, trong Go, 2 array có thể so sánh trực tiếp với nhau bởi toán tử ==!= .

Ví dụ:

var fiveElements = [5]string{"Kim", "Mộc", "Thủy", "Hỏa", "Thổ"}
var fiveElementsClone = [5]string{"Kim", "Mộc", "Thủy", "Hỏa", "Thổ"}
fmt.Println(fiveElements == fiveElementsClone) // true

Nếu chúng ta làm vậy trong các ngôn ngữ khác như C#, Java, thì kết quả lại khác rồi (check thử đi là biết):

so sánh 2 array trong C# so sánh 2 array trong C#

Trích từ spec của Go chính chủ 1, thì 2 array có thể so sánh với nhau, nếu phần tử của 2 array đều là giá trị "comparable". 2 array sẽ bằng nhau, nếu các giá trị bên trong chúng đều bằng nhau (dĩ nhiên theo index).

Điểm yếu của Array

Đây là điểm hạn chế lớn nhất khiến dev Go không dùng array, mà lại dùng nhân vật chính của bài viết: Chúng ta không thể khai báo length của array trong Go 1 cách động (dynamically) được.

Lý do là bởi vì length của Array được xem xét là 1 phần của kiểu dữ liệu array.

Do đó, 1 mảng [2]string sẽ có kiểu dữ liệu khác với mảng [3]string .

Thế nên độ dài của array khi khai báo phải là một hằng số kiểu int 2, nếu không, trình compiler sẽ chửi bạn như sau:

Không thể gán 1 dynamic value cho length của array Không thể gán 1 dynamic value cho length của array

Những kiến thức cơ bản về Slice

Các bạn biết mà, mọi thứ xảy ra đều có lý do của nó, và Array trong Go cũng không phải là ngoại lệ.

Chúng là nền tảng để xây dựng nên một kiểu dữ liệu xịn hơn: Slice.

Khai báo slice

Đầu tiên là khai báo, khi dùng slice chúng ta không cần phải xác định kích thước mặc định cho nó:

var numbers = []int{2,4,6}

Giống array, bạn cũng có thể gán giá trị cho từng index khi khởi tạo:

var numbers = []int{1:2, 3:4, 5: 6}
// kết quả: [0, 2, 0, 4, 0, 6]

Slice zero value

Nếu chúng ta chỉ khai báo mà không gán giá trị cho slice, thì giá trị mặc định của nó sẽ là nil.

var numbers []int
fmt.Println(numbers == nil) // true

💡 Đừng nhầm lẫn chỗ này, zero value của bản thân slice không phải là zero value của các phần tử bên trong nó.

Cấu trúc của một slice

Bản chất của Slice chính là 1 wrapper của array - 1 struct, cũng giống như ArrayList trong Java hay List trong C# vậy, (thậm chí là "array" trong Javascript).

Slice struct - wrapper

Slice header bao gồm 3 thành phần:

  • Con trỏ tới ô nhớ chứa dữ liệu
  • Length (Kích thước)
  • Capacity (Dung lượng)

Đây là mã nguồn của nó 3:

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

Thao tác với slice

Những thao tác cơ bản nhất với Slice chính là đọc, gán giá trị, khởi tạo và kiểm tra các giá trị capacity và length của nó.

Để khởi tạo 1 slice với cả length và capacity, hãy dùng hàm make()

Để kiểm tra kích thước và capacity của slice, dùng hàm len()cap()

// tạo 1 slice với size 3 và cap là 5
s := make([]int, 3, 5)

// Gán giá trị cho các phần tử
s[0] = 1
s[1] = 2
s[2] = 3

fmt.Println("slice:", s)
fmt.Println("length:", len(s))
fmt.Println("capacity:", cap(s))

// Mặc dù có cap là 5, nhưng không thể gán được index cao hơn size của slice
s[3] = 4 // error: index out of range

Kết quả

slice: [1 2 3]
length: 3
capacity: 5
panic: runtime error: index out of range [3] with length 3

❗ Nếu slice A có size là x, thì bạn chỉ được access tới A[x-1] mặc dù capacity có thể cao hơn X, nếu gán vào đó lỗi index out of bound sẽ bắn ra. Hãy sử dụng append trong trường hợp này.

Thêm phần tử mới

Để thêm phần tử mới, dùng append, nhưng không thể gọi trực tiếp từ slice như ngôn ngữ OOP khác được:

x := []int{1, 2, 3}
x = append(x, 4)
x = append(x, 5, 6, 7)

Trong Go ta có khái niệm variadic function, cú pháp hơi giống với spread syntax của javascript.

append() cũng là 1 variadic function, tức là chúng ta có thể thêm nhiều tham số, hoặc 1 slice khác vào phần tham số của append:

x := []int{1, 2, 3}
y := []int{4,5,6}
x = append(x, y...)
x = append(x, 7, 8 , 9)
fmt.Println(x)
// [1 2 3 4 5 6 7 8 9]

❗ Luôn luôn phải gán giá trị trả về của append cho 1 biến, nếu không sẽ gây ra lỗi compile: unused variable.

Duyệt các phần tử

Trong go, chúng ta có vài cách sử dụng for, nhưng hầu như chúng ta sẽ sử dụng for-range để duyệt element trong 1 slice:

x := []string{"Tony", "Stark", "Hulk", "Thor", "Panther"}

for i, v := range x {
	fmt.Printf("%d: %v\n", i, v)
}

Nếu không cần sử dụng index, thì hãy đổi tên nó thành dấu underscore - gạch dưới (_), nếu không compiler sẽ chửi bạn vì khai báo biến mà không dùng:

x := []string{"Tony", "Stark", "Hulk", "Thor", "Panther"}

for _, v := range x {
	fmt.Printf("%v\n", v)
}

Slice nâng cao

Nếu mình chỉ đề cập tới cách khai báo, sử dụng slice thì chẳng khác nào đi viết lại cái document của go dev blog hay là go tour, cho nên mình cần phần nâng cao này. Hi vọng nó giúp được anh em lúc mới làm quen Go có thể hiểu sâu hơn về con chuột túi mắt lé này.

Slice tăng capacity như thế nào?

Có thể bạn sẽ tự hỏi nếu một slice đã đạt đủ sức chứa (full capacity), nhưng mình tiếp tục append vào thì capacity sẽ tăng lên thế nào?

Kiểm tra thực tế chút nhé:

func main() {
	x := []int{1, 2, 3}
	fmt.Println(x)
	fmt.Printf("cap: %d, len: %d\n", cap(x), len(x))

	// append more
	x = append(x, 1)
	fmt.Printf("cap: %d, len: %d\n", cap(x), len(x))
}

Kết quả:

[1 2 3]
cap: 3, len: 3
cap: 6, len: 4

Bạn có thể thấy là capacity lúc đầu là 3, mà cũng đã full 3 phần tử, mình lại append thêm 1 số vào.

Lúc này cap tăng gấp đôi, trở thành 6.

Mỗi khi bạn append thêm phần tử vào 1 slice đã full capacity, thì Go sẽ tự động tạo ra 1 array mới với capacity lớn hơn, sau đó copy dữ liệu từ array cũ cũng như giá trị mà chúng ta vừa thêm.

Do đó capacity của Slice là vô cực, dĩ nhiên nó cũng sẽ die như Iron Man nếu hết RAM 😈.

Vậy câu hỏi là: Go quản lý việc tăng capacity thế nào? Nói cách khác là growth factor của nó là bao nhiêu?

Với phiên bản Go 1.14, thì Slice sẽ tăng cap gấp đôi cho đến khi cap đạt tới 1024.

Từ mốc 1024, mỗi lần sẽ tăng thêm 25%4.

Go 1.20 đổ đi lại khác, tham khảo commit này, thì logic tăng cap nó "smooth" hơn:

starting cap    growth factor
256             2.0
512             1.63
1024            1.44
2048            1.35
4096            1.30

Well, cũng chỉ để tham khảo mà thôi, vì lỡ như các bản update sau này người ta lại sửa đổi nữa :3

Slice best practices

Thường thì chúng ta sẽ khởi tạo 1 slice theo 2 cách, sử dụng make hoặc gán luôn 1 slice rỗng:

x := []int{}

Vậy khi nào thì dùng make, còn khi nào thì dùng luôn slice rỗng?

Well, chúng ta có 2 trường hợp:

  • Không biết slice sau khi khai báo sẽ có bao nhiêu phần tử.
  • Biết chính xác hoặc ước lượng số phần tử của slice.

Khi chúng ta không biết slice có thể có tối đa hoặc tối thiểu bao nhiêu phần tử, thì việc sử dụng x := []int{} hay x := make([]int, 0) là như nhau, trong trường hợp này mình thiên về việc sử dụng cách khởi tạo slice rỗng hơn là make vì nó dễ đọc hơn.

Ngược lại, khi chúng ta biết rõ hoặc ước lượng được số element tối đa, tối thiểu của slice thì có thể dùng make, khi đó có thể tuỳ tình hình mà khai báo size hoặc them capacity.

Ví dụ, khi mình biết chắc chắn slice images sẽ có 100 phần tử, hoặc chính xác hơn nó sẽ ngang bằng kích thước của slice products:

products := getLatestProducts(100)

images := make([]Image, len(products))

// Với mỗi product, chạy hàm generate image.
for i, p := range products {
	images[i] = genImage(p.Name)
}

Vì chúng ta biết rõ size của slice images, nên chúng ta có thể gán trực tiếp theo index. Tốc độ khi chúng ta gán trực tiếp theo index chắc chắn sẽ nhanh hơn việc append rồi.

Nhưng ở một trường hợp khác, chúng ta không thể biết trước size của slice là bao nhiêu thì bắt buộc phải dùng append thôi.

Khi đó, lưu ý bạn phải để length của slice là 0, vì append sẽ thêm phần tử mới, chứ không phải ghi đè lên các phần tử vừa tạo với zero value.

Ở case này mình ước lượng kích thước của slice images tối thiểu sẽ bằng với kích thước của products:

products := getLatestProducts(100)

images := make([]Image, 0, len(products))

// Với mỗi product, chạy hàm generate image.
for _, p := range products {
	images = append(images, genImages(p.Names)...)
}

Như các bạn có thể thấy, vì mỗi product ở trường hợp này có thể có nhiều tên khác nhau, do đó chúng ta sẽ không thể biết chắc chắn được kích thước của slice images.

Đoạn này nếu giả dụ ước lượng mỗi product có 2 Name, ta cũng có thể update thêm tham số capacity thành images := make([]Image, len(products), len(products)*2), để buffer thêm gấp đôi bộ nhớ.

Việc ước lượng đúng số phần tử tối đa để gán trước capacity cho slice sẽ bớt workload cho go runtime vì đỡ phải cấp phát lại bộ nhớ.

So sánh 2 Slice

Bên trên mình có nói rằng với array thì chúng ta có thể so sánh với 2 toán tử ==!= . Tuy nhiên việc này không thể làm được với slice. Vì lẽ đó nên chúng ta cần work around 1 chút.

Sử dụng 1 vòng lặp để so sánh

Nghe có vẻ khá "stupid", nhưng đây lại là cách dễ dàng implement nhất, performance cũng không đến nỗi nào, lại còn có thể tùy biến logic so sánh:

func main() {
	arr1 := []string{"a", "b"}
	arr2 := []string{"a", "b"}
	fmt.Println(Equal(arr1, arr2)) // true
}

// Equal tells whether a and b contain the same elements.
// A nil argument is equivalent to an empty slice.
func Equal[T comparable](a, b []T) bool {
	if len(a) != len(b) {
		return false
	}
	for i, v := range a {
		if v != b[i] {
			return false
		}
	}
	return true
}

Ở đoạn code trên mình đã áp dụng generic để có thể so sánh nhiều giá trị comparable.

✍️ Các giá trị comparable trong Go có thể là:

  • Numeric (int, float, etc.)
  • String
  • Bool
  • Pointer
  • Struct mà các thuộc tính của nó toàn bộ là comparable

Go vốn không có generic, tính năng này xuất hiện từ phiên bản 1.18, bản stable mới nhất của Go lúc mình viết bài là 1.20.3

Sử dụng reflect.DeepEqual

Nếu các bạn từng làm việc với Java hay C# thì chắc không lạ lùng gì với reflect (phản chiếu) nữa, Ví dụ:

import (
	"fmt"
	"reflect"
)

type Person struct {
	ID string
}

func main() {
	a := []Person{{ID: "A"}}
	b := []Person{{ID: "B"}}
	c := []Person{{ID: "A"}}
	fmt.Println(reflect.DeepEqual(a, b)) // false
	fmt.Println(reflect.DeepEqual(a, c)) // true
}

DeepEqual như tên gọi của nó, kiểm tra 2 giá trị đầu vào có bằng nhau hay không một cách đệ quy. Điều này có nghĩa là nó không chỉ so sánh các giá trị comparable, mà còn so sánh cả slice, map, struct, bla bla…

  • Nếu hai giá trị được so sánh không cùng type, thì chúng không bao giờ bằng nhau.
  • DeepEqual sẽ không đệ quy vào sâu hơn khi so sánh ở level hiện tại đã bằng nhau.

Tuy nhiên, có một số giá trị không thể so sánh bằng hàm này, chẳng hạn như NaN và các giá trị chứa  NaN hoặc func. (Check playground).

Để xem cách mà DeepEqual so sánh 1 cách đệ quy như thế nào, bạn có thể kiểm tra hàm nó gọi vào là deepequal.go5.

Cắt nhỏ 1 slice

Slice có nghĩa là "lát cắt", cái tên khá kì lạ, mà đã là một lát cắt thì chúng ta sẽ có thể cắt nhỏ nó ra.

Đó gọi là slice expression. (Dịch ra biểu thức lát cắt nghe nó củ chuối như nào ấy).

Khá giống với Array.prototype.splice của javascript, đây là biểu thức dùng để "cắt" ra 1 slice từ 1 slice.

Đây là cú pháp của slice expression, cũng có ở gotour:

x[low : high]

low là index bắt đầu tính từ 0.

high là index kết thúc của phép cắt (không lấy luôn, mà dừng trước index đó).

Nếu bỏ trống giá trị, thì low mặc định sẽ là 0, còn high là index cuối + 1 (lấy hết) .

Ví dụ luôn:

primes := []int{2, 3, 5, 7, 11, 13}

s := primes[1:4]
s2 := primes[:4]
s3 := primes[2:]

fmt.Println(s)  // [3, 5, 7]
fmt.Println(s2) // [2, 3, 5, 7]
fmt.Println(s3) // [5, 7, 11, 13]

Slice đôi lúc share chung bộ nhớ

Kéo lên xem lại struct của Slice bên trên 3, bạn sẽ thấy thực tế phần data nó liên kết tới chính là một pointer

Tham khảo gotour - Slices are like references to arrays sẽ rõ.

Nếu bạn chưa biết pointer là gì thì đợi mình ra blog mới trong series này nha, và có thể bỏ qua phần này, nếu mình lâu quá không ra bài về pointer thì hú mình bằng comment bên dưới nhé 🙇.

Đầu tiên hãy giả định chúng ta có hàm printAll để in ra thông tin cho 2 slice:

func printAll(x, y []string) {
	fmt.Printf("X: cap: %d, len %d, %v \n", cap(x), len(x), x)
	fmt.Printf("Y: cap: %d, len %d, %v \n", cap(y), len(y), y)
}

Tiếp theo mình có ví dụ này:

func main() {
	x := make([]string, 3, 6)
	x[0] = "A"
	x[1] = "B"
	x[2] = "C"
	fmt.Println("Original X")
	fmt.Printf("X: cap: %d, len %d, %v \n", cap(x), len(x), x)

	y := x
	y[2] = "C2"

	fmt.Println("Y is updated")
	printAll(x, y)
}

Ở đoạn code trên, khi y[2] được update thành C2, thì cả x cũng sẽ nhận được sự thay đổi đó.

Xem hình minh họa:

slice-share-the-same-memory.jpg

Nếu slice copy từ x là y được thêm mới 1 phần tử, thì lúc đó x sẽ không thấy được phần tử mới của y, vì len của x không đổi:

	x := make([]string, 3, 6)
	x[0] = "A"
	x[1] = "B"
	x[2] = "C"
	fmt.Println("Original X")
	fmt.Printf("X: cap: %d, len %d, %v \n", cap(x), len(x), x)

	y := x

	// change len of Y, the copied slice
	y = append(y, "D")
	y[2] = "C3"
	fmt.Println("Y is appended, modified")
	printAll(x, y)

Kết qủa sẽ là:

Original X
X: cap: 6, len 3, [A B C]
Y is appended, modified
X: cap: 6, len 3, [A B C3]
Y: cap: 6, len 4, [A B C3 D]

Trong ví dụ vừa rồi, mình đã thêm D vào slice y, chỉ có len của y là thay đổi, và len của x không đổi, nên x sẽ không thể thấy được D, xem hình vẽ minh họa:

slice-share-the-same-memory-2.jpg

Nếu chúng ta kết hợp với slice expression và append thì mọi thứ sẽ càng phức tạp và rối rắm hơn.

Chúng ta tạm gọi slice x là slice gốc, slice y là slice con hoặc slice dẫn xuất (derived slice):

x := []string{"I", "am", "a", "gopher"}
y := x[:2]
printAll(x, y)
fmt.Println()
y = append(y, "CUTE")
printAll(x, y)

Kết quả:

X: cap: 4, len 4, [I am a gopher]
Y: cap: 4, len 2, [I am]

X: cap: 4, len 4, [I am CUTE gopher]
Y: cap: 4, len 3, [I am CUTE]

Bạn thấy đấy, khi mình cắt slice x thành y, bắt đầu từ 0 tới index 2. Thì slice y có 2 giá trị là I, am .

Mình tiếp tục append thêm vào y 1 phần tử nữa là CUTE , lúc này CUTE sẽ được thêm vào y làm tăng len của y lên 3, nhưng lại thay thế luôn vào vị trí của a trong slice/array gốc (x).

Đây là bởi vì khi chúng ta dùng slice expression với 1 slice, thì khi đó capacity của slice gốc sẽ được share với slice con.

Vì vậy, hãy nên chắc chắn rằng bạn không append vào các slice dẫn xuất.

Nhưng!

Lại có 1 trường hợp khác Như bên trên phần append mình đề cập.

Đó chính là khi gọi append(), nếu capacity của slice dẫn xuất / copy thay đổi do không còn đủ capacity, thì slice này sẽ không còn trỏ tới chung vùng nhớ với slice gốc nữa, xem ví dụ sau:

x := make([]string, 3, 6)
x[0] = "A"
x[1] = "B"
x[2] = "C"
fmt.Println("Original X")
fmt.Printf("X: cap: %d, len %d, %v \n", cap(x), len(x), x)

y := x

// change cap
y = append(y, "D", "E", "F", "G", "H", "I")
y[2] = "C4"
fmt.Println("Y changed cap")
printAll(x, y)

Kết quả:

Original X
X: cap: 6, len 3, [A B C]
Y changed cap
X: cap: 6, len 3, [A B C]
Y: cap: 12, len 9, [A B C4 D E F G H I]

Lúc này Y đã đổi C thành C4, nhưng x vẫn không bị thay đổi.

Xem hình minh họa:

slice-share-the-same-memory-3-change-cap.jpg

Lý do là vì khi capacity thay đổi, thì Go runtime sẽ phải cấp phát lại bộ nhớ cho slice y, do đó slice sẽ phải "bỏ nhà ra đi", tìm một vùng nhớ khác đủ rộng và thích hợp hơn.

Đây chắc có lẽ là lí do mà length lại là 1 phần của array type. Bởi vì khi cấp phát bộ nhớ mới, nếu không có length thì bạn đâu biết tìm ô nhớ dài bao nhiêu, ở đâu mà cấp? đúng không? cứ cấp đại 1 số lượng thì chắc bao nhiêu RAM với CPU cũng không đủ.

Code của phần test cap và len này mình có share lên playground, lets give it a try!

Copy

Cái cơ chế sharing capacity khá khó chịu phải không nào, thế thì có thể bạn sẽ hỏi có cách nào clone slice này sang slice khác mà thuộc dạng "bỏ nhà ra đi" ngay từ đầu, không dính dáng gì tới ô nhớ cũ không?

Có, hãy dùng hàm copy. (Check playground)

func main() {
	x := []string{"I", "am", "a", "gopher"}
	z := make([]string, 4)
	copy(z, x)
	z[1] = "HEHEHE"
	printAll(x, z)
	fmt.Println()
	z = append(z, "NEW")
	printAll(x, z)
}

Kết quả:

X: cap: 4, len 4, [I am a gopher]
Y: cap: 4, len 4, [I HEHEHE a gopher]

X: cap: 4, len 4, [I am a gopher]
Y: cap: 8, len 5, [I HEHEHE a gopher NEW]

Hoo, Kết luận

Bài này mình công nhận dài thật, nhưng hàm lượng kiến thức mình nghĩ cũng không ít.

Những kiến thức trong bài này mình được học từ gotour, cũng như từ cuốn sách Learning Go của Jon Bodner, nếu được bạn hãy đọc nó, rất hay đấy.

Hi vọng sau bài viết này, bạn sẽ nắm vững hơn về Slice trong Go, và những thứ liên quan Slice như copy, len, cap, array, bla blo…

Sau đó nhớ theo dõi blog mình để đón đọc các bài viết tiếp theo nhé!

Happy coding, happy life!

Published under Go on .

Kiên Đinh

Bần đạo là Kiên Đinh, một Developer. Ta viết blog này với mục đích chia sẻ những kinh nghiệm của bản thân đối với coding chi đạo.