Hình dung map như một phòng gửi đồ có dãy móc dán nhãn. Bạn treo áo lên móc mang tên khoá, và lấy lại bằng đúng cái tên đó. 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ờ, và cả ba đều lộ ra khi ta đi kỹ vào cái phòng gửi đồ này.
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". Cái móc trống và "không có móc nào" nhìn giống hệt nhau.
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. Cái phòng gửi đồ chưa được dựng: bạn cứ đi tới kiểm một cái móc (chẳng thấy gì), nhưng muốn treo áo thì phòng phải tồn tại trước đã.
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 — người coi phòng cố ý bắt đầu từ một cái móc khác mỗi lần. Đâ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 — cái móc mà bạn chỉ cần biết có áo hay không, chẳng cần biết áo gì. 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. Hai người coi phòng cùng treo áo lên một giá một lúc thì cả cái giá sập. 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.
Nếu chỉ nhớ một cái bẫy, để nó là cái map-nil — thử trong 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 — 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.
Mẫu số chung
Bảng băm là con-ngựa-thồ của mọi ngôn ngữ, và ba chỗ nó cắn ở đây cắn ở khắp nơi — các ngôn ngữ chỉ khác nhau ở chỗ làm cái nguy hiểm đó ồn ào hay lặng lẽ.
- "Thiếu khoá" so với "giá trị 0". Python
dict[k]némKeyError(bắt bạn xử lý),.get()trảNone; RustHashMap.gettrảOption— sự vắng mặt là một kiểu, sạch nhất. Còn JavaHashMap.gettrảnullvà JS trảundefined— cùng cái nhập nhằng thiếu-hay-bằng-0 như zero value của Go, cộng thêm rủi roNullPointerException. Comma-ok của Go là cách làm cái nhập nhằng đó lộ ra. - Thứ tự duyệt. Go cố tình xáo; Python 3.7+ và JS lại cố tình giữ thứ tự chèn; C++
unordered_mapvà JavaHashMapthì "không xác định nhưng hay tình cờ ổn định" — cái bẫy âm ỉ nhất, vì bạn sẽ vô tình dựa vào nó. Go chọn xáo để bắt lỗi sớm. - Ghi đồng thời. Go sập hẳn (fatal); Java
HashMapcũ thì hỏng âm thầm (có phiên bản lặp vô hạn); bản an toàn làConcurrentHashMapvàsync.Map. Bảng băm trần không an toàn luồng ở đâu cả — Go chỉ chọn la-to thay vì hỏng-lặng.
Sợi chỉ chung đáng mang theo: coi mỗi lần tra map là câu hỏi "có hay không", đừng coi là "giá trị hay số 0"; đừng bao giờ dựng logic trên thứ tự duyệt (cần thứ tự thì sắp khoá); và nhớ map trần là một-người-ghi — chạm vào từ hai goroutine là phải có khoá hoặc bản đồng thời ngay. Và để ý một nét tính cách của Go xuyên suốt: đọc luôn an toàn, sửa đòi khởi tạo (map nil đọc được, ghi panic) — cùng một luật zero value với slice nil, cùng một bản năng "làm cái sai không thể lờ đi" đã sinh ra cả cú xáo thứ tự lẫn cú sập không-cứu-được.
Ngày mai: struct và composite literal — cách Go mô hình hoá dữ liệu.