Hình dung slice như một cặp thẻ đánh dấu một khoảng trang trong cuốn sách dùng chung. Nó không phải bản thân dữ liệu — nó là một cái nhìn vào dữ liệu: thẻ ghi bạn bắt đầu từ trang nào, đọc mấy trang, và còn bao nhiêu trang nữa trong sách tính từ chỗ bạn. Hai cặp thẻ có thể cùng trỏ vào một cuốn sách, nên viết lên trang qua cặp này thì cặp kia cũng thấy. 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 — số trang bạn đang đọc. cap là số ô đã cấp trong mảng nền tính từ vị trí slice bắt đầu — số trang còn lại trong sách từ chỗ bạn đặt thẻ.
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. Cắt slice chỉ là đặt cặp thẻ mới lên cùng cuốn sách — không sao chép gì cả.
Sửa phần tử:
con[0] = 99 -> goc=[1 99 3 4 5] <<< GỐC BỊ ĐỔI
con và goc dùng chung bộ nhớ — viết lên trang qua thẻ này thì thẻ kia cũng thấy.
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: viết sang trang kế, mà trang đó nằm trong khoảng cặp thẻ khác đang đọ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 — photocopy mấy trang sang cuốn sách riêng, viết gì trên đó là của bạn.
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.
Nếu chỉ nhớ một thứ từ cả bài, để nó là cái bẫy cap — thử trong 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.
Mẫu số chung
Slice là một cái nhìn (view) vào bộ nhớ bạn không sở hữu — và cái gì là view thì cái đó trùng danh (alias): hai view trên cùng một vùng đệm thấy lẫn nhau khi ghi. Đây là một phân biệt có ở mọi ngôn ngữ, và cái bẫy sinh ra từ việc không biết mình đang cầm view hay bản sao.
- Python:
a[1:3]trên list sao chép (không trùng danh), nhưng cắt mảng NumPy lại trả về view (trùng danh) — cùng cú pháp, hành vi ngược nhau, và là cái bẫy kinh điển của NumPy. - Java:
subListtrả về một view tựa trên list gốc. C++ cóstring_view/spanlà view không sở hữu, tường minh. - Rust:
&[T]cũng là view — nhưng trình kiểm mượn (borrow checker) cấm vừa-có-view-vừa-sửa: một tham chiếu sửa được, hoặc nhiều tham chiếu chỉ-đọc, không cả hai. Chính là cái bẫy slice của Go được đưa thành lỗi biên dịch.
Hai điều đáng mang theo. Một: view thì rẻ (O(1), không chép) nhưng trùng danh; bản sao thì an toàn nhưng tốn O(n) — đây là đánh đổi không tránh được, và mỗi ngôn ngữ chọn một mặc định (list Python chép, NumPy view, slice Go view, subList Java view) nên việc đầu tiên cần biết về bất kỳ thao tác cắt nào là "nó cho mình view hay bản sao". Hai: cách duy nhất có view rẻ mà không dính bẫy trùng danh là theo dõi quyền sở hữu lúc biên dịch — borrow checker của Rust sinh ra đúng để xoá sổ cái bẫy slice của Go; còn ở mọi nơi khác, biết mình cầm view hay bản sao là việc của bạn. Sợi chỉ chung: khi lấy một khoảng con của bất cứ thứ gì, hãy hỏi "mình vừa nhận một cửa sổ hay một bản sao" — nếu là cửa sổ, ghi qua nó chạm tới bản gốc và một lần lớn lên có thể âm thầm tách ra; hãy chép tường minh (hoặc chặn cap) mỗi khi khoảng con cần sống lâu hơn hoặc tách khỏi cha nó.
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.