Ký tự vs. Byte: Hiểu UTF-8 và sự khác biệt mã hóa

12 phút đọc

Trong lập trình và thiết kế cơ sở dữ liệu, hiểu sự khác biệt giữa "số ký tự" và "số byte" là điều thiết yếu. Các ngôn ngữ như tiếng Nhật và tiếng Trung sử dụng ký tự đa byte, trong đó một ký tự hiển thị có thể chiếm nhiều byte. Hiểu sai sự phân biệt này dẫn đến cắt ngắn dữ liệu, lỗi mã hóa và văn bản bị hỏng.

Số byte theo mã hóa

Mã hóaASCII (A-Z, 0-9)Ký tự CJKEmoji
UTF-81 byte3 bytes4 bytes
UTF-162 bytes2 bytes4 bytes
ASCII1 byteKhông hỗ trợKhông hỗ trợ

Ví dụ, từ "Hello" là 5 byte trong UTF-8, trong khi một cụm từ tiếng Trung 5 ký tự sẽ là 15 byte trong UTF-8 nhưng chỉ 10 byte trong UTF-16.

Cạm bẫy phổ biến

Hành vi độ dài chuỗi theo ngôn ngữ

Ngôn ngữPhương thức độ dàiTrả vềĐộ dài của "🎉"Độ dài của "𠮷"Đếm ký tự chính xác
JavaScript.lengthĐơn vị mã UTF-1622[...str].length
Python 3len()Điểm mã11len(s.encode('utf-8')) cho byte
Java.length()Đơn vị mã UTF-1622.codePointCount(0, s.length())
Golen()Byte44utf8.RuneCountInString()
Rust.len()Byte44.chars().count()
Swift.countCụm grapheme11.utf8.count cho byte

Thiết kế của Swift đặc biệt đáng chú ý. Thuộc tính .count của nó trả về số cụm grapheme, nên emoji ghép ZWJ như 👨‍👩‍👧‍👦 được đếm chính xác là 1 - xấp xỉ gần nhất với "những gì người dùng nhìn thấy." Đánh đổi là chuỗi Swift không thể được lập chỉ mục trong thời gian O(1); cần duyệt từ đầu.

Rust có cách tiếp cận nghiêm ngặt nhất đối với xử lý chuỗi. Kiểu String được lưu trữ nội bộ dưới dạng chuỗi byte UTF-8, và truy cập chỉ mục như s[0] là lỗi biên dịch. Điều này buộc lập trình viên phải chọn rõ ràng giữa truy cập cấp byte và cấp ký tự - một triết lý thiết kế ngăn ngừa lỗi mã hóa ở cấp ngôn ngữ.

Cách UTF-8 hoạt động

UTF-8 là mã hóa tiêu chuẩn web hiện tại, có khả năng biểu diễn mọi ký tự trong tiêu chuẩn Unicode. Nó sử dụng sơ đồ độ dài thay đổi, phân bổ 1 đến 4 byte mỗi ký tự tùy thuộc vào phạm vi điểm mã. Cốt lõi của thiết kế này là mẫu bit đầu của mỗi byte xác định duy nhất liệu đó là byte bắt đầu hay byte tiếp tục, và ký tự chiếm bao nhiêu byte. Để tìm hiểu sâu về hệ thống mã hóa, sách về mã hóa ký tự cung cấp tài liệu tham khảo có giá trị.

Một khía cạnh đặc biệt tinh tế của thiết kế này là thuộc tính "tự đồng bộ". Bạn có thể bắt đầu đọc từ bất kỳ vị trí tùy ý nào trong luồng byte và ngay lập tức xác định ranh giới ký tự bằng cách kiểm tra mẫu bit đầu. Byte tiếp tục luôn bắt đầu bằng 10, giúp phân biệt chúng với byte bắt đầu. Điều này cho phép khôi phục một phần từ dữ liệu bị hỏng và truy cập ngẫu nhiên hiệu quả trong file. UTF-16 thiếu thuộc tính tự đồng bộ này - bắt đầu đọc giữa luồng có nguy cơ hiểu sai nửa đầu và nửa sau của cặp thay thế.

Ưu điểm chính của UTF-8 là tương thích ngược với ASCII. Văn bản tiếng Anh vẫn chính xác 1 byte mỗi ký tự, đó là lý do tại sao các hệ thống dựa trên ASCII hiện có hoạt động liền mạch với UTF-8. Ngoài ra, chuỗi byte UTF-8 sắp xếp theo cùng thứ tự với điểm mã Unicode. Điều này có nghĩa là so sánh đơn giản ở cấp byte tạo ra thứ tự từ điển chính xác, có lợi cho chỉ mục cơ sở dữ liệu và sắp xếp hệ thống file.

Mã hóa cũ: Shift_JIS và EUC-JP

Shift_JIS và EUC-JP được phát triển riêng cho văn bản tiếng Nhật và được sử dụng rộng rãi trong nhiều thập kỷ. Mặc dù việc chuyển đổi sang UTF-8 đang tiến triển tốt, các mã hóa này vẫn xuất hiện trong hệ thống cũ, truyền email và xử lý file CSV.

