1. Введение
Chúng ta đã hiểu rằng dù máy tính có thông minh đến đâu, tự nó nó không hiểu chữ cái "A" hay ký tự "⌘". Máy chỉ hiểu 0 và 1, và để dịch những ký tự mà con người thấy được, nó cần một "thông dịch viên" — mã hóa (encoding).
Lịch sử các mã hóa là lịch sử của những đánh đổi và tiến hóa. Ban đầu mọi thứ đơn giản, sau đó phức tạp hơn, và rồi cuối cùng xuất hiện một tiêu chuẩn tương đối phổ quát. Hãy đi sơ qua dòng thời gian này.
Начало истории
Bắt đầu từ gốc. Ở bài trước chúng ta đã nhắc đến tổ tiên của các mã hóa ký tự — ASCII (phát âm là "aski"). Nhắc lại, nó là viết tắt của American Standard Code for Information Interchange — tức là chuẩn mã hóa Mỹ để trao đổi thông tin. Tên đã nói lên ai là đối tượng chính của nó, và vì sao nó "Mỹ".
ASCII được phát triển vào thập niên 1960 và là tiêu chuẩn mã hóa ký tự được dùng rộng rãi đầu tiên. Nó là tập hợp gồm 128 ký tự:
- Chữ La-tinh (hoa và thường): A-Z, a-z
- Số: 0-9
- Dấu câu: .,!?"' v.v.
- Một vài ký tự điều khiển: xuống dòng, tab, v.v.
Mỗi trong 128 ký tự này được mã hóa bằng một byte, sử dụng 7 bit trong 8 bit có sẵn (bit cao thường để trống hoặc dùng cho kiểm tra lỗi). Rất gọn và hiệu quả cho tiếng Anh.
Ví dụ:
Ký tự 'A' trong ASCII được mã hóa thành byte 0x41 (dạng nhị phân 01000001)
Ký tự '!' trong ASCII được mã hóa thành byte 0x21 (dạng nhị phân 00100001)
Hạn chế:
Hạn chế lớn của ASCII là rõ ràng: nó tối ưu cho tiếng Anh. Nếu bạn muốn viết tiếng Nga ("Привет"), tiếng Đức ("Grüße") hay tiếng Trung, ASCII không giúp được. Trong bảng của nó đơn giản không có những ký tự đó. Điều này dẫn tới xuất hiện nhiều cái gọi là code pages (trang mã), cố gắng mở rộng 128 ký tự của ASCII lên 256 bằng cách dùng bit thứ tám. Ví dụ cho tiếng Nga có CP1251 (Windows Cyrillic), KOI8-R và những cái khác. Nhưng vấn đề là các trang mã này không tương thích với nhau: cùng một byte trong các trang mã khác nhau có thể biểu thị ký tự hoàn toàn khác! Thực sự như Tháp Babel.
Sử dụng thực tế ngày nay:
Định dạng ASCII thuần túy hiếm khi dùng cho file văn bản chung ngày nay, trừ vài trường hợp đặc thù hoặc hệ thống cũ. Tuy nhiên di sản của nó còn sống: nhiều mã hóa hiện đại, như ta sẽ thấy, tương thích ngược với ASCII.
Hãy thử ghi và đọc thứ gì đó bằng ASCII, rồi thêm chữ tiếng Nga để xem chuyện gì xảy ra.
Tạo project console mới trong JetBrains Rider và đặt tên, ví dụ, là FileEncodingExplorer.
using System;
using System.IO;
using System.Text;
string file = "ascii.txt";
string asciiText = "Hello, world!";
string cyrillicText = "Привет, мир!";
// Ghi bằng ASCII
using var writer = new StreamWriter(file, false, Encoding.ASCII);
writer.WriteLine(asciiText);
writer.WriteLine(cyrillicText);
// Đọc bằng ASCII
using var reader = new StreamReader(file, Encoding.ASCII);
string content = reader.ReadToEnd();
Console.WriteLine("Nội dung file:");
Console.WriteLine(content);
Console.WriteLine("\nCác chữ Kirill đã bị thay bằng '?' vì ASCII không hỗ trợ chữ Kirill!");
Khi bạn chạy mã này, phần tiếng Anh sẽ đọc bình thường, còn phần tiếng Nga sẽ biến thành dấu hỏi (?) hoặc các ký tự "không biết" khác. Lý do là Encoding.ASCII không biết chuyển các ký tự Kirill thành byte như thế nào, nên nó thay bằng cái gì đó "an toàn" (thường là ?), hoặc các byte tương ứng chữ Nga trong một mã hóa khác bị diễn giải dưới ASCII thành ký tự khác. Trong ví dụ này, StreamWriter ép các ký tự không có trong ASCII thành ? khi ghi. Đó là minh họa vì sao quan trọng phải dùng mã hóa đúng!
2. UTF-8: Король интернета и гибкости
Giờ đến một trong những mã hóa quan trọng và phổ biến nhất hiện nay — UTF-8. Đây là mã hóa mà phần lớn Internet, các hệ Linux và hầu hết ứng dụng hiện đại dùng.
Nó là gì?
UTF-8 (Unicode Transformation Format - 8-bit) — một mã hóa Unicode khác, giải quyết vấn đề kém hiệu quả của UTF-16 với văn bản tiếng Anh. UTF-8 là một mã hóa độ dài thay đổi, nhưng có cách thiết kế thông minh:
- Các ký tự thuộc ASCII (mã từ 0 đến 127) được mã hóa bằng 1 byte. Điều tuyệt là: những byte này hoàn toàn giống như trong ASCII! Nghĩa là UTF-8 tương thích ngược với ASCII.
- Các ký tự khác được mã hóa bằng 2 đến 4 byte:
- Chữ Kirill — thường 2 byte.
- Nhiều ký tự Châu Âu có dấu, tiếng Ả Rập, Hebrew, Hy Lạp — 2 byte.
- Ký tự Hán/Nhật/Hàn — thường 3 byte.
- Ký tự hiếm và một số emoji — 4 byte.
Ví dụ biểu diễn ở dạng byte trong UTF-8:
- Ký tự 'A' (ASCII): 01000001 (1 byte)
- Ký tự 'я' (tiếng Nga): 11010001 10111111 (2 byte)
- Ký tự '€' (Euro): 11100010 10000010 10101100 (3 byte)
- Ký tự '😂' (emoji): 11110000 10011111 10011000 10000010 (4 byte)
Tại sao UTF-8 là "vua"?
- Hiệu quả: Rất gọn với văn bản chứa nhiều ký tự ASCII (điển hình là tiếng Anh, code, file cấu hình).
- Tương thích ngược với ASCII: Nếu bạn đọc file UTF-8 chỉ có ký tự ASCII thì có thể đọc như ASCII và mọi thứ vẫn ổn!
- Thường không có BOM: Khác với UTF-16, UTF-8 thường không dùng BOM. Nếu có (ví dụ EF BB BF), đó là "tùy chọn" và đôi khi gây phiền (ví dụ khi parse một số định dạng hay script Linux).
Nhược điểm:
- Độ dài thay đổi có thể làm phức tạp một vài thao tác (ví dụ, nhảy trực tiếp tới ký tự thứ N mà không quét trước), nhưng trong C# điều này không thành vấn đề: string làm việc với ký tự Unicode bất kể mã hóa file.
Sử dụng thực tế:
- Trang web (HTML, CSS, JavaScript),
- API (JSON, XML),
- File cấu hình,
- Source code hầu hết ngôn ngữ lập trình,
- Hệ điều hành dòng Linux/Unix.
Hãy viết và đọc file bằng UTF-8.
string file = "utf8.txt";
string text = "Hello, thế giới! 😀 €";
// Ghi bằng UTF-8 (mặc định không có BOM)
File.WriteAllText(file, text, Encoding.UTF8);
// Đọc bằng UTF-8
string readText = File.ReadAllText(file, Encoding.UTF8);
Console.WriteLine(readText); // Mọi thứ đọc đúng!
Khi chạy mã và so sánh kích thước file, bạn sẽ thấy utf8.txt cho văn bản hỗn hợp thường nhỏ hơn file trong UTF-16, và nếu văn bản chỉ tiếng Anh thì kích thước sẽ tương đương với ASCII.
3. UTF-16: Юникод для всех, почти
Vấn đề "Tháp Babel" của các trang mã khiến các dev đau đầu, đặc biệt khi phần mềm trở nên toàn cầu. Cần một giải pháp phổ quát. Và nó xuất hiện — Unicode. Unicode không phải là một mã hóa, mà là một bảng lớn gán cho mỗi ký tự một mã số duy nhất (code point).
Nó là gì?
UTF-16 (Unicode Transformation Format - 16-bit) — mã hóa ban đầu dự định mã hóa mọi ký tự Unicode bằng hai byte (16 bit).
- Phần lớn ký tự (BMP, tới 65535) được mã hóa bằng 2 byte.
- Với ký tự ngoài BMP dùng surrogate pairs — 4 byte. Vậy UTF-16 cũng là độ dài thay đổi, nhưng thường được coi là 2 byte cho một ký tự.
Thứ tự byte (Endianness) và BOM:
- Big-Endian (BE): byte có trọng số cao đứng trước.
- Little-Endian (LE): byte có trọng số thấp đứng trước.
- Để chương trình đọc biết thứ tự, người ta thường đặt BOM ở đầu file:
- Với UTF-16 LE: FF FE
- Với UTF-16 BE: FE FF
Lợi thế:
- Hỗ trợ hầu hết các ký tự trên thế giới.
- Dễ làm việc với ký tự trong BMP (độ dài cố định ~2 byte).
Nhược điểm:
- Không hiệu quả cho văn bản tiếng Anh: mỗi ký tự ASCII chiếm 2 byte.
- Sự tồn tại của BOM có thể gây rắc rối nếu chương trình đọc không mong đợi.
Sử dụng thực tế:
UTF-16 được dùng rộng rãi bên trong Windows và, ví dụ, trong Java — cho biểu diễn chuỗi nội bộ. Các file text của Notepad trên Windows với chữ Kirill thường lưu dưới dạng UTF-16 LE có BOM.
string file = "utf16.txt";
string text = "Hello, thế giới! 👋";
// Ghi bằng UTF-16 (mặc định Little-Endian, có BOM)
File.WriteAllText(file, text, Encoding.Unicode);
// Đọc bằng UTF-16
string readText = File.ReadAllText(file, Encoding.Unicode);
Console.WriteLine(readText); // Mọi thứ đọc đúng!
Console.WriteLine($"Kích thước file: {new FileInfo(file).Length} byte");
Sau khi chạy bạn sẽ thấy mọi ký tự hiển thị chính xác. File tiếng Anh lưu bằng UTF-16 thường chiếm khoảng gấp đôi so với ASCII hoặc UTF-8 (trong phạm vi ASCII).
4. Сводная таблица сравнения кодировок
Để hệ thống hóa kiến thức, ta tổng hợp các đặc tính chính vào một bảng.
| Mã hóa | Số byte tối thiểu trên ký tự | Số byte tối đa trên ký tự | Tương thích với ASCII (trực tiếp) | Có dùng BOM (mặc định trong .NET) | Ví dụ ứng dụng |
|---|---|---|---|---|---|
| ASCII | 1 | 1 | Đầy đủ | Không | Hệ thống cũ, dữ liệu văn bản rất đơn giản, giao thức nội bộ |
| UTF-16 | 2 | 4 | Không | Có (Encoding.Unicode) | Biểu diễn chuỗi nội bộ trong Windows, Java; file text trên Windows |
| UTF-8 | 1 | 4 | Đầy đủ | Không (Encoding.UTF8 trong .NET 5+); Có (Encoding.UTF8 trong .NET Framework) | Web (HTML, JSON), file cấu hình, source code, Linux/Unix |
Ghi chú nhỏ về Encoding.UTF8 trong .NET:
Trong .NET Framework xưa, Encoding.UTF8 mặc định thêm BOM. Trong .NET hiện đại (Core/5+) hành vi thay đổi: mặc định BOM không được thêm. Nếu cần BOM, dùng new UTF8Encoding(true).
5. Как указать кодировку в C#
Như bạn thấy trong ví dụ, để nói với StreamReader hoặc StreamWriter dùng "từ điển" nào, ta truyền cho chúng một đối tượng từ lớp System.Text.Encoding.
System.Text.Encoding cung cấp một số tùy chọn sẵn có:
- Encoding.ASCII: làm việc với ASCII.
- Encoding.Unicode: UTF-16 LE (có BOM).
- Encoding.UTF8: UTF-8 (không có BOM theo mặc định trong .NET hiện đại).
Các mã hóa khác có thể lấy qua Encoding.GetEncoding (ví dụ "windows-1251", "koi8-r"), nhưng trọng tâm ở đây là Unicode.
// Ghi bằng UTF-8
using var writer = new StreamWriter("my_file.txt", false, Encoding.UTF8);
writer.WriteLine("Một vài dòng text.");
// Đọc bằng UTF-16
using var reader = new StreamReader("another_file.txt", Encoding.Unicode);
string content = reader.ReadToEnd();
Console.WriteLine(content);
Đó là bí quyết! StreamReader và StreamWriter đảm nhiệm toàn bộ việc chuyển ký tự thành byte và ngược lại, theo quy tắc của mã hóa đã chọn.
6. Проблемы с кодировками: "Кракозябры"
Chúng ta đã thấy "ký tự lạ" khi ghi text tiếng Nga bằng ASCII. Nhưng nếu bạn ghi file bằng mã hóa này nhưng đọc bằng mã hóa khác thì sao? Đó mới là phần thú vị!
Giả sử một thư viết bằng tiếng Nga lưu dưới dạng UTF-8, nhưng client của bạn cố đọc nó như CP1251. Dãy byte sẽ bị diễn giải sai, và thay vì "Привет, мир!" bạn sẽ thấy các "ký tự lạ" (tiếng Anh gọi là mojibake).
Lý do duy nhất là: mã hóa khi ghi và đọc không khớp. Luôn dùng cùng một mã hóa ở cả hai phía, trừ khi bạn đang chủ động chuyển mã.
string file = "mismatch.txt";
string russianText = "Привет, мир!";
// Ghi bằng UTF-8 (đúng!)
File.WriteAllText(file, russianText, Encoding.UTF8);
// Đọc sai: cố đọc file UTF-8 như ASCII
string readAsAscii = File.ReadAllText(file, Encoding.ASCII);
Console.WriteLine($"Bản gốc: {russianText}");
Console.WriteLine($"Đọc như ASCII: {readAsAscii}"); // Đây là nơi xuất hiện "ký tự lạ"!
Chạy chương trình và bạn sẽ thấy chữ Kirill biến thành dấu hỏi hoặc chuỗi ký tự vô nghĩa. Ở bài sau mình sẽ nói về cách làm việc với mã hóa một cách linh hoạt hơn để tránh mấy vấn đề này, và cách xác định mã hóa của file bạn đang đọc.
GO TO FULL VERSION