Slice trong Go: len, cap và bẫy khi append
1. Tổng quan
Slice là kiểu dữ liệu được dùng nhiều nhất trong Go, nhưng cũng gây ra nhiều bug khó phát hiện nhất. Lý do là nhiều slice có thể dùng chung một mảng bên dưới. Khi đó append có thể ghi đè dữ liệu của một slice khác mà không báo lỗi gì.
Bài viết giải thích len và cap, cách append quyết định cấp phát mảng mới hay ghi vào mảng cũ, và các kỹ thuật tránh lỗi: full slice expression, copy, slices.Clone. Đọc xong, bạn sẽ đoán được đúng kết quả của các đoạn code có append và biết cách viết test để bắt lỗi aliasing.
Các ví dụ dùng Go 1.21 trở lên, vì package slices có từ phiên bản này.
2. Slice thực chất là gì
Một slice là một struct nhỏ gồm ba trường:
- pointer: trỏ tới phần tử đầu tiên trong mảng bên dưới (backing array).
- len: số phần tử bạn có thể truy cập.
- cap: số phần tử tính từ pointer tới cuối mảng bên dưới.
Khi bạn truyền slice vào hàm hoặc gán cho biến khác, Go chỉ copy struct ba trường này. Mảng bên dưới không được copy.
2.1. Quan sát len và cap
package main
import "fmt"
func main() {
orders := []string{"A01", "A02", "A03", "A04", "A05"}
recent := orders[1:3]
fmt.Println(recent, len(recent), cap(recent))
}
[A02 A03] 2 4
orders[1:3] lấy các phần tử từ chỉ số 1 tới trước chỉ số 3, nên len bằng 2. cap bằng 4 vì từ vị trí orders[1] tới cuối mảng còn 4 ô: A02, A03, A04, A05.
2.2. Hai slice, một mảng
Vì recent trỏ vào cùng mảng với orders, sửa phần tử của slice này sẽ thấy ngay ở slice kia:
recent[0] = "CANCELLED"
fmt.Println(orders)
[A01 CANCELLED A03 A04 A05]
Đây là hành vi có chủ đích. Nó giúp việc cắt slice không tốn chi phí. Nhưng nó cũng là gốc của mọi bẫy ở các mục sau.
3. append hoạt động thế nào
append(s, x) làm một trong hai việc:
- Nếu
len(s) < cap(s): ghixvào ôs[len(s)]trong mảng hiện tại, rồi trả về slice mới cólen + 1. - Nếu
len(s) == cap(s): cấp phát mảng mới lớn hơn, copy dữ liệu cũ sang, ghix, rồi trả về slice trỏ vào mảng mới.
Chính vì trường hợp 2 có thể xảy ra, bạn luôn phải gán lại kết quả: s = append(s, x).
3.1. Quan sát cap tăng dần
package main
import "fmt"
func main() {
var ids []int
for i := 0; i < 10; i++ {
ids = append(ids, i)
fmt.Printf("len=%d cap=%d\n", len(ids), cap(ids))
}
}
len=1 cap=1
len=2 cap=2
len=3 cap=4
len=4 cap=4
len=5 cap=8
len=6 cap=8
len=7 cap=8
len=8 cap=8
len=9 cap=16
len=10 cap=16
Với slice nhỏ, cap thường gấp đôi mỗi lần đầy. Với slice lớn, runtime tăng chậm hơn, và kết quả còn được làm tròn theo size class của bộ cấp phát.
Lưu ý: Công thức tăng
caplà chi tiết cài đặt của runtime và đã thay đổi qua các phiên bản Go. Đừng viết code phụ thuộc vào một giá trịcapcụ thể sau khiappend.
4. Bẫy 1: append ghi đè dữ liệu của slice gốc
Đây là lỗi phổ biến nhất:
package main
import "fmt"
func main() {
prices := []int{10, 20, 30, 40, 50}
firstTwo := prices[:2]
firstTwo = append(firstTwo, 99)
fmt.Println("firstTwo:", firstTwo)
fmt.Println("prices: ", prices)
}
firstTwo: [10 20 99]
prices: [10 20 99 40 50]
firstTwo có len = 2 nhưng cap = 5. Mảng còn chỗ, nên append không cấp phát mảng mới mà ghi 99 vào ô thứ ba của mảng chung. Kết quả là prices[2] từ 30 thành 99.
Bug này rất khó tìm. Code không panic, và giá trị bị ghi đè có thể chỉ lộ ra ở một module khác, rất lâu sau đó.
5. Bẫy 2: hai lần append từ cùng một slice
package main
import "fmt"
func main() {
base := make([]string, 3, 4)
base[0], base[1], base[2] = "pen", "book", "bag"
cartA := append(base, "laptop")
cartB := append(base, "phone")
fmt.Println("cartA:", cartA)
fmt.Println("cartB:", cartB)
}
cartA: [pen book bag phone]
cartB: [pen book bag phone]
base còn đúng một ô trống. Lệnh append đầu tiên ghi "laptop" vào ô đó. Lệnh thứ hai ghi "phone" vào cùng ô đó. Cả cartA và cartB đều trỏ vào một mảng, nên "laptop" đã biến mất.
Nếu base có cap = 3, cả hai append sẽ cấp phát mảng riêng và kết quả đúng như bạn mong đợi. Cùng một đoạn code, hành vi đổi theo cap. Đó là lý do bug này chỉ thỉnh thoảng mới xuất hiện.
6. Bẫy 3: append bên trong hàm không đổi slice của caller
package main
import "fmt"
func addItem(items []string, item string) {
items = append(items, item)
}
func main() {
cart := make([]string, 0, 10)
addItem(cart, "pen")
fmt.Println(len(cart), cart)
fmt.Println(cart[:1])
}
0 []
[pen]
Hàm addItem nhận một bản sao của struct slice. append đã ghi "pen" vào mảng chung, vì còn cap. Nhưng len mới chỉ được gán cho biến cục bộ items. Biến cart ở main vẫn có len = 0. Dữ liệu nằm sẵn trong mảng, nhưng bạn chỉ thấy nó khi cố tình cắt lại cart[:1].
Cách sửa là trả về slice mới:
func addItem(items []string, item string) []string {
return append(items, item)
}
// sử dụng
cart = addItem(cart, "pen")
Đây cũng là lý do thư viện chuẩn thiết kế append dưới dạng hàm trả về giá trị, chứ không sửa slice tại chỗ.
7. Bẫy 4: nhầm giữa make với len và make với cap
package main
import "fmt"
func main() {
names := []string{"An", "Binh", "Chi"}
upper := make([]string, len(names)) // sai ý định
for _, n := range names {
upper = append(upper, n+"!")
}
fmt.Println(len(upper), upper)
}
6 [ An! Binh! Chi!]
make([]string, 3) tạo slice có 3 phần tử rỗng, không phải slice rỗng có sức chứa 3. append thêm vào sau 3 chuỗi rỗng đó.
Bạn có hai cách sửa đúng. Chọn một trong hai, không trộn lẫn:
// Cách 1: len = 0, cap = n, rồi append
upper := make([]string, 0, len(names))
for _, n := range names {
upper = append(upper, n+"!")
}
// Cách 2: len = n, gán theo chỉ số
upper := make([]string, len(names))
for i, n := range names {
upper[i] = n + "!"
}
Cả hai đều cấp phát đúng một lần, nhanh hơn việc để append tăng cap nhiều lần.
8. Cách phòng tránh
8.1. Full slice expression: giới hạn cap
Cú pháp s[low:high:max] đặt cap = max - low. Khi cap bằng len, lần append tiếp theo buộc phải cấp phát mảng mới:
prices := []int{10, 20, 30, 40, 50}
firstTwo := prices[:2:2] // len=2, cap=2
firstTwo = append(firstTwo, 99)
fmt.Println("firstTwo:", firstTwo)
fmt.Println("prices: ", prices)
firstTwo: [10 20 99]
prices: [10 20 30 40 50]
prices không bị ảnh hưởng. Kỹ thuật này hữu ích khi bạn trả một phần slice nội bộ ra ngoài và muốn caller append thoải mái mà không phá dữ liệu của bạn.
8.2. slices.Clone và copy: tách hẳn mảng
Khi cần một bản độc lập hoàn toàn, hãy clone:
import "slices"
orders := []string{"A01", "A02", "A03"}
snapshot := slices.Clone(orders)
snapshot[0] = "X"
fmt.Println(orders[0], snapshot[0]) // A01 X
Với Go cũ hơn 1.21, dùng copy:
snapshot := make([]string, len(orders))
copy(snapshot, orders)
copy trả về số phần tử đã copy, bằng giá trị nhỏ hơn giữa len(dst) và len(src). Vì vậy dst phải có len đủ lớn chứ không chỉ cap. Một lỗi hay gặp là viết copy(make([]string, 0, n), src) rồi nhận về slice rỗng.
8.3. Bẫy giữ bộ nhớ
Cắt một slice nhỏ từ một mảng lớn sẽ giữ toàn bộ mảng lớn trong bộ nhớ:
func firstLine(data []byte) []byte {
for i, b := range data {
if b == '\n' {
return slices.Clone(data[:i]) // không giữ cả data
}
}
return slices.Clone(data)
}
Nếu data là file 100 MB và bạn trả về data[:i] mà không clone, garbage collector không thể giải phóng 100 MB đó chừng nào slice kết quả còn được tham chiếu.
9. Viết test để bắt lỗi aliasing
Giả sử bạn có một kiểu Inventory trả về danh sách sản phẩm. Bạn muốn chắc rằng caller append vào kết quả sẽ không làm hỏng dữ liệu nội bộ:
// inventory.go
package inventory
type Inventory struct {
items []string
}
func New(items ...string) *Inventory {
return &Inventory{items: items}
}
func (inv *Inventory) Items() []string {
return inv.items[:len(inv.items):len(inv.items)]
}
// inventory_test.go
package inventory
import (
"slices"
"testing"
)
func TestItems_AppendDoesNotCorruptInternalState(t *testing.T) {
inv := New("pen", "book")
inv.items = append(inv.items[:0:0], "pen", "book", "bag")[:2] // cap dư cố ý
got := inv.Items()
got = append(got, "laptop")
want := []string{"pen", "book"}
if !slices.Equal(inv.Items(), want) {
t.Fatalf("internal items = %v, want %v", inv.Items(), want)
}
if inv.items[:3][2] != "bag" {
t.Fatalf("backing array bị ghi đè: %v", inv.items[:3])
}
if !slices.Equal(got, []string{"pen", "book", "laptop"}) {
t.Fatalf("got = %v", got)
}
}
Test cố tình tạo slice nội bộ có cap lớn hơn len, vì đó là điều kiện để bug xuất hiện. Nếu bạn đổi Items() thành return inv.items, lệnh append(got, "laptop") sẽ ghi đè "bag" và test fail ở assertion thứ hai.
go test ./...
ok example.com/inventory 0.002s
Lưu ý: Test aliasing luôn phải dựng tình huống
len < cap. Nếu slice trong test được tạo bằng literal như[]string{"pen", "book"}thìlen == cap,appendluôn cấp phát mảng mới, và test sẽ pass dù code có bug.
10. Khi nào KHÔNG cần lo
Không phải slice nào cũng cần clone. Copy thừa tốn bộ nhớ và CPU. Bạn có thể dùng chung mảng một cách an toàn khi:
- Slice chỉ được đọc, không ai
appendhay sửa phần tử. - Slice là biến cục bộ, không được chia sẻ ra ngoài hàm.
- Bạn chủ động muốn sửa tại chỗ, ví dụ lọc phần tử bằng
s[:0]để tái sử dụng mảng:
active := orders[:0]
for _, o := range orders {
if o != "CANCELLED" {
active = append(active, o)
}
}
Kỹ thuật này đúng vì active không bao giờ ghi vượt qua vị trí đang đọc của orders. Sau vòng lặp, bạn chỉ nên dùng active và bỏ orders.
11. Kết luận
Slice là một struct gồm pointer, len và cap, trỏ vào một mảng có thể được chia sẻ. append chỉ cấp phát mảng mới khi len == cap. Trong mọi trường hợp khác, nó ghi thẳng vào mảng chung. Đó là nguồn gốc của bốn bẫy đã gặp: ghi đè slice gốc, hai lần append đè lên nhau, append trong hàm không đổi caller, và nhầm len với cap khi make.
Để an toàn, bạn hãy nhớ vài quy tắc:
- Luôn viết
s = append(s, ...). - Hàm cần thêm phần tử thì trả về slice mới.
- Dùng
s[low:high:high]khi trả slice nội bộ ra ngoài. - Dùng
slices.Clonekhi cần bản độc lập hoặc muốn giải phóng mảng lớn.
Khi viết test, nhớ dựng điều kiện len < cap để bug có cơ hội lộ diện.