Shift_JIS có tên từ cách nó "dịch chuyển" giá trị byte vào các vùng không sử dụng của JIS X 0201 (katakana nửa chiều rộng) để cùng tồn tại với ASCII. Tuy nhiên, thiết kế này đã tạo ra "vấn đề 0x5C" khét tiếng: một số kanji có 0x5C (dấu gạch chéo ngược ASCII "\") là byte thứ hai, xung đột với chuỗi thoát C và dấu phân cách đường dẫn file. Các ký tự như 表 (0x955C), 能 (0x945C) và ソ (0x835C) là những ký tự có vấn đề nổi tiếng có thể gây lỗi khi sử dụng trong tên file hoặc đường dẫn - một vấn đề vẫn được báo cáo ngày nay.

EUC-JP được sử dụng rộng rãi trong môi trường UNIX. Vì byte thứ hai của nó luôn nằm trong phạm vi 0xA1–0xFE, nó hoàn toàn tránh được vấn đề 0x5C. Đặc tính an toàn này là một trong những lý do EUC-JP được ưa chuộng trong môi trường UNIX hơn Shift_JIS.

Tính năngShift_JISEUC-JP
Mục đích sử dụng chínhWindows, hệ thống cũMôi trường UNIX/Linux
Byte mỗi ký tự CJK2 byte2 byte
Hỗ trợ emojiKhôngKhông
Hỗ trợ đa ngôn ngữChỉ tiếng NhậtChỉ tiếng Nhật
Khuyến nghị hiện tạiKhông còn khuyến nghị (chỉ hệ thống cũ)Không còn khuyến nghị (chỉ hệ thống cũ)

Cân nhắc thực tế cho lập trình viên

Sự phân biệt ký tự-byte gây ra vấn đề thực tế trong phát triển hàng ngày. Dưới đây là các tình huống phổ biến nhất cần chú ý.

Ví dụ văn bảnKý tựByte UTF-8Byte UTF-16Tỷ lệ Byte/Ký tự (UTF-8)
Hello55101.0
café4581.25
日本語3963.0
𠮷野家31083.3
🎉🎊🎈312124.0
👨‍👩‍👧‍👦 (emoji gia đình)Nhìn thấy 12522

"Emoji gia đình" ở hàng cuối là ký tự tổ hợp được tạo bằng cách ghép bốn emoji riêng lẻ với ZWJ (Zero Width Joiner, U+200D). Nó hiển thị như một ký tự đơn nhưng bên trong bao gồm 7 điểm mã (4 emoji + 3 ký tự ZWJ). String.length của JavaScript trả về 11, và [...str].length trả về 7. Đây là ví dụ điển hình về cách "số ký tự trực quan" và "số ký tự nội bộ" có thể khác biệt đáng kể, và nó đại diện cho trường hợp thách thức nhất cho các triển khai đếm ký tự.

Kỹ thuật của kỹ sư có kinh nghiệm

Kỹ sư xử lý vấn đề mã hóa hàng ngày dựa vào bộ kỹ thuật thực tế để tránh các bẫy phổ biến.

Lỗi mã hóa thực tế

Vấn đề mã hóa có thể gây ra lỗi hệ thống quy mô lớn. Dưới đây là các mẫu đã gây ra sự cố sản xuất thực tế.

Sự thật thú vị về mã hóa ký tự

UTF-8 được thiết kế năm 1992 bởi Rob Pike và Ken Thompson. Theo một giai thoại nổi tiếng, sơ đồ ban đầu được phác thảo trên mặt sau của tấm lót đĩa tại một quán ăn ở New Jersey. Vào thời điểm đó, nhiều đề xuất mã hóa Unicode đang cạnh tranh, và yếu tố quyết định trong việc áp dụng UTF-8 là khả năng tương thích với chuỗi kết thúc bằng null của C. Trong UTF-8, byte null (0x00) không bao giờ xuất hiện ngoại trừ dưới dạng ký tự ASCII NUL, nên các hàm C như strlen()strcpy() hoạt động mà không cần sửa đổi. Nếu không có thuộc tính này, cơ sở mã khổng lồ hiện có của phần mềm C/UNIX sẽ cần phải viết lại, và việc áp dụng sẽ bị trì hoãn đáng kể.

Một sự thật ít được biết đến khác: trong khi hầu hết kanji tiếng Nhật chiếm 3 byte trong UTF-8, một số kanji hiếm trong khối CJK Unified Ideographs Extension B trở đi yêu cầu 4 byte. Ký tự "𠮷" (dạng thay thế của 吉 được sử dụng trong tên chính thức của chuỗi nhà hàng Yoshinoya) là một ký tự 4 byte như vậy. Về mặt kỹ thuật, kanji trong BMP (Basic Multilingual Plane, U+0000–U+FFFF) là 3 byte trong UTF-8, trong khi những ký tự được đặt trong các mặt phẳng bổ sung (U+10000 trở lên) yêu cầu 4 byte. Riêng Extension B chứa khoảng 42.000 ký tự, và nhiều ký tự biến thể được sử dụng trong tên người và tên địa danh nằm trong phạm vi này.

Kết luận

Hiểu sự phân biệt ký tự-byte là nền tảng để xây dựng phần mềm mạnh mẽ. Đối với dự án mới, hãy chuẩn hóa trên UTF-8 (cụ thể là utf8mb4 trong MySQL), luôn cắt ngắn theo số ký tự thay vì số byte, và chuẩn bị các trường hợp kiểm thử bao gồm emoji và ký tự biến thể. Những thực hành này là cách hiệu quả nhất để ngăn ngừa các vấn đề liên quan đến mã hóa trước khi chúng đến môi trường sản xuất. Sử dụng Bộ đếm ký tự để kiểm tra cả số ký tự và số byte cho văn bản của bạn.