Trang chủ

Chuỗi và Unicode

Grapheme, các khung nhìn, và vì sao count là O(n)

String là kiểu mà Swift từ chối nói dối bạn một cách lộ liễu nhất. Ở phần lớn ngôn ngữ, chuỗi là một mảng đơn vị mã và length thì rẻ, trông có vẻ đúng, và sai với một phần lớn thế giới. Swift đưa tính đúng đắn ấy ra mặt tiền và bắt bạn trả giá ngay từ đầu.

String là collection của grapheme

let flag = "🇻🇳"
flag.count                    // 1
flag.unicodeScalars.count     // 2 — hai ký hiệu vùng
flag.utf16.count              // 4
flag.utf8.count               // 8

Cả bốn con số đều đúng; chúng trả lời bốn câu hỏi khác nhau. String duyệt theo cụm grapheme — thứ mà người đọc gọi là một ký tự — nên count là O(n): phải đi hết chuỗi, vì một ký tự có thể dài bao nhiêu byte cũng được.

Đó cũng là lý do String không có subscript bằng số nguyên. str[5] không thể là O(1), và Swift không trao cho bạn một thao tác mà chi phí của nó giấu ngay giữa ban ngày:

let text = "Xin chào"
let index = text.index(text.startIndex, offsetBy: 4)
text[index]                    // "c"
text[text.startIndex..<index]  // "Xin "

Tương đương chuẩn tắc

Hai chuỗi có thể chứa byte khác nhau mà vẫn bằng nhau:

let composed = "é"            // U+00E9
let decomposed = "e\u{0301}"  // U+0065 U+0301
composed == decomposed        // true
composed.utf8.count           // 2
decomposed.utf8.count         // 3

Swift so sánh theo tương đương chuẩn tắc, nên văn bản hiển thị giống hệt nhau thì bằng nhau. Đó gần như luôn là điều con người muốn nói. Nó cũng có nghĩa == trên chuỗi không phải memcmp, và tra cứu Dictionary với khoá chuỗi có làm việc thật với Unicode.

Cảnh báo

Đừng bao giờ mang kết quả so sánh String của Swift qua một ranh giới đòi hỏi byte phải giống hệt nhau — một mã băm, một chữ ký, một khoá chính trong cơ sở dữ liệu. Khi cần byte đúng nghĩa byte, hãy so sánh Array(string.utf8).

Các khung nhìn

Mỗi String phơi ra bốn khung nhìn trên cùng một vùng lưu trữ, và chọn đúng khung nhìn chính là phần lớn ý nghĩa của cụm từ “tối ưu chuỗi” trong Swift:

  • characters (chính bản thân chuỗi) — cụm grapheme. Dùng cho mọi thứ hiển thị cho người.
  • unicodeScalars — điểm mã Unicode. Dùng để phân loại ký tự, kiểm tra thuộc tính.
  • utf16 — để giao tiếp với NSString, NSRange và phần lớn API C trên nền tảng Apple.
  • utf8 — cho định dạng truyền tải, băm, và đọc ghi tệp. Từ Swift 5, đây là dạng lưu trữ gốc, nên khung nhìn utf8 là miễn phí.
func isASCIIDigitsOnly(_ text: String) -> Bool {
    !text.utf8.isEmpty && text.utf8.allSatisfy { $0 >= 0x30 && $0 <= 0x39 }
}

Làm việc trên khung nhìn utf8 ở đây bỏ qua hoàn toàn việc tách grapheme: phép kiểm tra chỉ còn là một lượt quét byte.

Substring không phải String

Cắt chuỗi cho ra một Substring, mà nó — giống ArraySlice — dùng chung vùng lưu trữ với nguồn:

func lastComponent(of path: String) -> String {
    guard let slash = path.lastIndex(of: "/") else { return path }
    return String(path[path.index(after: slash)...])   // cái String(...) này quan trọng
}

Không có phép chuyển đổi đó, bạn trả về một Substring ghim nguyên đường dẫn trong bộ nhớ. Hệ thống kiểu ở đây hích bạn đi đúng hướng: các API lưu trữ văn bản đều nhận String, nên phép chuyển đổi thường bị ép ngay tại ranh giới. Bên trong vòng lặp phân tích cú pháp, hãy ở lại với Substring và chỉ chuyển đổi một lần ở cuối.

Nội suy chuỗi là một protocol

"\(value)" không phải phép màu, và cũng không phải description. Nó đi qua ExpressibleByStringInterpolation, thứ mà bạn mở rộng được:

extension String.StringInterpolation {
    mutating func appendInterpolation(_ value: Double, decimals: Int) {
        appendLiteral(String(format: "%.\(decimals)f", value))
    }
}

"π ≈ \(Double.pi, decimals: 3)"   // "π ≈ 3.142"

Trình biên dịch biến từng đoạn thành một lời gọi appendLiteral hoặc appendInterpolation, nên mọi thứ được phân giải tĩnh — một phép nội suy tuỳ biến không tốn hơn đoạn code bạn tự viết tay, mà lại giữ được logic định dạng ra khỏi nơi gọi.