Menu

Slice trong Golang: append, make, len, cap và lỗi dùng chung

Slice là kiểu danh sách dùng hằng ngày trong Go. Tìm hiểu make, append và cách slice tăng kích thước, len so với cap, việc cắt slice chia sẻ mảng nền (và lỗi phát sinh từ đó), copy, package slices, slice 2 chiều, nil và rỗng.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Slice qua một ví dụ

Slice là một khung nhìn có thể mở rộng lên một mảng. Bạn tạo slice bằng literal hoặc bằng make, mở rộng bằng append, và lấy kích thước bằng len.

[]string không có độ dài trong ngoặc vuông. Đó là điểm khác với kiểu mảng như [4]string, có kích thước cố định.

Slice là gì

Một giá trị slice là một header nhỏ gồm ba trường:

  • một con trỏ tới một phần tử trong mảng nền
  • độ dài: số phần tử slice nhìn thấy
  • dung lượng: số phần tử có từ vị trí con trỏ đến cuối mảng nền

Sao chép một slice (gán nó, truyền nó vào hàm) chỉ sao chép header này. Cả hai bản sao cùng trỏ tới những phần tử giống nhau. Gần như mọi bất ngờ với slice đều đến từ việc quên điều đó.

make: độ dài và dung lượng

make([]T, len, cap) cấp phát một mảng nền và trả về một slice trên mảng đó. Dung lượng là tùy chọn, mặc định bằng độ dài.

Một nhầm lẫn hay gặp: make([]int, 5) rồi gọi append năm lần sẽ cho bạn mười phần tử, năm phần tử đầu là 0. Dùng make([]int, 0, 5) khi định append, hoặc make([]int, 5) khi định gán theo chỉ số.

Cấp phát trước dung lượng khi đã biết kích thước cuối cùng giúp tránh việc mở rộng lặp đi lặp lại. Đây là tối ưu hóa, không phải yêu cầu bắt buộc.

append và cách slice mở rộng

append thêm phần tử vào sau độ dài hiện tại. Nếu dung lượng đủ lớn, nó ghi ngay tại chỗ. Nếu không, nó cấp phát một mảng lớn hơn, sao chép các phần tử cũ sang, và trả về một slice trỏ tới mảng mới.

Dung lượng tăng khoảng gấp đôi khi slice còn nhỏ, và tăng theo hệ số nhỏ hơn (tiến dần về 1,25 lần) khi vượt quá 256 phần tử. Con số chính xác là chi tiết của runtime và đã thay đổi qua các phiên bản Go, nên đừng bao giờ viết code phụ thuộc vào nó.

Vì append có thể trả về một mảng khác, bạn phải dùng kết quả của nó:

append(s, 4)     // compile error: append(s, 4) (value of type []int) is not used
s = append(s, 4) // correct

Thêm cả một slice dùng cú pháp trải: s = append(s, other...).

Cắt slice dùng chung mảng nền

s[low:high] tạo một header slice mới trên cùng mảng đó, bao gồm các chỉ số từ low đến trước high. Có thể bỏ trống bất kỳ cận nào. Không phần tử nào bị sao chép.

Đầu ra:

[10 99 30 40 50]
3 4

Việc dùng chung đó khiến cắt slice rất rẻ. Nó cũng là nguồn gốc của lỗi slice nổi tiếng nhất.

Lỗi append dùng chung vùng nhớ

Khi một slice con còn dung lượng trống, append vào nó sẽ ghi vào mảng của slice cha, đè lên những phần tử mà slice cha vẫn đang dùng.

Đầu ra:

[1 2 100 4 5]
[1 2 100]
[cmd test] [cmd test]

a và b dùng chung ô mảng ngay sau "cmd", nên lần append thứ hai ghi đè lần thứ nhất. Đoạn code trông ổn và chạy đúng mỗi khi dung lượng tình cờ đã đầy, vì thế lỗi này chỉ thỉnh thoảng mới xuất hiện.

Hai cách sửa:

  1. Biểu thức cắt đầy đủ s[low:high:max] giới hạn dung lượng ở max-low. Không còn dung lượng trống, lần append tiếp theo buộc phải cấp phát. first := base[:2:2] làm ví dụ đầu tiên trở nên an toàn.
  2. Sao chép tường minh khi một slice sẽ tồn tại lâu hơn lời gọi hàm hoặc được append độc lập: slices.Clone(prefix) hoặc append([]string(nil), prefix...).

Một cái bẫy liên quan: một slice nhỏ của một mảng khổng lồ giữ cả mảng đó sống với bộ thu gom rác. Nếu bạn đọc một tệp 100 MB rồi giữ lại data[:10], cả 100 MB vẫn nằm trong bộ nhớ. Hãy clone phần bạn giữ lại.

copy

Hàm có sẵn copy(dst, src) sao chép min(len(dst), len(src)) phần tử và trả về số đó. Nó không bao giờ mở rộng dst.

copy xử lý đúng khi nguồn và đích chồng lên nhau, nên copy(s[1:], s) dịch các phần tử sang phải mà không làm hỏng dữ liệu.

