Map trong Go là bảng băm, giống HashMap của Java về ý tưởng nhưng khác ở ba chỗ đủ để gây bất ngờ.
Khai báo và dùng
m := map[string]int{"a": 1} // literal
m2 := make(map[string]int) // rỗng, ghi được
m3 := make(map[string]int, 100) // gợi ý sức chứa
var m4 map[string]int // NIL — đọc được, ghi PANIC
Bài 4 đã đo unsafe.Sizeof(map) là 8 byte — map chỉ là một con trỏ. Nên truyền map vào hàm là truyền tham chiếu trên thực tế: hàm sửa map là bản gốc đổi theo. Khác hẳn slice và struct.
Khoá phải là kiểu so sánh được — theo đúng ranh giới ở bài 5. Nên map[[]string]int là lỗi biên dịch, còn map[Diem]int với Diem là struct hai số nguyên thì được.
comma-ok: phân biệt "không có" với "bằng 0"
v, ok := m["b"] -> v=0 ok=false
Đọc khoá không tồn tại trả về zero value, không panic. Nên m["chua-co"] cho 0 với map[string]int — và bạn không biết đó là "không có khoá" hay "có khoá với giá trị 0".
Dạng hai giá trị trả lời câu đó:
if v, ok := m[k]; ok {
// chắc chắn khoá tồn tại
}
Đây là mẫu bạn sẽ gõ rất nhiều. Kết hợp với if có câu lệnh khởi tạo ở bài 6, nó thành một dòng gọn.
delete(m, k) xoá, và an toàn kể cả khi khoá không tồn tại — không lỗi, không panic:
sau delete: len=0
Map nil: đọc được, ghi panic
var m map[string]int
fmt.Println(m["x"]) // 0 — hoàn toàn hợp lệ
fmt.Println(len(m)) // 0
for k := range m { } // chạy 0 lần
m["x"] = 1 // panic: assignment to entry in nil map
Đây là bất đối xứng cố ý, và nó theo đúng triết lý zero value ở bài 4: đọc luôn an toàn, ghi phải khởi tạo.
Hệ quả thực tế: struct có trường map thì phải nhớ make trong hàm khởi tạo. Quên là code chạy tốt cho tới lần ghi đầu tiên — có thể rất lâu sau khi triển khai.
Thứ tự duyệt: ngẫu nhiên có chủ đích
lượt 1: b c d e a
lượt 2: a b c d e
lượt 3: a b c d e
Go cố tình ngẫu nhiên hoá điểm bắt đầu khi duyệt map. Đây không phải hệ quả tình cờ của bảng băm — nó là tính năng, thêm vào từ Go 1.0.
Lý do: ngăn bạn viết mã phụ thuộc vào thứ tự. Nếu thứ tự tình cờ ổn định trên máy bạn, mã sẽ chạy đúng suốt quá trình phát triển rồi hỏng khi dữ liệu đổi hoặc khi nâng phiên bản Go.
Chú ý kết quả đo ở trên: hai lượt sau cho cùng thứ tự. Go ngẫu nhiên hoá điểm bắt đầu chứ không xáo trộn hoàn toàn, nên với map nhỏ bạn vẫn thấy trùng lặp. Đừng vì thế mà tin vào nó.
Cần thứ tự ổn định thì lấy khoá ra rồi sắp xếp:
khoa := make([]string, 0, len(m))
for k := range m { khoa = append(khoa, k) }
slices.Sort(khoa)
for _, k := range khoa { fmt.Println(k, m[k]) }
Đây cũng là lý do khi in map bằng fmt.Println, Go sắp xếp khoá trước — để output ổn định cho test.
Map dùng làm set
Go không có kiểu set. Quy ước là map với giá trị rỗng:
set := map[string]struct{}{}
set["a"] = struct{}{}
_, co := set["a"]
Dùng struct{} thay bool vì nó chiếm 0 byte. Với set lớn, khác biệt là thật.
Cú pháp struct{}{} trông kỳ — kiểu là struct{}, giá trị là {}. Nhiều người dùng map[string]bool cho dễ đọc và chấp nhận tốn một byte mỗi phần tử.
Map không an toàn khi chạy đồng thời
Đọc và ghi map từ nhiều goroutine không an toàn, và Go phát hiện được:
fatal error: concurrent map writes
Đây là fatal error, không phải panic — không recover được, chương trình chết ngay. Go cố tình làm vậy để lỗi này không bao giờ bị nuốt.
Ba cách xử lý sẽ nói kỹ ở chặng đồng thời: bọc sync.RWMutex, dùng sync.Map (chỉ hợp với vài mẫu truy cập cụ thể), hoặc thiết kế lại để mỗi goroutine có map riêng.
Thử ba mươi giây
type Kho struct{ ds map[string]int }
k := Kho{}
fmt.Println(k.ds["a"]) // 0 — chạy tốt
k.ds["a"] = 1 // panic
Struct có trường map, zero value của nó là map nil. Đọc thì im lặng, ghi thì chết. Ba mươi giây đó giải thích vì sao hàm khởi tạo trong Go thường tồn tại chỉ để make vài trường.
Ngày mai: struct và composite literal — cách Go mô hình hoá dữ liệu.