Vì sao Swift không cho bạn viết `string[0]`
Mọi lập trình viên đến với Swift từ một ngôn ngữ khác đều vấp phải điều này trong tuần đầu:
let name = "Swift"
let first = name[0] // lỗi: 'subscript(_:)' is unavailable
Nó đọc lên như thể ngôn ngữ đang cố tình gây khó. Không phải — đó là thiết kế duy nhất không lặng lẽ
làm hỏng văn bản, và hiểu vì sao thì cả cái API String thôi cho cảm giác vụng về.
Một ký tự thật ra là gì
Một Character trong Swift là một cụm chữ cái — thứ mà con người đọc thành một ký tự, thứ có thể
gồm nhiều Unicode scalar, thứ có thể gồm nhiều byte.
let flag = "🇻🇳"
flag.count // 1 — một Character
flag.unicodeScalars.count // 2 — hai chỉ báo vùng
flag.utf8.count // 8 — tám byte
Lá cờ Việt Nam là hai Unicode scalar (chỉ báo vùng V và N) mà hệ thống ghép lại thành một glyph. Chẳng có nghĩa nào hợp lý để nói nó có một “nửa đầu”.
Điều tương tự áp dụng cho các ký tự có dấu, và đó là lý do chuyện này đặc biệt quan trọng với tiếng Việt:
let a = "ế" // một scalar dựng sẵn
let b = "ế" // e + dấu mũ tổ hợp + dấu sắc tổ hợp
a == b // true — Swift so sánh theo dạng chuẩn
a.count == b.count // true, cả hai bằng 1
a.unicodeScalars.count // 1
b.unicodeScalars.count // 3
Hai chuỗi trông y hệt nhau, bằng nhau, có cùng count, và chiếm số byte khác nhau. Trong một ngôn
ngữ mà string[3] nghĩa là “byte thứ tư”, hai thứ này hành xử khác nhau — và đó chính xác là lớp lỗi
mà Swift đang ngăn chặn.
Vì sao chỉ số lại mờ đục
Vì một Character có bề rộng thay đổi, việc tìm cái thứ n nghĩa là đi bộ từ đầu. Một phép truy cập
bằng Int sẽ là O(n) trong khi trông như O(1), và thiết kế tập hợp của Swift từ chối giấu điều đó.
String.Index là một vị trí mờ đục vốn đã biết nó trỏ vào đâu:
let name = "Swift"
let first = name[name.startIndex] // "S"
let second = name[name.index(after: name.startIndex)] // "w"
let third = name[name.index(name.startIndex, offsetBy: 2)] // "i"
let last = name[name.index(before: name.endIndex)] // "t"
Dài dòng, và thành thật về cái giá.
Cảnh báo
Một chỉ số thuộc về đúng cái chuỗi mà nó sinh ra từ đó. Dùng chỉ số của chuỗi này lên chuỗi khác là hành vi không xác định và có thể gây sập — và sửa một chuỗi sẽ làm các chỉ số của nó mất hiệu lực. Đây là phần hay bẫy những ai viết vòng lặp vừa duyệt vừa sửa.
Nên viết gì thay thế
Phần lớn thời gian bạn chẳng cần chỉ số nào cả, và code không có nó thì tốt hơn.
// đầu / cuối
name.first // Character?, an toàn
name.last
// tiền tố / hậu tố
name.prefix(3) // "Swi"
name.suffix(2) // "ft"
name.dropFirst() // "wift"
// tìm kiếm
if let range = text.range(of: "Swift") {
text.replaceSubrange(range, with: "Rust")
}
// tách
"a,b,c".split(separator: ",") // ["a", "b", "c"]
// duyệt kèm vị trí
for (offset, character) in name.enumerated() { … }
enumerated() cho bạn một vị trí mà không cần chỉ số, và đó là thứ mà phần lớn các vòng
for i in 0..<count thật ra đang muốn.
Cái extension người ta hay viết, và vì sao đừng viết
Rốt cuộc ai cũng viết cái này:
extension String {
subscript(index: Int) -> Character {
self[self.index(startIndex, offsetBy: index)]
}
}
Nó chạy được và tôi sẽ không phát hành nó. Hai lý do: nó là O(n) trong khi trông như O(1), nên
for i in 0..<s.count { s[i] } lặng lẽ trở thành O(n²); và nó sập chứ không trả về nil khi chỉ số
vượt biên, nên nó kém an toàn hơn chính cái API mà nó thay thế.
Nếu bạn thật sự cần vị trí bằng số nguyên — viết một bộ phân tích cú pháp, chuyển một thuật toán sang — hãy chuyển đổi một lần:
let characters = Array(name) // O(n) một lần
let third = characters[2] // O(1) từ đây trở đi
Một lần cấp phát và các chỉ số hành xử đúng như thuật toán mong đợi.
Điều quan trọng với tiếng Việt
Phép so sánh diễn ra theo dạng chuẩn, và đó gần như luôn là thứ bạn muốn:
"Việt" == "Việt" // true, bất kể cách tổ hợp
Nhưng các thao tác ở mức byte thì không:
let composed = "ế" // 3 byte
let decomposed = "ế" // 6 byte
composed.utf8.count == decomposed.utf8.count // false
Nếu bạn lưu chuỗi vào cơ sở dữ liệu, sinh một checksum, hay so khớp với một giá trị từ máy chủ, hãy chuẩn hóa trước:
let normalised = text.precomposedStringWithCanonicalMapping
Đây là con bug đứng sau chuyện “tìm kiếm không ra cái từ mà tôi đang nhìn thấy trên màn hình” — người dùng gõ ra dạng phân tách còn cơ sở dữ liệu giữ dạng dựng sẵn, và mọi phép so sánh ở mức byte đều bất đồng trong khi mọi phép so sánh trong Swift đều đồng ý.
Kết luận
API này dài dòng vì bài toán thật sự khó, và mọi ngôn ngữ làm cho s[0] trở nên dễ đều đã lặng lẽ
quyết định rằng “ký tự” nghĩa là “byte” hoặc “đơn vị mã UTF-16”. Điều đó ổn cho tới khi có người gõ
một cái emoji, một nguyên âm tiếng Việt có dấu, hay bất kỳ chữ viết nào trong hàng tá hệ chữ mà ở đó
nó đẻ ra chữ rác.
Swift chọn cách làm cho sự vụng về ấy hiện ra. Sau khi đã đi gỡ cái phương án thay thế trong một ngôn ngữ khác, tôi vẫn sẽ chấp nhận cuộc đổi chác này lần nữa.