Package slices

Từ Go 1.21, package chuẩn slices có các hàm generic cho những thao tác trước đây bạn phải tự viết.

HàmCông dụng
Contains, Indextìm một giá trị (ContainsFunc, IndexFunc nhận hàm điều kiện)
Sort, SortFunc, SortStableFuncsắp xếp tại chỗ
BinarySearchtìm kiếm trên slice đã sắp xếp
Insert, Delete, DeleteFuncchèn hoặc xóa, trả về slice mới
Compactxóa các phần tử trùng liên tiếp
Equal, Compareso sánh từng phần tử
Clone, Reverse, Max, Minđúng như tên gọi
Collect, Sorted, Values, Alllàm việc với iterator (Go 1.23)

Delete và Insert trả về một slice mà bạn phải gán lại, giống append. Sắp xếp có trang riêng: sắp xếp.

Xóa phần tử trong khi lặp theo chỉ số sẽ bỏ sót phần tử. Hãy dùng slices.DeleteFunc:

s = slices.DeleteFunc(s, func(n int) bool { return n%2 == 0 }) // drop evens

Slice 2 chiều

Slice của slice cho bạn một lưới mà các hàng có thể dài ngắn khác nhau. Mỗi hàng phải được cấp phát riêng.

Quên make bên trong thì mỗi hàng là nil, và grid[1][2] = 7 gây panic với thông báo index out of range [2] with length 0.

Slice nil và slice rỗng

Slice nil hoạt động như slice rỗng với len, cap, range và append, nên hãy ưu tiên var s []T làm trạng thái ban đầu. Chỉ khởi tạo bằng []T{} khi khác biệt có ý nghĩa, chủ yếu là đầu ra JSON (null so với []). Kiểm tra rỗng bằng len(s) == 0, không phải s == nil.

Slice và hàm

Hàm nhận một slice có thể thay đổi các phần tử của nó, và nơi gọi sẽ thấy thay đổi. Hàm không thể đổi độ dài của slice bên nơi gọi, vì nó chỉ nhận một bản sao của header. Hàm có append phải trả về slice mới:

func addAll(s []int, vals ...int) []int {
	return append(s, vals...)
}

Đây là lý do append, slices.Delete và slices.Insert đều trả về một slice.

Lỗi thường gặp

  • Bỏ qua kết quả của append. Bỏ hẳn kết quả thì không biên dịch được, nhưng gán append(s, x) cho một biến khác với biến bạn tiếp tục dùng là lỗi logic.
  • Hai lần append từ cùng một slice gốc. Chúng có thể dùng chung vùng nhớ. Hãy clone slice gốc hoặc dùng biểu thức cắt đầy đủ.
  • make([]T, n) rồi append. Để lại n số 0 ở đầu.
  • Chỉ số vượt phạm vi. s[len(s)] gây panic. Phần tử cuối là s[len(s)-1].
  • So sánh slice bằng ==. Chỉ s == nil biên dịch được. Hãy dùng slices.Equal.

Câu hỏi thường gặp

Độ dài và dung lượng của slice trong Go khác nhau thế nào?

len(s) là số phần tử slice đang chứa. cap(s) là số phần tử vừa trong mảng nền tính từ phần tử đầu tiên của slice. append ghi vào phần dung lượng còn trống nếu có, và cấp phát một mảng mới lớn hơn nếu không còn.

Làm sao thêm phần tử vào slice trong Go?

Gọi hàm có sẵn append và gán kết quả lại: s = append(s, x). Hàm này thêm được nhiều giá trị (append(s, 1, 2, 3)) hoặc cả một slice khác (append(s, other...)). Luôn dùng giá trị trả về, vì append có thể trả về một slice trỏ tới mảng mới.

Làm sao xóa một phần tử khỏi slice trong Go?

Dùng slices.Delete(s, i, i+1) (Go 1.21), hàm này dồn các phần tử phía sau lên và trả về slice ngắn hơn. Từ Go 1.22, nó còn đặt về giá trị zero các ô cuối vừa được giải phóng. Nếu thứ tự không quan trọng, hãy đổi chỗ với phần tử cuối rồi cắt bớt: s[i] = s[len(s)-1]; s = s[:len(s)-1].

Làm sao kiểm tra slice có chứa một giá trị trong Go?

Dùng slices.Contains(s, v) trong package chuẩn slices (Go 1.21). slices.Index(s, v) trả về vị trí hoặc -1, còn slices.ContainsFunc nhận một hàm điều kiện. Nếu cần tra cứu nhiều lần trên dữ liệu lớn, hãy dựng một map.

nil slice và slice rỗng trong Go khác nhau thế nào?

var s []int là nil; s := []int{} là rỗng nhưng không nil. Cả hai đều có độ dài 0 và đều dùng được với len, range và append. Khác biệt lộ ra ở phép kiểm tra s == nil và trong encoding/json, nơi slice nil được mã hóa thành null còn slice rỗng thành [].

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu