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[4]inthai 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ả. congoc 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

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.

Không có cảnh báo nào. Mã biên dịch sạch, chạy không panic, và dữ liệu sai. Đây là lỗi đặc trưng nhất của Go và là lý do bài này tồn tại.

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.