Slice là kiểu dữ liệu được dùng nhiều nhất trong Go, và cũng là nguồn của những bug khó hiểu nhất — sửa một slice bỗng dưng làm hỏng dữ liệu ở nơi khác, truyền slice vào hàm bị hàm đó thay đổi phần tử. Toàn bộ những hành vi này đến từ một sự thật đơn giản mà nhiều người bỏ qua: slice không phải mảng, nó là một header 24 byte. Hiểu ba trường của header đó là hiểu mọi hành vi slice. Bài này đo trực tiếp cấu trúc slice tận bytes.
Slice header: ba từ máy
Một slice trên máy 64-bit là một struct 24 byte gồm ba từ máy (mỗi từ 8 byte):
ptr: con trỏ tới phần tử đầu tiên của slice trong mảng nền (backing array).len: số phần tử hiện dùng được.cap: số phần tử mảng nền chứa được tính từptr.
Điểm mấu chốt: dữ liệu thực không nằm trong header — nó ở mảng nền, một vùng nhớ chỗ khác. Header chỉ trỏ vào đó. Và nhiều slice có thể cùng trỏ vào một mảng nền, chia sẻ dữ liệu.

Hình 1: Slice header 24 byte {ptr, len, cap}. Dữ liệu ở mảng nền chỗ khác. Sub-slice và truyền slice đều chia sẻ mảng nền — nguồn của sức mạnh lẫn bug.
Đo thật: cấu trúc và chia sẻ mảng nền
Dùng unsafe để soi ba trường của một slice và một sub-slice của nó:
type hdr struct{ ptr uintptr; len int; cap int }
h := (*hdr)(unsafe.Pointer(&s)) // đọc header của s
sub := s[2:4] // sub-slice

