BeeTool.Dev - Essential Tools for developers

Stories.Tool Tabs VaultPersonal Documents
BeeTool.Dev

Essential tools, knowledge, and playgrounds for developers.

Explore

  • Stories
  • Personal Documents
  • Features
  • Beetool for Mac
  • Algorithms Playground

Tool Tabs

  • JSON Compare
  • SQL Formatter
  • Text Diff
  • All tools →

Learn

  • Sorting algorithms
  • Searching algorithms
  • Algorithm Race Mode
© 2024 – 2026 BeeTool.Dev
AboutContactPrivacy policyTerms
All stories
GolangCourse

Array và Slice trong Go

Trong bài viết này, chúng ta sẽ bắt đầu từ array, sau đó đi sâu vào cách slice hoạt động bên dưới runtime Go.

DUKE443 viewsJuly 20, 2026
Array và Slice trong Go

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 append cấ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 string
  • false đối với boolean
  • nil đố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ị:

  1. Chỉ số hiện tại
  2. 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ó 2 phầ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:

  • s1 và s2 có length riêng
  • s1 và s2 có 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 đó:

  • low là chỉ số bắt đầu, được bao gồm
  • high là 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:

[]T

Ví 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 := a

Phé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 := s1

chỉ 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à:

append trả 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ẽ:

  1. Cấp phát một underlying array lớn hơn
  2. Sao chép các phần tử cũ sang array mới
  3. Thêm các phần tử mới
  4. 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 đó:

  • s1 vẫn trỏ đến array cũ
  • s2 trỏ đế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:

[]int

Khi đã 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à:

append có 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] = 99

Giá 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:

null

Empty 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 3

Chỉ 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 * 2

Chiế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]byte

Hoặ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 B

Sau 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 + capacity

Slice 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
  • append có 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à append trả về
  • Muốn có bản sao độc lập, cần sử dụng copy, slices.Clone hoặ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 append ghi 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.

Quiz


#Backend
DUKE
Written byDUKEView all stories by DUKE

Bình luận