Slice là kiểu dữ liệu bạn dùng nhiều nhất trong Go, và cũng là nguồn của cái bẫy khó chịu nhất với người mới. Bài này mổ nó ra.
Mảng khác slice
var a [3]int // MẢNG: độ dài là một phần của kiểu
var s []int // SLICE: không có độ dài trong kiểu
[3]int và [4]int là hai kiểu khác nhau. Mảng có kích thước cố định, truyền vào hàm là sao chép toàn bộ, và so sánh được bằng == như bài 5 đã đo.
Trong thực tế bạn hiếm khi dùng mảng trực tiếp. Slice là thứ mọi API nhận và trả.
Slice là ba con số
Bài 4 đo được unsafe.Sizeof của slice là 24 byte — đó là ba trường 8 byte:
con trỏ tới mảng nền | độ dài (len) | sức chứa (cap)
make([]int, 3, 10) -> len=3 cap=10 [0 0 0]
len là số phần tử dùng được. cap là số ô đã cấp trong mảng nền, tính từ vị trí slice bắt đầu.
Hiểu ba con số này là hiểu toàn bộ hành vi của slice.
append có hai chế độ
append khi còn cap : cap giữ nguyên 10, DÙNG LẠI mảng nền
append khi ĐẦY : cap 3 -> 6, cấp mảng nền MỚI
Khi len < cap, append ghi vào ô trống sẵn có và trả về slice trỏ cùng mảng nền.
Khi len == cap, Go cấp mảng mới (thường gấp đôi), chép dữ liệu sang, rồi trả về slice trỏ mảng mới.
Nên câu này luôn đúng và phải viết đúng:
s = append(s, x) // BẮT BUỘC gán lại
Không gán lại thì khi append cấp mảng mới, bạn mất kết quả. Trình biên dịch có cảnh báo cho trường hợp đơn giản, nhưng không bắt hết.
Cái bẫy: chia sẻ mảng nền
Đây là phần chính của bài.
goc := []int{1, 2, 3, 4, 5}
con := goc[1:3]
goc=[1 2 3 4 5] con=goc[1:3]=[2 3] (len=2 cap=4)
Chú ý cap=4: slice con nhìn thấy tới hết mảng nền tính từ chỗ nó bắt đầu, dù len chỉ là 2.
Sửa phần tử:
con[0] = 99 -> goc=[1 99 3 4 5] <<< GỐC BỊ ĐỔI
Cắt slice không sao chép gì cả. con và goc dùng chung bộ nhớ.
Và đây là phần tệ hơn:
append(con, 777) -> goc=[1 99 3 777 5] <<< GHI ĐÈ PHẦN TỬ GỐC
Vì con còn cap dư, append ghi thẳng vào ô kế tiếp của mảng nền — mà ô đó là phần tử thứ tư của goc. Bạn append vào một slice và làm hỏng dữ liệu của slice khác.
Ba cách phòng
Sao chép tường minh khi cần độc lập:
antoan := append([]int(nil), goc[1:3]...)
sửa antoan -> goc không đổi
Hoặc copy(dst, src) nếu đã có slice đích.
Cắt ba chỉ số để giới hạn cap:
con := goc[1:3:3] // len=2, cap=2
Chỉ số thứ ba đặt trần cho cap. Giờ append vào con buộc phải cấp mảng mới, nên không đụng tới goc. Đây là cách rẻ nhất khi bạn trả một slice con ra khỏi hàm.
Trong API công khai, hãy nghĩ kỹ trước khi trả slice trỏ vào dữ liệu nội bộ. Người gọi sửa nó là sửa luôn trạng thái của bạn. Trả bản sao, hoặc ghi rõ trong tài liệu.
Vài chi tiết thực dụng
Cấp trước cap nếu biết kích thước:
s := make([]int, 0, 1000)
Tránh được nhiều lần cấp lại và chép. Với vòng lặp lớn, đây là tối ưu dễ nhất và rõ nhất.
Slice nil dùng được ngay. var s []int rồi append luôn — bài 4 đã đo. Không cần make nếu bạn không cần cap sẵn.
Xoá phần tử ở giữa không có hàm sẵn:
s = append(s[:i], s[i+1:]...)
Cách này giữ thứ tự nhưng chép phần đuôi. Không cần giữ thứ tự thì đổi phần tử cuối vào chỗ i rồi cắt — nhanh hơn hẳn.
slices từ Go 1.21 có sẵn Contains, Index, Sort, Reverse, Equal. Dùng nó thay vì tự viết.
Thử ba mươi giây
a := []int{1, 2, 3, 4, 5}
b := a[:2]
b = append(b, 99)
fmt.Println(a)
In ra [1 2 99 4 5]. Bạn append vào b và phần tử thứ ba của a biến mất.
Đổi dòng thứ hai thành b := a[:2:2] rồi chạy lại — a giữ nguyên. Ba mươi giây, và đó là toàn bộ bài học về cap.
Ngày mai: map — comma-ok, thứ tự duyệt ngẫu nhiên có chủ đích, và vì sao ghi vào map nil thì panic.