Array và slice là hai cấu trúc dữ liệu nền tảng dùng để lưu trữ một tập hợp các phần tử cùng kiểu trong Go.
Array có kích thước cố định, mang ngữ nghĩa giá trị và được sử dụng khi số lượng phần tử đã được xác định từ trước. Slice linh hoạt hơn, có thể thay đổi độ dài và là cấu trúc được sử dụng phổ biến trong hầu hết chương trình Go thực tế.
Tuy nhiên, slice không đơn giản chỉ là một “array có thể mở rộng”. Để sử dụng slice an toàn, chúng ta cần hiểu rõ:
- Slice được tổ chức như thế nào trong bộ nhớ
- Length và capacity khác nhau ra sao
- Khi nào các slice chia sẻ cùng underlying array
- Khi nào
appendcấp phát bộ nhớ mới - Vì sao một thay đổi trên slice có thể vô tình ảnh hưởng đến dữ liệu khác
- Cách tránh aliasing và giữ lại những vùng nhớ lớn không cần thiết
Array
Array là một tập hợp có kích thước cố định, gồm các phần tử cùng kiểu và được truy cập thông qua chỉ số.
Cú pháp khai báo array:
var name [length]Type
Ví dụ:
func main() {
var arr [4]int
fmt.Println(arr)
}
Kết quả:
$ go run main.go
[0 0 0 0]
Mặc dù chưa gán giá trị cho bất kỳ phần tử nào, array vẫn chứa bốn số 0.
Nguyên nhân là mọi biến trong Go đều được khởi tạo với zero value của kiểu tương ứng:
0đối với các kiểu số""đối với stringfalseđối với booleannilđối với pointer, slice, map, function, channel và interface
Vì vậy, một array luôn được khởi tạo đầy đủ ngay khi được khai báo.
Khởi tạo array bằng literal
Khi đã biết trước các phần tử, chúng ta có thể sử dụng array literal:
var arr = [4]int{1, 2, 3, 4}
Hoặc sử dụng cú pháp khai báo rút gọn:
arr := [4]int{1, 2, 3, 4}
Mỗi phần tử được truy cập thông qua chỉ số:
fmt.Println(arr[0]) // 1
fmt.Println(arr[3]) // 4
Chỉ số của array bắt đầu từ 0 và kết thúc tại len(arr) - 1
Với array có độ dài 4, các chỉ số hợp lệ là:
0, 1, 2, 3Để compiler tự suy ra độ dài
Nếu không muốn tự đếm số phần tử, chúng ta có thể sử dụng cú pháp [...]:
arr := [...]int{1, 2, 3, 4}
Compiler sẽ suy ra kiểu của arr là: [4]int
Dấu ... trong trường hợp này chỉ yêu cầu compiler tự xác định độ dài dựa trên số phần tử trong literal. Kết quả cuối cùng vẫn là một array có kích thước cố định.
Duyệt qua array
Có thể duyệt array bằng vòng lặp for thông thường:
func main() {
arr := [4]int{1, 2, 3, 4}
for i := 0; i < len(arr); i++ {
fmt.Printf("Index: %d, Element: %d\n", i, arr[i])
}
}
Tuy nhiên, cách phổ biến và tự nhiên hơn trong Go là sử dụng range:
func main() {
arr := [4]int{1, 2, 3, 4}
for i, element := range arr {
fmt.Printf("Index: %d, Element: %d\n", i, element)
}
}
Kết quả:
$ go run main.go
Index: 0, Element: 1
Index: 1, Element: 2
Index: 2, Element: 3
Index: 3, Element: 4
Ở mỗi vòng lặp, range trả về hai giá trị:
- Chỉ số hiện tại
- Bản sao của phần tử tại chỉ số đó
Tùy vào nhu cầu, chúng ta có thể sử dụng range theo nhiều cách:
for i, element := range arr {
// Lấy cả chỉ số và phần tử
}
for _, element := range arr {
// Chỉ lấy phần tử
}
for i := range arr {
// Chỉ lấy chỉ số
}
for range arr {
// Chỉ lặp len(arr) lần
}
Blank identifier _ được dùng khi chúng ta không cần giá trị chỉ số nhưng vẫn muốn lấy phần tử.
Array nhiều chiều
Bản thân mỗi array trong Go chỉ có một chiều. Tuy nhiên, phần tử của array có thể tiếp tục là một array khác.
Nhờ đó, chúng ta có thể tạo array nhiều chiều:
func main() {
arr := [2][4]int{
{1, 2, 3, 4},
{5, 6, 7, 8},
}
fmt.Println(arr)
}
Kết quả:
$ go run main.go
[[1 2 3 4] [5 6 7 8]]
Kiểu [2][4]int:
- Array bên ngoài có
2phần tử - Mỗi phần tử là một array
[4]int
Có thể truy cập phần tử bằng hai chỉ số:
fmt.Println(arr[0][0]) // 1
fmt.Println(arr[1][2]) // 7
Những đặc điểm quan trọng của array
Có ba đặc điểm chính quyết định cách array hoạt động trong Go.
1. Độ dài là một phần của kiểu
Trong Go, [4]int và [2]int là hai kiểu dữ liệu khác nhau.
var a [4]int
var b [2]int
Mặc dù cả hai đều chứa phần tử kiểu int, chúng không thể được sử dụng thay thế cho nhau:
a = b // Compile error
Điều này cũng ảnh hưởng đến tham số hàm:
func process(values [4]int) {
// ...
}
Hàm trên chỉ nhận chính xác giá trị kiểu [4]int. Một giá trị [3]int hoặc [5]int đều không hợp lệ.
2. Array không thể thay đổi kích thước
Sau khi được khai báo, độ dài của array không thể thay đổi.
arr := [4]int{1, 2, 3, 4}
arr sẽ luôn chứa đúng bốn vị trí phần tử. Không thể thêm phần tử thứ năm hoặc thu nhỏ nó thành ba phần tử.
Đây là một trong những lý do slice được sử dụng phổ biến hơn trong code ứng dụng.
3. Array là value type
Array mang ngữ nghĩa giá trị. Khi gán một array cho biến khác, Go sao chép toàn bộ các phần tử sang một array mới.
func main() {
a := [7]string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
}
b := a
b[0] = "Monday"
fmt.Println(a)
fmt.Println(b)
}
Kết quả:
$ go run main.go
[Mon Tue Wed Thu Fri Sat Sun]
[Monday Tue Wed Thu Fri Sat Sun]
Phép gán b := a tạo ra một bản sao đầy đủ của a.
Sau phép gán, a và b là hai array độc lập. Việc thay đổi b[0] không ảnh hưởng đến a[0].
Nguyên tắc tương tự cũng được áp dụng khi truyền array vào hàm:
func change(arr [3]int) {
arr[0] = 100
}
func main() {
values := [3]int{1, 2, 3}
change(values)
fmt.Println(values) // [1 2 3]
}
Tham số arr là một bản sao của values, vì vậy thay đổi bên trong hàm không tác động đến array ban đầu.
Với array lớn, việc sao chép toàn bộ dữ liệu như vậy có thể gây tốn kém. Trong thực tế, chúng ta thường sử dụng slice hoặc pointer đến array thay vì truyền một array lớn theo giá trị.
Slice
Kích thước cố định khiến array không phù hợp với nhiều tình huống thực tế.
Ví dụ, chúng ta có thể không biết trước:
- API sẽ trả về bao nhiêu bản ghi
- Người dùng sẽ nhập bao nhiêu phần tử
- Một truy vấn database sẽ trả về bao nhiêu hàng
- Một file sẽ được chia thành bao nhiêu đoạn dữ liệu
Trong những trường hợp đó, Go sử dụng slice.
Slice có thể được xem là một cửa sổ hoặc một khung nhìn vào một đoạn dữ liệu nằm trong một array bên dưới.
Ví dụ:
a := [5]int{20, 15, 5, 30, 25}
s := a[1:4]
Slice s không sao chép ba phần tử từ a. Thay vào đó, nó tham chiếu đến một vùng dữ liệu bên trong a.
Array: [20 15 5 30 25]
Index: 0 1 2 3 4
└─────┘
Slice
Kết quả của s là: []int{15, 5, 30}
Slice header
Ở mức khái niệm, một slice gồm ba thành phần:
Pointer
Length
Capacity
Có thể hình dung slice header như sau:
type sliceHeader struct {
Data unsafe.Pointer
Len int
Cap int
}
Đây chỉ là mô hình khái niệm để giải thích cách slice hoạt động. Trong code thông thường, chúng ta không nên thao tác trực tiếp với cấu trúc nội bộ này.
Ba thành phần của slice có ý nghĩa như sau:
Pointer
Pointer trỏ đến phần tử đầu tiên của slice trong underlying array.
Length
Length là số phần tử hiện có thể truy cập thông qua slice.
Giá trị này được lấy bằng:
len(s)Capacity
Capacity là số phần tử tối đa mà slice có thể mở rộng, tính từ vị trí bắt đầu của slice đến cuối underlying array.
Giá trị này được lấy bằng:
cap(s)Slice không phải reference type theo nghĩa truyền thống
Slice thường được mô tả là một reference type vì nhiều slice có thể cùng nhìn thấy thay đổi trên một vùng dữ liệu.
Tuy nhiên, mô tả chính xác hơn là:
Slice là một giá trị header nhỏ, chứa pointer, length và capacity. Header này được truyền và sao chép theo giá trị giống như mọi giá trị khác trong Go.
Khi sao chép một slice:
s2 := s1
Go sao chép slice header, không sao chép toàn bộ các phần tử.
Sau phép gán:
s1vàs2có length riêngs1vàs2có capacity riêng- Hai header có thể cùng trỏ đến một underlying array
Chính việc cùng trỏ đến underlying array khiến thay đổi phần tử thông qua một slice có thể được quan sát thông qua slice còn lại.
Bạn nên biết, sau phép gán s2 := s1, hai slice cùng trỏ đến một underlying array. Nếu append vượt quá capacity của s2, Go sẽ cấp phát một underlying array mới cho kết quả trả về. Khi đó, s1 và s2 không còn chia sẻ dữ liệu. Nếu capacity vẫn còn đủ, append có thể tiếp tục sử dụng array cũ và hai slice vẫn có thể ảnh hưởng lẫn nhau.
Length và capacity
Hãy quan sát một ví dụ cụ thể:
func main() {
c
fmt.Println("Array:", a, "len:", len(a), "cap:", cap(a))
fmt.Println("Slice:", s, "len:", len(s), "cap:", cap(s))
}
Kết quả:
$ go run main.go
Array: [20 15 5 30 25] len: 5 cap: 5
Slice: [15 5 30] len: 3 cap: 4
Slice được tạo bằng biểu thức:
s := a[1:4]
Quy tắc của biểu thức slice là:
a[low:high]
Trong đó:
lowlà chỉ số bắt đầu, được bao gồmhighlà chỉ số kết thúc, không được bao gồm
Vì vậy a[1:4] bao gồm các phần tử: a[1], a[2], a[3]
Length được tính bằng:
high - low
4 - 1 = 3
Capacity được tính từ vị trí low đến cuối underlying array:
len(a) - low
5 - 1 = 4
Mặc dù s hiện chỉ nhìn thấy ba phần tử, nó vẫn có khả năng mở rộng thêm một phần tử về phía cuối array mà chưa cần cấp phát bộ nhớ mới.
Khai báo và khởi tạo slice
Kiểu của slice được viết giống kiểu array nhưng không có độ dài:
[]TVí dụ:
[]int
[]string
[]User
Có nhiều cách để tạo slice.
Zero value của slice
Khi khai báo slice mà chưa khởi tạo, giá trị của nó là nil:
func main() {
var s []string
fmt.Println(s)
fmt.Println(s == nil)
fmt.Println(len(s))
fmt.Println(cap(s))
}
Kết quả:
[]
true
0
0
Nil slice có các đặc điểm:
- Không có underlying array
- Length bằng
0 - Capacity bằng
0 - Có thể sử dụng với
range - Có thể truyền vào
append - Có thể kiểm tra bằng
s == nil
Ví dụ:
var s []int
s = append(s, 10, 20, 30)
fmt.Println(s) // [10 20 30]
append hoạt động an toàn với nil slice.
Tạo slice bằng literal
Slice literal có cú pháp tương tự array literal nhưng không khai báo độ dài:
s := []string{"a", "b", "c"}
Ở đây, Go tạo một underlying array chứa ba phần tử và trả về một slice trỏ đến toàn bộ array đó.
Thông thường:
len(s) == 3
cap(s) == 3
Tạo slice bằng make
Hàm built-in make có thể được sử dụng để tạo slice:
s := make([]string, 4)
Cú pháp đầy đủ:
make([]T, length, capacity)Ví dụ:
s := make([]string, 4, 8)
Khi đó:
len(s) == 4
cap(s) == 8
Go cấp phát một underlying array có khả năng chứa tám phần tử, nhưng slice ban đầu chỉ có length bằng bốn.
Bốn phần tử hiện có đều mang zero value:
fmt.Println(s) // [ ]Với []string, mỗi phần tử ban đầu là chuỗi rỗng.
Nếu không truyền capacity, capacity mặc định bằng length:
s := make([]int, 4)
Tương đương về length và capacity với:
s := make([]int, 4, 4)
Length không phải số phần tử đã thêm
Một lỗi phổ biến là sử dụng make với length khi thực tế chỉ muốn chuẩn bị capacity:
s := make([]int, 3)
s = append(s, 1, 2, 3)
fmt.Println(s)
Kết quả:
[0 0 0 1 2 3]make([]int, 3) đã tạo ra một slice chứa sẵn ba phần tử có giá trị 0. append tiếp tục thêm ba phần tử vào phía sau.
Nếu mục tiêu là tạo slice rỗng nhưng chuẩn bị trước bộ nhớ cho ba phần tử, hãy viết:
s := make([]int, 0, 3)
s = append(s, 1, 2, 3)
fmt.Println(s) // [1 2 3]
Tạo slice từ array hoặc slice khác
Có thể tạo slice từ array:
a := [5]int{20, 15, 5, 30, 25}
s1 := a[1:4] // [15 5 30]
s2 := a[:3] // [20 15 5]
s3 := a[2:] // [5 30 25]
s4 := a[:] // [20 15 5 30 25]
Quy tắc rút gọn:
a[:high] tương đương: a[0:high]
a[low:] tương đương: a[low:len(a)]
Slice cũng có thể tiếp tục được slice:
s1 := a[1:4] // [15 5 30]
s2 := s1[1:2] // [5]
Trong trường hợp này, s2, s1 và a vẫn có thể cùng chia sẻ một underlying array.
Slice chia sẻ underlying array
Vì slice chỉ là một view vào underlying array, thay đổi phần tử thông qua slice sẽ thay đổi dữ liệu trong array đó.
func main() {
a := [7]string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
}
s := a[0:2]
s[0] = "Sunday"
fmt.Println(a)
fmt.Println(s)
}
Kết quả:
$ go run main.go
[Sunday Tue Wed Thu Fri Sat Sun]
[Sunday Tue]
Phép gán:
s[0] = "Sunday"thực chất ghi trực tiếp vào vị trí a[0] của underlying array.
Điều này khác hoàn toàn với phép gán array:
b := aPhép gán array sao chép toàn bộ các phần tử, trong khi thao tác slicing chỉ tạo một slice header mới trỏ vào dữ liệu hiện có.
Gán slice không sao chép dữ liệu
Hãy xem ví dụ sau:
func main() {
s1 := []int{1, 2, 3}
s2 := s1
s2[0] = 100
fmt.Println(s1)
fmt.Println(s2)
}
Kết quả:
[100 2 3]
[100 2 3]
Phép gán:
s2 := s1chỉ sao chép slice header.
Cả s1 và s2 vẫn trỏ đến cùng một underlying array. Vì vậy, việc sửa phần tử thông qua s2 cũng được nhìn thấy qua s1.
Tuy nhiên, hai slice header vẫn độc lập.
Ví dụ:
func main() {
s1 := []int{1, 2, 3}
s2 := s1
s2 = s2[:2]
fmt.Println(s1) // [1 2 3]
fmt.Println(s2) // [1 2]
}
Thay đổi length của s2 không làm thay đổi length của s1, vì length nằm trong từng slice header riêng biệt.
Hàm copy
Hàm built-in copy sao chép phần tử từ một slice sang slice khác:
copy(destination, source)
Giá trị trả về là số phần tử thực sự được sao chép.
Số lượng phần tử được copy bằng giá trị nhỏ hơn giữa:
len(destination)và:
len(source)Ví dụ:
func main() {
s1 := []string{"a", "b", "c", "d"}
s2 := make([]string, len(s1))
count := copy(s2, s1)
fmt.Println("Copied", count, "elements")
fmt.Println(s2)
}
Kết quả:
$ go run main.go
Copied 4 elements
[a b c d]
Vì s2 được tạo bằng make, nó có underlying array riêng.
Sau khi copy, hai slice chứa cùng nội dung nhưng không còn chia sẻ dữ liệu:
s2[0] = "changed"
fmt.Println(s1) // [a b c d]
fmt.Println(s2) // [changed b c d]
Destination phải có length đủ lớn
copy chỉ ghi vào những phần tử đã tồn tại trong destination.
Ví dụ sau không sao chép được phần tử nào:
source := []int{1, 2, 3}
destination := make([]int, 0, 3)
count := copy(destination, source)
fmt.Println(count) // 0
fmt.Println(destination) // []
Mặc dù destination có capacity bằng 3, length của nó vẫn bằng 0.
Để copy ba phần tử, destination cần có length bằng 3:
destination := make([]int, len(source))
copy(destination, source)
Hoặc sử dụng append:
destination := append([]int(nil), source...)
Hàm append
Hàm built-in append thêm một hoặc nhiều phần tử vào cuối slice:
s = append(s, value)
Hoặc:
s = append(s, value1, value2, value3)
Ví dụ:
func main() {
s := []string{"a", "b"}
s = append(s, "c", "d")
fmt.Println(s)
}
Kết quả:
[a b c d]Điểm quan trọng nhất là:
appendtrả về một slice header mới và kết quả này luôn phải được sử dụng.
append khi capacity còn đủ
Nếu underlying array vẫn còn đủ capacity, append ghi phần tử mới trực tiếp vào array hiện có.
Ví dụ:
func main() {
s := make([]int, 2, 4)
s[0] = 10
s[1] = 20
fmt.Println(len(s), cap(s)) // 2 4
s = append(s, 30)
fmt.Println(s) // [10 20 30]
fmt.Println(len(s), cap(s)) // 3 4
}
Underlying array có bốn vị trí, trong khi slice ban đầu chỉ sử dụng hai vị trí.
Vì vậy, append có thể thêm 30 mà không cần cấp phát array mới.
append khi capacity không còn đủ
Nếu capacity hiện tại không đủ, Go sẽ:
- Cấp phát một underlying array lớn hơn
- Sao chép các phần tử cũ sang array mới
- Thêm các phần tử mới
- Trả về slice header trỏ đến array mới
Ví dụ:
func main() {
s1 := []string{"a", "b", "c", "d"}
s2 := append(s1, "e", "f")
fmt.Println(s1)
fmt.Println(s2)
}
Kết quả:
$ go run main.go
[a b c d]
[a b c d e f]
Trong ví dụ này:
len(s1) == 4
cap(s1) == 4
Underlying array không còn chỗ trống. Vì vậy, append phải cấp phát một array mới cho s2.
Sau đó:
s1vẫn trỏ đến array cũs2trỏ đến array mới- Hai slice không còn chia sẻ underlying array
Luôn gán lại kết quả của append
Cách sử dụng đúng:
s = append(s, 4)
Không nên coi append là thao tác sửa trực tiếp slice header ban đầu.
Ví dụ:
func grow(s []int) {
s = append(s, 4)
}
func main() {
values := []int{1, 2, 3}
grow(values)
fmt.Println(values) // [1 2 3]
}
Khi values được truyền vào grow, slice header được sao chép theo giá trị.
Bên trong hàm:
s = append(s, 4)
chỉ cập nhật biến s cục bộ. Slice header của values bên ngoài không được cập nhật.
Để người gọi nhận được slice mới, hàm cần trả kết quả:
func grow(s []int) []int {
return append(s, 4)
}
func main() {
values := []int{1, 2, 3}
values = grow(values)
fmt.Println(values) // [1 2 3 4]
}
Một cách khác là truyền pointer đến slice, nhưng trong hầu hết trường hợp, trả về slice mới là cách đơn giản và idiomatic hơn.
Slice và variadic function
Variadic function là hàm có thể nhận số lượng tham số thay đổi.
Ví dụ:
func add(values ...int) int {
sum := 0
for _, value := range values {
sum += value
}
return sum
}
Có thể gọi hàm bằng nhiều đối số riêng lẻ:
fmt.Println(add(1, 2, 3)) // 6
Bên trong hàm, values có kiểu:
[]intKhi đã có sẵn một slice, toán tử ... có thể mở rộng slice thành các đối số riêng lẻ:
func main() {
values := []int{1, 2, 3}
fmt.Println(add(values...)) // 6
}
Nếu bỏ ..., compiler sẽ báo lỗi vì []int là một giá trị slice, trong khi hàm đang mong đợi nhiều đối số kiểu int.
Cách append tăng capacity
Nếu mỗi lần thêm phần tử đều cấp phát một array mới lớn hơn đúng một vị trí, việc xây dựng một slice lớn sẽ rất tốn kém.
Ví dụ, để thêm n phần tử, runtime sẽ phải sao chép liên tục:
1 phần tử
2 phần tử
3 phần tử
4 phần tử
...
n phần tử
Tổng số phần tử phải sao chép có thể tăng theo bậc hai.
Để tránh vấn đề này, runtime Go tăng capacity theo từng bước lớn hơn.
Với slice nhỏ, capacity thường tăng nhanh, có thể gần như gấp đôi. Khi slice lớn hơn, tốc độ tăng dần chậm lại để cân bằng giữa hai mục tiêu:
- Giảm số lần cấp phát lại
- Tránh dành quá nhiều bộ nhớ không sử dụng
Chi tiết tăng trưởng là quyết định triển khai của runtime và có thể thay đổi giữa các phiên bản Go. Vì vậy, code không nên phụ thuộc vào một hệ số tăng capacity cụ thể.
Điều chúng ta có thể dựa vào là:
appendcó chi phí hằng số khấu hao — amortized constant time.
Phần lớn các lần append chỉ ghi vào vùng nhớ đã được cấp phát. Thỉnh thoảng, một lần append phải cấp phát và sao chép toàn bộ dữ liệu, nhưng chi phí đó được phân bổ qua nhiều lần append rẻ trước đó.
Bug aliasing kinh điển của append
Một trong những lỗi khó phát hiện nhất khi làm việc với slice xuất hiện khi nhiều slice cùng chia sẻ underlying array.
Hãy xem ví dụ sau:
func main() {
a := []int{1, 2, 3, 4, 5}
s1 := a[0:2]
s2 := append(s1, 99)
fmt.Println(a)
fmt.Println(s1)
fmt.Println(s2)
}
Kết quả:
$ go run main.go
[1 2 99 4 5]
[1 2]
[1 2 99]
Có thể chúng ta kỳ vọng append chỉ tạo ra:
[1 2 99]mà không làm thay đổi a.
Tuy nhiên, s1 có:
len(s1) == 2
cap(s1) == 5
Nó bắt đầu tại phần tử đầu tiên của a và còn đủ capacity để mở rộng đến cuối underlying array.
Vì capacity còn đủ, lệnh:
append(s1, 99)không cấp phát array mới. Thay vào đó, nó ghi trực tiếp 99 vào vị trí tiếp theo của underlying array:
a[2] = 99Giá trị 3 ban đầu bị ghi đè.
Điểm nguy hiểm là hành vi của append phụ thuộc vào capacity tại thời điểm chạy:
- Còn capacity: dùng lại underlying array
- Hết capacity: cấp phát array mới
Vì vậy, cùng một đoạn code có thể chia sẻ dữ liệu trong trường hợp này nhưng lại trở nên độc lập trong trường hợp khác.
Full slice expression
Go cung cấp biểu thức slice ba chỉ số:
a[low:high:max]Trong đó:
- Length bằng
high - low - Capacity bằng
max - low
Ví dụ:
a := []int{1, 2, 3, 4, 5}
s := a[0:2:2]
fmt.Println(len(s)) // 2
fmt.Println(cap(s)) // 2
Vì length và capacity đều bằng 2, slice không còn khả năng mở rộng trong underlying array hiện tại.
Nếu gọi:
s2 := append(s, 99)
append buộc phải cấp phát array mới:
func main() {
a := []int{1, 2, 3, 4, 5}
s1 := a[0:2:2]
s2 := append(s1, 99)
fmt.Println(a)
fmt.Println(s1)
fmt.Println(s2)
}
Kết quả:
[1 2 3 4 5]
[1 2]
[1 2 99]
Underlying array của a không bị ghi đè.
Full slice expression đặc biệt hữu ích khi trả về hoặc truyền một sub-slice cho code khác nhưng không muốn phía nhận có thể append lấn vào vùng dữ liệu phía sau.
Ví dụ:
func firstTwo(values []int) []int {
return values[:2:2]
}
Code nhận slice vẫn có thể sửa hai phần tử hiện tại, vì chúng còn chia sẻ underlying array:
result[0] = 100
Nhưng nếu append, runtime sẽ phải cấp phát array mới.
Nếu cần cô lập hoàn toàn dữ liệu, chúng ta vẫn phải tạo bản sao.
Tạo một slice độc lập
Cả phép gán slice và thao tác slicing đều không sao chép các phần tử.
s2 := s1
và:
s2 := s1[:]
đều có thể khiến hai slice chia sẻ cùng underlying array.
Để tạo một bản sao độc lập, có thể sử dụng make kết hợp với copy:
func clone(source []int) []int {
result := make([]int, len(source))
copy(result, source)
return result
}
Sử dụng:
func main() {
s1 := []int{1, 2, 3}
s2 := clone(s1)
s2[0] = 100
fmt.Println(s1) // [1 2 3]
fmt.Println(s2) // [100 2 3]
}
Một cách viết ngắn hơn:
s2 := append([]int(nil), s1...)
Từ Go 1.21, package slices cũng cung cấp hàm:
s2 := slices.Clone(s1)
Nil slice và empty slice
Nil slice và empty slice đều có length bằng 0, nhưng không hoàn toàn giống nhau.
var a []int
b := []int{}
c := make([]int, 0)
Kiểm tra:
fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
fmt.Println(c == nil) // false
Cả ba đều hoạt động giống nhau với nhiều thao tác:
len(a) // 0
len(b) // 0
cap(a) // 0
cap(b) // 0
Có thể range an toàn:
for range a {
// Không chạy vòng lặp nào
}
Có thể append an toàn:
a = append(a, 1)
b = append(b, 1)
Khác biệt thường trở nên quan trọng tại ranh giới serialization.
Ví dụ với JSON:
package main
import (
"encoding/json"
"fmt"
)
func main() {
var a []int
b := []int{}
jsonA, _ := json.Marshal(a)
jsonB, _ := json.Marshal(b)
fmt.Println(string(jsonA))
fmt.Println(string(jsonB))
}
Kết quả:
null
[]
Nil slice được encode thành:
nullEmpty slice được encode thành:
[]Nếu API contract yêu cầu luôn trả về một JSON array, kể cả khi không có phần tử, chúng ta cần bảo đảm field được khởi tạo thành empty slice thay vì nil slice.
Trong code nội bộ, nil slice thường là lựa chọn tự nhiên vì không cần cấp phát và vẫn hoạt động tốt với len, range và append.
Chỉ nên phân biệt nil slice và empty slice tại những nơi sự khác biệt có thể được quan sát, chẳng hạn:
- JSON response
- Database driver
- Reflection
- API contract
- Logic kiểm tra trạng thái chưa khởi tạo
Slice có thể giữ lại một underlying array rất lớn
Một slice nhỏ vẫn có thể giữ toàn bộ underlying array sống trong bộ nhớ.
Ví dụ:
func firstTwo(huge []byte) []byte {
return huge[:2]
}
Giả sử huge trỏ đến một underlying array có kích thước 100 MB.
Mặc dù hàm chỉ trả về hai byte, slice kết quả vẫn trỏ vào array 100 MB. Garbage collector không thể giải phóng array đó chừng nào slice hai byte vẫn còn được sử dụng.
Có thể hình dung như sau:
Underlying array: 100 MB
┌──────────────────────────────────────────────┐
│ 2 byte được dùng │ 99.999... MB không dùng │
└──────────────────────────────────────────────┘
↑
Slice nhỏ vẫn giữ reference đến toàn bộ array
Đây không phải memory leak theo nghĩa bộ nhớ bị mất hoàn toàn. Bộ nhớ vẫn có reference hợp lệ. Tuy nhiên, chương trình đang giữ lại một vùng nhớ lớn hơn nhiều so với nhu cầu thực tế.
Để tránh tình trạng này, hãy copy phần dữ liệu nhỏ sang một array mới:
func firstTwoCopy(huge []byte) []byte {
result := make([]byte, 2)
copy(result, huge[:2])
return result
}
Sau khi hàm kết thúc, nếu không còn reference nào khác đến huge, underlying array lớn có thể được garbage collector thu hồi.
Một cách viết khác:
func firstTwoCopy(huge []byte) []byte {
return append([]byte(nil), huge[:2]...)
}
Pattern này đặc biệt quan trọng khi xử lý:
- File lớn
- HTTP response body
- Buffer mạng
- Dữ liệu parse từ một block lớn
- Chuỗi byte từ cache
- Batch dữ liệu lớn nhưng chỉ giữ lại một phần nhỏ
Package slices trong thư viện chuẩn
Từ Go 1.21, thư viện chuẩn cung cấp package slices với nhiều hàm generic hữu ích.
Ví dụ:
package main
import (
"fmt"
"slices"
)
func main() {
values := []int{3, 1, 2}
slices.Sort(values)
fmt.Println(values)
fmt.Println(slices.Contains(values, 2))
}
Kết quả:
[1 2 3]
true
Một số hàm thường dùng:
Clone
cloned := slices.Clone(values)
Tạo một slice mới với underlying array độc lập.
Contains
exists := slices.Contains(values, 10)
Kiểm tra một phần tử có tồn tại hay không.
Index
index := slices.Index(values, 10)
Trả về chỉ số đầu tiên của phần tử hoặc -1 nếu không tìm thấy.
Equal
equal := slices.Equal(a, b)
So sánh từng phần tử của hai slice.
Điều này cần thiết vì slice không thể được so sánh trực tiếp bằng ==, ngoại trừ việc so sánh với nil.
a == b // Compile error
Reverse
slices.Reverse(values)
Đảo ngược thứ tự phần tử ngay trên slice hiện tại.
Sort
slices.Sort(values)
Sắp xếp slice chứa các kiểu có thứ tự.
Delete
values = slices.Delete(values, start, end)
Xóa một vùng phần tử theo khoảng [start:end).
Package slices giúp code rõ ràng hơn và giảm nhu cầu tự viết lại những vòng lặp phổ biến.
Những lỗi thường gặp với array và slice
1. Index out of range
Truy cập chỉ số nằm ngoài length sẽ gây lỗi.
Với array và chỉ số là hằng số, compiler thường có thể phát hiện ngay:
arr := [3]int{1, 2, 3}
fmt.Println(arr[3])
Lỗi:
invalid argument: index 3 out of bounds [0:3]Với chỉ số được xác định tại runtime:
s := []int{1, 2, 3}
i := 3
fmt.Println(s[i])
Chương trình sẽ panic:
panic: runtime error: index out of range [3] with length 3Chỉ số hợp lệ luôn nằm trong khoảng:
0 <= index < len(collection)2. Quên rằng phép gán array tạo bản sao
a := [3]int{1, 2, 3}
b := a
b[0] = 100
fmt.Println(a) // [1 2 3]
fmt.Println(b) // [100 2 3]
b là một array độc lập.
Nếu muốn sửa array gốc thông qua hàm, có thể truyền pointer:
func change(values *[3]int) {
values[0] = 100
}
Tuy nhiên, trong phần lớn code ứng dụng, slice thường thuận tiện hơn.
3. Quên rằng phép gán slice chia sẻ dữ liệu
s1 := []int{1, 2, 3}
s2 := s1
s2[0] = 100
fmt.Println(s1) // [100 2 3]
fmt.Println(s2) // [100 2 3]
Hai slice header khác nhau nhưng cùng trỏ đến một underlying array.
Khi cần dữ liệu độc lập, hãy clone:
s2 := slices.Clone(s1)Hoặc:
s2 := make([]int, len(s1))
copy(s2, s1)
4. Quên gán lại kết quả của append
Sai về mặt ý tưởng:
func grow(s []int) {
s = append(s, 4)
}
Biến s chỉ là bản sao cục bộ của slice header.
Cách đúng:
func grow(s []int) []int {
return append(s, 4)
}
Và tại nơi gọi:
s = grow(s)Quy tắc nên ghi nhớ:
s = append(s, value)5. Nhầm lẫn giữa length và capacity
s := make([]int, 5)Đoạn code trên tạo một slice đã có năm phần tử:
[0 0 0 0 0]Nó không tạo một slice rỗng có chỗ chứa năm phần tử.
Để tạo slice rỗng nhưng chuẩn bị trước capacity:
s := make([]int, 0, 5)6. Dựa vào capacity growth cụ thể
Không nên viết logic phụ thuộc vào việc runtime sẽ tăng capacity lên chính xác bao nhiêu.
Ví dụ, không nên giả định:
newCap == oldCap * 2Chiến lược tăng capacity là chi tiết triển khai và có thể thay đổi theo:
- Phiên bản Go
- Kích thước phần tử
- Capacity hiện tại
- Số phần tử được append
- Quy tắc làm tròn cấp phát của runtime
Chỉ nên coi capacity là một công cụ để tối ưu allocation hoặc kiểm soát aliasing, không phải một phần của business logic.
7. Trả về sub-slice của buffer lớn
func extract(data []byte) []byte {
return data[:10]
}
Nếu data rất lớn và kết quả được giữ lâu, toàn bộ underlying array vẫn còn sống.
Khi cần giữ lâu dài một phần nhỏ:
func extract(data []byte) []byte {
result := make([]byte, 10)
copy(result, data[:10])
return result
}
Khi nào nên sử dụng array?
Trong code Go thông thường, slice xuất hiện thường xuyên hơn array. Tuy nhiên, array vẫn phù hợp trong một số trường hợp.
Kích thước là một phần của ý nghĩa dữ liệu
Ví dụ:
type IPv4 [4]byteHoặc:
type RGB [3]uint8Độ dài cố định giúp compiler bảo đảm cấu trúc luôn có đúng số phần tử.
Cần khả năng so sánh bằng ==
Array có thể được so sánh nếu kiểu phần tử của nó có thể so sánh:
a := [3]int{1, 2, 3}
b := [3]int{1, 2, 3}
fmt.Println(a == b) // true
Slice không hỗ trợ phép so sánh trực tiếp như vậy.
Cần dùng làm map key
Array có thể làm map key nếu kiểu phần tử có thể so sánh:
cache := map[[16]byte]string{}Slice không thể làm map key vì slice không phải comparable type.
Dữ liệu nhỏ và có kích thước cố định
Array có thể phù hợp với:
- Hash
- UUID dạng byte
- Vector kích thước cố định
- Ma trận nhỏ cố định
- Buffer có kích thước compile-time
- Dữ liệu giao thức có cấu trúc cố định
Khi nào nên sử dụng slice?
Slice là lựa chọn mặc định cho hầu hết danh sách dữ liệu trong Go:
[]User
[]Order
[]string
[]byte
Slice phù hợp khi:
- Số lượng phần tử thay đổi
- Cần
append - Cần truyền danh sách vào hàm
- Cần trả về tập kết quả từ database hoặc API
- Cần thao tác với một vùng dữ liệu trong array hoặc buffer
- Không muốn sao chép toàn bộ array khi truyền qua các hàm
Tuy nhiên, vì slice có thể chia sẻ underlying array, chúng ta cần xác định rõ ownership:
- Ai được phép sửa phần tử?
- Ai được phép append?
- Slice có được lưu giữ lâu dài không?
- Có cần clone trước khi trao dữ liệu cho code khác không?
- Có đang giữ lại một buffer lớn chỉ vì một sub-slice nhỏ không?
Hiểu ownership quan trọng hơn việc chỉ ghi nhớ cú pháp.
Mô hình tư duy cần ghi nhớ
Khi làm việc với array và slice, hãy giữ mô hình sau trong đầu.
Array
Biến array
│
▼
Toàn bộ dữ liệu nằm trực tiếp trong giá trị array
Phép gán array:
Array A ──copy toàn bộ──▶ Array BSau khi copy, hai array độc lập.
Slice
Slice header
├── pointer ─────────────▶ Underlying array
├── length
└── capacity
Phép gán slice:
Slice header A ──copy header──▶ Slice header B
│ │
└──────────────┬───────────────┘
▼
Cùng underlying array
Hai slice có header riêng nhưng có thể cùng nhìn vào một vùng dữ liệu.
Append
Còn capacity
│
└── Ghi vào underlying array hiện có
Không còn capacity
│
└── Cấp phát array mới
Sao chép dữ liệu cũ
Trả về slice header mới
Đây là lý do kết quả của append luôn phải được gán lại.
Tổng kết
Array là một tập hợp có kích thước cố định, trong đó độ dài là một phần của kiểu dữ liệu.
Array mang ngữ nghĩa giá trị. Khi gán hoặc truyền array vào hàm, toàn bộ các phần tử được sao chép.
Slice linh hoạt hơn và được sử dụng phổ biến trong code Go thực tế. Tuy nhiên, slice không trực tiếp chứa toàn bộ dữ liệu. Nó là một header nhỏ gồm:
pointer + length + capacitySlice header được sao chép theo giá trị, nhưng nhiều slice header có thể cùng trỏ đến một underlying array. Vì vậy:
- Thay đổi phần tử có thể được chia sẻ
- Thay đổi length của một slice không làm thay đổi slice khác
appendcó thể dùng lại array cũ hoặc cấp phát array mới- Phải luôn sử dụng giá trị mà
appendtrả về - Muốn có bản sao độc lập, cần sử dụng
copy,slices.Clonehoặc một kỹ thuật clone tương đương - Sub-slice nhỏ có thể giữ một underlying array lớn sống trong bộ nhớ
- Full slice expression có thể giới hạn capacity và ngăn
appendghi lấn vào dữ liệu phía sau
Điều quan trọng nhất không phải là ghi nhớ rằng slice là “reference type”, bởi cách mô tả đó dễ gây hiểu nhầm.
Mô hình chính xác hơn là:
Slice là một giá trị header được sao chép theo value semantics, nhưng các bản sao của header có thể cùng tham chiếu đến một underlying array.
Khi nắm được mô hình này, hầu hết hành vi tưởng như bất ngờ của slice — từ mutation, append, capacity, aliasing đến giữ lại bộ nhớ — đều trở nên dễ dự đoán.
DUKE
Bình luận