Mã hóa ký tự *¶
Trong máy tính, tất cả dữ liệu đều được lưu trữ dưới dạng nhị phân, và kiểu ký tự char cũng không ngoại lệ. Để biểu diễn các ký tự, chúng ta cần thiết lập một "bộ ký tự" nhằm xác định mối quan hệ tương ứng một-đối-một giữa mỗi ký tự với các số nhị phân. Nhờ có bộ ký tự (character set), máy tính có thể chuyển đổi số nhị phân thành ký tự bằng cách tra bảng.
Bộ ký tự ASCII¶
Mã ASCII (ASCII code) là bộ ký tự sớm nhất, tên đầy đủ là American Standard Code for Information Interchange (Mã tiêu chuẩn Mỹ cho trao đổi thông tin). Nó sử dụng 7 bit nhị phân (7 bit thấp của một byte) để biểu diễn một ký tự, và có thể biểu diễn tối đa 128 ký tự khác nhau. Như hiển thị trong hình bên dưới, mã ASCII bao gồm các chữ cái tiếng Anh viết hoa và viết thường, các số từ 0 đến 9, một số dấu câu và một số ký tự điều khiển (như dòng mới và tab).
Tuy nhiên, mã ASCII chỉ có thể biểu diễn tiếng Anh. Cùng với sự toàn cầu hóa của máy tính, một bộ ký tự có tên là EASCII có khả năng biểu diễn nhiều ngôn ngữ hơn đã ra đời. Nó mở rộng từ nền tảng 7 bit của ASCII lên thành 8 bit, và có thể biểu diễn 256 ký tự khác nhau.
Trên toàn thế giới, một loạt các bộ ký tự EASCII phù hợp với các khu vực khác nhau đã lần lượt xuất hiện. 128 ký tự đầu tiên của các bộ ký tự này thống nhất sử dụng mã ASCII, còn 128 ký tự sau được định nghĩa khác nhau để thích ứng với nhu cầu của các ngôn ngữ khác nhau.
Bộ ký tự GBK¶
Về sau, người ta nhận thấy rằng EASCII vẫn không thể cung cấp đủ số lượng ký tự cho nhiều ngôn ngữ. Ví dụ, có gần một trăm nghìn chữ Hán, và vài nghìn chữ trong số đó được sử dụng trong cuộc sống hàng ngày. Vào năm 1980, Tổng cục Tiêu chuẩn hóa Quốc gia Trung Quốc đã công bố bộ ký tự GB2312 bao gồm 6.763 chữ Hán, về cơ bản đáp ứng được nhu cầu xử lý tiếng Trung của máy tính.
Tuy nhiên, GB2312 không thể xử lý một số ký tự hiếm gặp và chữ Hán phồn thể. Bộ ký tự GBK là một bản mở rộng dựa trên GB2312, bao gồm tổng cộng 21.886 chữ Hán. Trong lược đồ mã hóa GBK, các ký tự ASCII được biểu diễn bằng một byte, còn chữ Hán được biểu diễn bằng hai byte.
Bộ ký tự Unicode¶
Với sự phát triển mạnh mẽ của công nghệ máy tính, các bộ ký tự và tiêu chuẩn mã hóa phát triển bùng nổ, mang lại nhiều rắc rối. Một mặt, các bộ ký tự này nhìn chung chỉ định nghĩa các ký tự cho các ngôn ngữ cụ thể và không thể hoạt động bình thường trong môi trường đa ngôn ngữ. Mặt khác, có nhiều tiêu chuẩn bộ ký tự tồn tại cho cùng một ngôn ngữ, và nếu hai máy tính sử dụng các tiêu chuẩn mã hóa khác nhau, các ký tự bị lỗi (garbled characters/lỗi font) sẽ xuất hiện trong quá trình truyền thông tin.
Các nhà nghiên cứu thời bấy giờ đã nghĩ: Nếu có một bộ ký tự đủ hoàn chỉnh bao gồm tất cả các ngôn ngữ và biểu tượng trên thế giới được công bố, liệu có thể giải quyết được các vấn đề trong môi trường đa ngôn ngữ và loại bỏ hoàn toàn hiện tượng lỗi hiển thị văn bản hay không? Thúc đẩy bởi ý tưởng này, một bộ ký tự lớn và toàn diện, Unicode, đã ra đời.
Unicode, hay Mã Thống nhất, về mặt lý thuyết có thể chứa hơn một triệu ký tự. Nó cam kết đưa các ký tự từ khắp nơi trên thế giới vào một bộ ký tự duy nhất, cung cấp một bộ ký tự phổ quát để xử lý và hiển thị văn bản của nhiều ngôn ngữ, giảm thiểu các vấn đề lỗi ký tự do các tiêu chuẩn mã hóa khác nhau gây ra. Kể từ khi được công bố vào năm 1991, Unicode đã liên tục mở rộng để đưa vào các ngôn ngữ và ký tự mới. Tính đến tháng 9 năm 2022, Unicode đã chứa 149.186 ký tự, bao gồm các chữ cái, ký hiệu và cả biểu tượng cảm xúc (emoji) từ nhiều ngôn ngữ khác nhau.
Là một bộ ký tự phổ quát, về bản chất Unicode gán cho mỗi ký tự một "điểm mã (code point)" duy nhất (mã nhận diện ký tự), có phạm vi từ U+0000 đến U+10FFFF, tạo thành một không gian đánh số ký tự thống nhất. Tuy nhiên, Unicode không quy định cách lưu trữ các điểm mã ký tự này trong máy tính. Chúng ta không khỏi thắc mắc: khi các điểm mã Unicode có nhiều độ dài khác nhau xuất hiện đồng thời trong một văn bản, hệ thống sẽ phân tách các ký tự như thế nào? Ví dụ, với một đoạn mã hóa có độ dài 2 byte, làm thế nào hệ thống xác định được đó là một ký tự 2 byte hay là hai ký tự 1 byte?
Đối với vấn đề trên, một giải pháp trực quan là lưu trữ tất cả các ký tự dưới dạng mã hóa có độ dài bằng nhau. Như hiển thị trong hình bên dưới, mỗi ký tự trong từ "Hello" chiếm 1 byte, và mỗi ký tự trong từ "算法" (thuật toán) chiếm 2 byte. Chúng ta có thể mã hóa tất cả các ký tự trong "Hello 算法" thành độ dài 2 byte bằng cách đệm thêm số 0 ở các bit cao. Bằng cách này, hệ thống có thể phân tách một ký tự sau mỗi 2 byte và khôi phục nội dung của cụm từ này.
Tuy nhiên, mã ASCII đã chứng minh cho chúng ta thấy rằng việc mã hóa tiếng Anh chỉ cần 1 byte. Nếu áp dụng sơ đồ trên, kích thước của văn bản tiếng Anh sẽ lớn gấp đôi so với khi dùng mã hóa ASCII, điều này cực kỳ lãng phí không gian bộ nhớ. Do đó, chúng ta cần một phương pháp mã hóa Unicode hiệu quả hơn.
Mã hóa UTF-8¶
Hiện nay, UTF-8 đã trở thành phương pháp mã hóa Unicode được sử dụng rộng rãi nhất trên thế giới. Nó là một mã hóa độ dài biến đổi (variable-length encoding) sử dụng từ 1 đến 4 byte để biểu diễn một ký tự, tùy thuộc vào độ phức tạp của ký tự đó. Các ký tự ASCII chỉ cần 1 byte, chữ cái Latinh và Hy Lạp cần 2 byte, chữ Hán thông dụng cần 3 byte, và một số ký tự hiếm khác cần 4 byte.
Quy tắc mã hóa của UTF-8 không phức tạp và có thể được chia thành hai trường hợp sau.
- Đối với ký tự 1 byte, thiết lập bit cao nhất thành \(0\), và thiết lập 7 bit còn lại thành điểm mã Unicode. Đáng chú ý là các ký tự ASCII chiếm 128 điểm mã đầu tiên trong bộ ký tự Unicode. Nghĩa là, mã hóa UTF-8 tương thích ngược với mã ASCII. Điều này có nghĩa là chúng ta có thể sử dụng UTF-8 để phân tách các văn bản mã ASCII rất cũ.
- Đối với ký tự có độ dài \(n\) byte (trong đó \(n > 1\)), thiết lập \(n\) bit cao nhất của byte đầu tiên thành \(1\), và thiết lập bit thứ \((n + 1)\) thành \(0\); bắt đầu từ byte thứ hai, thiết lập 2 bit cao nhất của mỗi byte thành \(10\); sử dụng tất cả các bit còn lại để điền điểm mã Unicode của ký tự đó.
Hình bên dưới hiển thị mã hóa UTF-8 tương ứng với "Hello 算法". Có thể quan sát thấy rằng vì các bit \(n\) cao nhất đều được đặt thành \(1\), hệ thống có thể xác định độ dài ký tự là \(n\) bằng cách đếm số lượng bit \(1\) dẫn đầu.
Nhưng tại sao lại đặt 2 bit cao nhất của tất cả các byte khác thành \(10\)? Trên thực tế, \(10\) này có vai trò như một ký hiệu kiểm tra (check symbol). Giả sử hệ thống bắt đầu phân tách văn bản từ một byte sai vị trí, số \(10\) ở đầu byte có thể giúp hệ thống nhanh chóng phát hiện ra sự bất thường.
Lý do sử dụng \(10\) làm ký hiệu kiểm tra là vì theo quy tắc mã hóa UTF-8, hai bit cao nhất của một ký tự không bao giờ có dạng \(10\). Kết luận này có thể được chứng minh bằng phản chứng: giả sử hai bit cao nhất của một ký tự là \(10\), điều đó có nghĩa là độ dài của ký tự là \(1\), tương ứng với mã ASCII. Tuy nhiên, bit cao nhất của mã ASCII phải là \(0\), mâu thuẫn với giả định.
Ngoài UTF-8, các phương pháp mã hóa phổ biến còn bao gồm hai phương pháp sau.
- Mã hóa UTF-16: Sử dụng 2 hoặc 4 byte để biểu diễn một ký tự. Tất cả các ký tự ASCII và các ký tự không phải tiếng Anh thông dụng được biểu diễn bằng 2 byte; một số ít ký tự cần sử dụng 4 byte. Đối với ký tự 2 byte, mã hóa UTF-16 bằng đúng với điểm mã Unicode.
- Mã hóa UTF-32: Mỗi ký tự đều sử dụng 4 byte. Điều này có nghĩa là UTF-32 chiếm nhiều không gian hơn so với UTF-8 và UTF-16, đặc biệt là đối với các văn bản có tỷ lệ ký tự ASCII cao.
Từ góc độ chiếm dụng không gian lưu trữ, việc sử dụng UTF-8 để biểu diễn các ký tự tiếng Anh rất hiệu quả vì nó chỉ cần 1 byte; sử dụng mã hóa UTF-16 cho một số ký tự không phải tiếng Anh (như tiếng Trung) sẽ hiệu quả hơn vì nó chỉ cần 2 byte, trong khi UTF-8 có thể cần tới 3 byte.
Từ góc độ tương thích, UTF-8 có tính phổ quát tốt nhất, nhiều công cụ và thư viện ưu tiên hỗ trợ UTF-8 trước tiên.
Mã hóa ký tự trong các ngôn ngữ lập trình¶
Đối với nhiều ngôn ngữ lập trình trong quá khứ, các chuỗi ký tự trong quá trình thực thi chương trình sử dụng các mã hóa nội bộ như UTF-16 hoặc UTF-32. Dưới các biểu diễn này, chúng ta thường có thể coi các chuỗi ký tự như các mảng trong quá trình xử lý, và cách tiếp cận này có các ưu điểm sau.
- Truy cập ngẫu nhiên (random access): Các chuỗi mã hóa UTF-16 có thể dễ dàng được truy cập ngẫu nhiên. UTF-8 là mã hóa có độ dài biến đổi. Để tìm ký tự thứ \(i\), chúng ta cần duyệt từ đầu chuỗi đến ký tự thứ \(i\), đòi hỏi thời gian \(O(n)\).
- Đếm số ký tự: Tương tự như truy cập ngẫu nhiên, việc tính toán độ dài của một chuỗi mã hóa UTF-16 cũng là một thao tác \(O(1)\). Tuy nhiên, việc tính toán độ dài của một chuỗi mã hóa UTF-8 yêu cầu phải duyệt qua toàn bộ chuỗi.`
- Các thao tác trên chuỗi: Nhiều thao tác trên chuỗi (như cắt, nối, chèn, xóa, v.v.) đối với chuỗi mã hóa UTF-16 dễ thực hiện hơn. Việc thực hiện các thao tác này trên chuỗi mã hóa UTF-8 thường yêu cầu tính toán thêm để đảm bảo không tạo ra mã hóa UTF-8 không hợp lệ.`
Trên thực tế, thiết kế các lược đồ mã hóa ký tự cho các ngôn ngữ lập trình là một chủ đề rất thú vị liên quan đến nhiều yếu tố.
- Kiểu
Stringcủa Java sử dụng mã hóa UTF-16, với mỗi ký tự chiếm 2 byte. Điều này là do khi bắt đầu thiết kế ngôn ngữ Java, người ta tin rằng 16 bit là đủ để biểu diễn tất cả các ký tự có thể có. Tuy nhiên, đây là một phán đoán sai lầm. Về sau, đặc tả Unicode đã mở rộng vượt ra ngoài 16 bit, do đó các ký tự trong Java hiện nay có thể được biểu diễn bằng một cặp giá trị 16 bit (được gọi là "cặp thay thế (surrogate pair)"). - Chuỗi ký tự của JavaScript và TypeScript sử dụng mã hóa UTF-16 vì lý do tương tự như Java. Khi Netscape lần đầu tiên giới thiệu ngôn ngữ JavaScript vào năm 1995, Unicode vẫn đang ở giai đoạn phát triển ban đầu, và vào thời điểm đó, việc sử dụng mã hóa 16 bit là đủ để biểu diễn tất cả các ký tự Unicode.
- C# sử dụng mã hóa UTF-16 chủ yếu vì nền tảng .NET được thiết kế bởi Microsoft, và nhiều công nghệ của Microsoft (bao gồm cả hệ điều hành Windows) sử dụng rộng rãi mã hóa UTF-16.
Do đánh giá thấp số lượng ký tự của các ngôn ngữ lập trình trên, họ đã phải áp dụng phương pháp "cặp thay thế" để biểu diễn các ký tự Unicode có độ dài vượt quá 16 bit. Đây là một sự thỏa hiệp bất đắc dĩ. Một mặt, trong các chuỗi có chứa cặp thay thế, một ký tự có thể chiếm 2 byte hoặc 4 byte, do đó làm mất đi lợi thế của mã hóa có độ dài cố định. Mặt khác, việc xử lý các cặp thay thế đòi hỏi thêm mã nguồn, làm tăng độ phức tạp và khó khăn khi gỡ lỗi lập trình.
Vì những lý do trên, một số ngôn ngữ lập trình đã đề xuất các phương án mã hóa khác.
- Kiểu
strcủa Python sử dụng mã hóa Unicode và áp dụng biểu diễn chuỗi linh hoạt, trong đó độ dài ký tự được lưu trữ phụ thuộc vào điểm mã Unicode lớn nhất trong chuỗi. Nếu tất cả các ký tự trong chuỗi đều là ký tự ASCII, mỗi ký tự chiếm 1 byte; nếu có các ký tự vượt quá phạm vi ASCII nhưng tất cả đều nằm trong Mặt phẳng đa ngôn ngữ cơ bản (Basic Multilingual Plane - BMP), mỗi ký tự chiếm 2 byte; nếu có các ký tự vượt quá BMP, mỗi ký tự chiếm 4 byte. - Kiểu
stringcủa ngôn ngữ Go sử dụng mã hóa UTF-8 bên trong. Ngôn ngữ Go cũng cung cấp kiểurune, được dùng để biểu diễn một điểm mã Unicode đơn lẻ. - Các kiểu
strvàStringcủa ngôn ngữ Rust sử dụng mã hóa UTF-8 bên trong. Rust cũng cung cấp kiểucharđể biểu diễn một điểm mã Unicode đơn lẻ.
Cần lưu ý rằng cuộc thảo luận ở trên là về cách lưu trữ chuỗi ký tự trong các ngôn ngữ lập trình, điều này khác với cách lưu trữ chuỗi trong các tệp hoặc truyền tải qua mạng. Trong lưu trữ tệp hoặc truyền tải mạng, chúng ta thường mã hóa chuỗi thành định dạng UTF-8 để đạt được khả năng tương thích và hiệu quả không gian tối ưu.