Hình 2: Header = 24 byte. s có ptr=...2000, len=5, cap=10. s[2:4] có ptr=...2010 (lệch đúng 16 byte = 2 int), len=2, cap=8 — chia sẻ mảng nền. Sửa sub[0]=999 làm s[2] đổi theo. Truyền vào hàm sửa x[0]=-1 làm s[0] đổi thật.
Kết quả cho thấy toàn bộ hành vi slice:
- Header = 24 byte (3 từ máy), đúng như thiết kế.
- Sub-slice chia sẻ mảng nền:
s[2:4]cóptrlệch đúng 16 byte (2 phần tửint) so vớiptrcủas. Chúng trỏ vào cùng một mảng nền, chỉ khác điểm bắt đầu và độ dài.capcủa sub là 8 (từ vị trí 2 tới hết mảng nền cap 10). - Aliasing: sửa
sub[0] = 999thực chất sửas[2]— vì cả hai trỏ cùng ô nhớ.sđổi từ[0 10 20 30 40]thành[0 10 999 30 40]. - Truyền slice = copy header, chia sẻ data:
suaPhanTu(s)nhận một bản copy của header 24 byte, nhưngptrtrong bản copy trỏ cùng mảng nền, nênx[0] = -1sửas[0]thật.
Ứng dụng thực tế
Truyền slice vào hàm luôn rẻ. Dù mảng nền lớn cỡ nào (1 phần tử hay 1 triệu), truyền slice chỉ copy 24 byte header. Bạn không cần truyền *[]T để "tránh copy" — slice đã hiệu quả. (Truyền con trỏ chỉ cần khi hàm phải thay đổi len/cap của slice gốc, ví dụ append.)
Cẩn thận aliasing khi cắt slice. Nếu bạn cắt một slice (sub := s[a:b]) rồi sửa sub, bạn đang sửa s. Đây là nguồn bug kinh điển: một hàm nhận slice, cắt nó, sửa phần cắt, và vô tình làm hỏng slice của người gọi. Nếu cần bản độc lập, phải copy ra slice mới.
append có thể phá vỡ chia sẻ — hoặc không. Đây là chỗ tinh vi nhất: append vào một sub-slice, nếu còn cap thừa, sẽ ghi đè lên mảng nền chung (ảnh hưởng slice khác); nếu hết cap, nó cấp mảng mới (tách khỏi chia sẻ). Hành vi phụ thuộc cap — bài sau về slice growth sẽ đo kỹ.
Đánh đổi cần cân nhắc
Chia sẻ mảng nền vừa là sức mạnh vừa là bẫy. Nó khiến slice cực nhanh (không copy data khi truyền/cắt), nhưng cũng khiến việc sửa dữ liệu lan ra ngoài ý muốn. Quy tắc an toàn: nếu một slice sẽ bị nhiều nơi giữ và sửa độc lập, copy ra bản riêng; nếu chỉ đọc hoặc một nơi sửa, chia sẻ là ổn và nhanh.
Sub-slice giữ cả mảng nền sống. Một hệ quả về bộ nhớ: small := big[:10] giữ toàn bộ mảng nền của big sống, không chỉ 10 phần tử — vì ptr vẫn trỏ vào mảng lớn. Nếu big là 1 triệu phần tử mà bạn chỉ cần 10, đây là rò bộ nhớ (bài phát hiện rò đã nhắc). Cách sửa: copy 10 phần tử ra slice mới, thả big.
Đừng lạm dụng unsafe để đọc header. Bài này dùng unsafe để minh hoạ cấu trúc, nhưng trong code thật đừng đọc header bằng unsafe (dễ sai khi bố cục đổi). Nếu cần, dùng reflect.SliceHeader (đã deprecated) hoặc unsafe.SliceData/unsafe.Slice (Go 1.20+) — API chính thức.
Ba ý mang về
- Slice là header 24 byte {ptr, len, cap}, không chứa dữ liệu: đo thật
unsafe.Sizeof= 24 byte trên 64-bit — dữ liệu ở mảng nền chỗ khác, và nhiều slice có thể cùng trỏ vào một mảng nền. - Sub-slice và cắt slice chia sẻ mảng nền: đo thật
s[2:4]có ptr lệch đúng 16 byte (2 int) so vớis, nên sửasub[0]làms[2]đổi theo — nguồn của bug aliasing kinh điển; cần bản độc lập thì phảicopy. - Truyền slice copy header không copy data: chỉ 24 byte dù mảng lớn cỡ nào (nên luôn rẻ), nhưng hàm sửa được phần tử của bạn vì cùng trỏ mảng nền — và sub-slice giữ cả mảng nền lớn sống, một nguồn rò bộ nhớ.
Bài tập làm thử
Bài 1 (đọc hiểu). Slice header trên máy 64-bit gồm bao nhiêu byte và ba trường nào? Trường nào giữ dữ liệu thực của slice?
Đáp án
24 byte (3 từ máy, mỗi từ 8 byte trên 64-bit): ptr (con trỏ tới phần tử đầu tiên trong mảng nền), len (số phần tử hiện dùng được), cap (số phần tử mảng nền chứa được tính từ ptr). Không trường nào giữ dữ liệu thực — dữ liệu nằm ở mảng nền (backing array), một vùng nhớ riêng mà ptr chỉ trỏ vào.
Bài 2 (đọc hiểu số liệu). Theo số đo trong bài, s có ptr=0x40000b2000, len=5, cap=10, còn s[2:4] có ptr=0x40000b2010, len=2, cap=8. Giải thích vì sao ptr của sub-slice lệch đúng 16 byte so với ptr của s, và vì sao cap của sub-slice là 8 chứ không phải 4 hay 2.
Đáp án
s[2:4] bắt đầu từ phần tử chỉ số 2 của mảng nền; mỗi phần tử là int chiếm 8 byte, nên điểm bắt đầu lệch 2×8 = 16 byte so với ptr của s. cap của sub-slice không phải là độ dài của lát cắt (2) mà là số phần tử còn lại trong mảng nền tính từ điểm bắt đầu mới tới hết mảng nền gốc: mảng nền của s có cap 10, sub bắt đầu từ vị trí 2, nên cap còn lại là 10 − 2 = 8.
Bài 3 (sửa lỗi/giải thích code). Đoạn code sau có một hệ quả mà người viết không ngờ tới. Chạy đoạn này, s cuối cùng bằng gì, và vì sao?
s := []int{0, 10, 20, 30, 40}
sub := s[2:4]
sub[0] = 999
fmt.Println(s)
Đáp án
s in ra [0 10 999 30 40]. Vì sub := s[2:4] không tạo bản sao dữ liệu — nó chỉ tạo một header mới có ptr trỏ vào phần tử chỉ số 2 của cùng mảng nền mà s đang dùng. sub[0] chính là cùng ô nhớ với s[2], nên gán sub[0] = 999 sửa trực tiếp s[2]. Đây là hiện tượng aliasing do chia sẻ mảng nền.
Bài 4 (vận dụng thực tế). Bạn có một slice big gồm 1 triệu phần tử, nhưng chỉ cần giữ lại 10 phần tử đầu để dùng lâu dài (small := big[:10]). Theo bài, cách làm này có vấn đề gì về bộ nhớ, và cách khắc phục là gì?
Đáp án
small := big[:10] chỉ tạo header mới với ptr vẫn trỏ vào mảng nền của big, nên small giữ toàn bộ mảng nền 1 triệu phần tử sống trong bộ nhớ dù chỉ dùng 10 phần tử đầu — đây là một dạng rò bộ nhớ. Cách sửa: tạo một slice mới và copy 10 phần tử ra đó (small := make([]T, 10); copy(small, big[:10])), sau đó có thể để big bị thu hồi.
Bài 5 (bẫy/đánh đổi). Bài kết luận "truyền slice vào hàm luôn rẻ" vì chỉ copy 24 byte header, và vì vậy có người đề xuất "để tối ưu hơn nữa, luôn truyền *[]T thay vì []T để tránh copy 24 byte đó". Nhận định này có đáng làm theo không? Khi nào thực sự cần truyền con trỏ tới slice?
Đáp án
Không đáng — 24 byte là chi phí không đáng kể dù mảng nền lớn cỡ nào, nên truyền []T bình thường đã đủ hiệu quả; thêm một tầng con trỏ chỉ làm code phức tạp hơn mà không có lợi ích đo được. Chỉ thực sự cần truyền *[]T khi hàm cần thay đổi chính len/cap của slice gốc mà người gọi nhìn thấy được (ví dụ hàm nội bộ làm việc như append phải cập nhật lại slice ở phía gọi) — còn việc sửa phần tử bên trong slice thì []T thường đã đủ, vì ptr trong bản copy header vẫn trỏ cùng mảng nền.
Phần sau ta đo chính cơ chế mà bài này nhắc tới: Phần sau mổ xẻ chiến lược tăng trưởng slice khi append vượt cap — Go nhân đôi hay nhân 1.25, khi nào tách mảng nền, và cách chọn cap ban đầu để tránh copy thừa.