Dictionary và Set
Băm, và cái giá của việc làm sai Hashable
Dictionary và Set đều là bảng băm, và cả hai phụ thuộc hoàn toàn vào việc Hashable được cài đặt
đúng. Làm sai thì chẳng có gì sập — hiệu năng lặng lẽ tụt từ O(1) xuống O(n), hoặc giá trị biến mất.
Bản hợp đồng
Hashable có đúng một quy tắc, và nó chỉ chạy theo một chiều:
Nếu hai giá trị bằng nhau thì chúng phải có cùng giá trị băm.
Chiều ngược lại không bắt buộc. Hai giá trị khác nhau có thể trùng mã băm — đó là va chạm, và bảng
xử lý nó bằng cách so sánh bằng == trong cùng một ngăn.
Phá vỡ quy tắc theo chiều còn lại là thứ làm mất giá trị:
struct User: Hashable {
let id: UUID
var lastSeen: Date
static func == (lhs: User, rhs: User) -> Bool {
lhs.id == rhs.id // phép bằng bỏ qua lastSeen
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(lastSeen) // nhưng mã băm thì không — hỏng
}
}
Hai user cùng id và khác lastSeen thì == với nhau, nhưng băm ra khác nhau. Chèn một cái vào một
Set rồi tra cái kia thì không tìm thấy, vì lượt tra đi tới sai ngăn và không bao giờ chạm tới phép
so sánh.
Cảnh báo
Quy tắc trong thực tế: == bỏ qua thứ gì thì hash(into:) cũng phải bỏ qua đúng thứ đó. Phần
tuân thủ do trình biên dịch sinh ra luôn làm đúng chuyện này, và đó là lập luận mạnh nhất cho việc
đừng tự viết cái nào cả. Chỉ viết chúng khi phép bằng thật sự mang nghĩa hẹp hơn “mọi trường đều
khớp” — và khi đó nhớ thu hẹp mã băm y hệt.
Việc băm được gieo hạt lại ở mỗi lần khởi chạy
Hasher được gieo hạt ngẫu nhiên ở mỗi lần tiến trình khởi động, nên các giá trị băm không ổn định
qua các lần chạy. Có hai hệ quả:
Đừng bao giờ lưu lại một hashValue. Không lưu vào file, không vào cơ sở dữ liệu, không vào một
khóa cache sống lâu hơn tiến trình. Ngày mai nó sẽ khác.
Thứ tự duyệt không ổn định. Một Dictionary hay Set duyệt theo thứ tự do hạt băm quyết định,
nên cùng một đoạn code cho ra thứ tự khác ở lần chạy sau. Code có vẻ chạy đúng vì một dictionary tình
cờ duyệt theo thứ tự bảng chữ cái rồi sẽ hỏng, và nó sẽ hỏng trong môi trường thật chứ không phải
trong test.
for (key, value) in settings.sorted(by: { $0.key < $1.key }) { … }
Nếu thứ tự quan trọng, hãy sắp xếp tường minh. Nếu thứ tự quan trọng và là một phần của mô hình dữ liệu, hãy dùng một mảng các cặp hoặc một tập hợp có thứ tự.
Các thao tác dictionary đáng biết
Phép truy cập kèm giá trị mặc định dẹp đi phần lớn đoạn code vụng về quanh việc đếm và gom nhóm:
var counts: [String: Int] = [:]
for word in words {
counts[word, default: 0] += 1
}
Dòng đó đọc và ghi qua cùng một phép truy cập, và vì nó dựa trên _modify nên nó không sao chép giá
trị ra rồi lại vào — nó sửa tại chỗ.
Ba bộ khởi tạo lo được phần lớn phần còn lại:
Dictionary(grouping: users, by: \.city) // [String: [User]]
Dictionary(uniqueKeysWithValues: pairs) // sập khi có khóa trùng
Dictionary(pairs, uniquingKeysWith: { old, _ in old }) // giải quyết khóa trùng
uniqueKeysWithValues đáng được gọi tên: khi gặp khóa trùng, nó không trả về nil và cũng không ném
lỗi, nó sập. Dựng một dictionary từ dữ liệu máy chủ bằng bộ khởi tạo đó là một cú sập đang chờ id
trùng đầu tiên. Hãy dùng uniquingKeysWith: cho bất cứ dữ liệu nào không do chính bạn sinh ra.
merge và merging gộp hai dictionary với một quy tắc xử lý xung đột tường minh, và đó là hình dạng
nên với tới thay cho một vòng lặp:
defaults.merging(overrides) { _, override in override }
Khi nào Set là câu trả lời đúng
Bản ba dòng: Set đúng khi bạn cần kiểm tra thành viên, tính duy nhất, hoặc đại số tập hợp; và sai
khi bạn cần thứ tự hoặc cần phần tử lặp.
Trường hợp người ta hay bỏ sót là kiểm tra thành viên bên trong một vòng lặp:
// O(n × m)
let filtered = items.filter { blockedIDs.contains($0.id) } // blockedIDs là một Array
// O(n)
let blocked = Set(blockedIDs)
let filtered = items.filter { blocked.contains($0.id) }
Array.contains là một lượt quét tuyến tính. Nằm trong một filter trên một nghìn phần tử, với một
nghìn id bị chặn, đó là một triệu phép so sánh thay vì một nghìn lượt tra. Chuyển sang Set tốn một
lượt duyệt và hoàn vốn ngay lập tức.
Các phương thức đại số tập hợp đáng thuộc tên, vì viết tay bằng vòng lặp thì dài hơn và chậm hơn:
current.subtracting(previous) // đã thêm
previous.subtracting(current) // đã bỏ
current.intersection(previous) // không đổi
current.symmetricDifference(previous)
Cặp đầu tiên chính là toàn bộ một thuật toán so sánh khác biệt cho dữ liệu không có thứ tự, và đó là cách bạn tìm ra cần chèn gì và xóa gì mà không phải so từng phần tử của hai mảng.
Copy-on-write cũng áp dụng ở đây
Cả hai kiểu đều là kiểu giá trị bọc quanh một vùng đệm trên heap, đúng như chương về giá trị và tham chiếu đã mô tả. Truyền một dictionary lớn cho một hàm là sao chép một con trỏ; sửa một bản sao thì nhân đôi vùng đệm.
Cái giá riêng của dictionary là băm lại khi phình ra. Một dictionary lớn quá sức chứa sẽ cấp phát
một vùng đệm lớn hơn rồi băm lại mọi khóa vào đó. Vì thế dựng một dictionary lớn trong một vòng lặp
sẽ băm lại vài lần, và minimumCapacity tránh được điều đó:
var index = Dictionary<String, Item>(minimumCapacity: items.count)
Đáng làm khi kích thước đã biết trước và lớn. Không đáng làm trong các trường hợp khác — chiến lược cấp phát lại đã được khấu hao, và đây là một tối ưu vi mô ở khắp mọi nơi trừ đúng cái vòng lặp mà nó không phải vậy.