1. 介紹
我們已經知道,無論電腦多聰明,它本身並不知道什麼是字母 "A" 或符號 "⌘"。它只懂 0 跟 1,為了把對人類可讀的字元對應過來,它需要一個翻譯——也就是編碼(encoding)。
編碼的歷史就是折衷與演進的歷史。一開始很簡單,後來變得複雜,最後出現了比較通用的標準。咱們就按時間線快速過一遍。
故事的開始
從最源頭說起。在之前的課我們提到過文字編碼的祖師 — ASCII(念作 "aski")。提醒一下,ASCII 全名是 American Standard Code for Information Interchange,也就是「美國資訊交換標準碼」。從名字就知道這是為誰設計的,也能理解為什麼它是「美式」的。
ASCII 在 1960 年代被制定,是第一個廣泛使用的字元編碼標準。它是一套 128 個字元:
- 拉丁字母(大寫與小寫): A-Z, a-z
- 數字: 0-9
- 標點符號: .,!?"' 等等
- 一些控制字元:換行、tab 等
這 128 個字元每個都用一個位元組表示,實際只用了 7 個位元(最高位通常留空或用於檢錯)。對於英文來說,這非常緊湊且有效率。
範例:
字元 'A' 在 ASCII 中編碼為位元組 0x41(以二進位表示 01000001)
字元 '!' 在 ASCII 中編碼為位元組 0x21(以二進位表示 00100001)
限制:
ASCII 的主要限制很明顯:它針對英文設計。如果你想寫俄文("Привет")、德文("Grüße")或中文,ASCII 幫不上忙,因為表裡沒有這些字元。這導致出現了許多所謂的 code pages(碼頁)CP1251(Windows Cyrillic)、KOI8-R 等。但問題是,這些碼頁之間互不相容:同一個位元在不同碼頁可能代表完全不同的字元!真是巴比倫塔式的混亂。
實際使用情況(今天):
純粹的 ASCII 今天很少用於通用文字檔,除非是非常特殊的需求或古老系統。不過它的影響還在:很多現代編碼都向後相容 ASCII,這點很重要。
咱們來試著寫檔和讀檔用 ASCII,然後再加上俄文看看會發生什麼。
在 JetBrains Rider 建一個新的 console 專案,隨便取名例如 FileEncodingExplorer。
using System;
using System.IO;
using System.Text;
string file = "ascii.txt";
string asciiText = "Hello, world!";
string cyrillicText = "嗨,世界!";
// 寫入到 ASCII
using var writer = new StreamWriter(file, false, Encoding.ASCII);
writer.WriteLine(asciiText);
writer.WriteLine(cyrillicText);
// 從 ASCII 讀取
using var reader = new StreamReader(file, Encoding.ASCII);
string content = reader.ReadToEnd();
Console.WriteLine("檔案內容:");
Console.WriteLine(content);
Console.WriteLine("\n西里爾字母被替換成 '?',因為 ASCII 不支援西里爾字母!");
執行這段程式時,你會看到英文部分正常顯示,但俄文會變成問號(?)或其他「未知」符號。原因是 Encoding.ASCII 不知道如何把西里爾字元轉成位元組,所以它會把那些字元替換成某個「安全」字元(通常是 ?),或者讀出的位元組若來自其他編碼,會被 ASCII 當作別的字元來解釋。在我們的範例中,StreamWriter 在寫入時會把不在 ASCII 表裡的字元強制換成 ?。這就是為什麼選對編碼很重要!
2. UTF-8: 網路之王且很彈性
接下來講當今最重要、也應該是最常用的編碼之一 — UTF-8。這是大部分網路、Linux 系統和現代應用常用的編碼。
它是什麼?
UTF-8(Unicode Transformation Format - 8-bit)是 Unicode 的一種編碼,為了解決 UTF-16 在處理英文時的效率問題。UTF-8 是一種 可變長度編碼,但設計得很巧妙:
- 那些屬於普通 ASCII 範圍的字元(碼點 0 到 127)只佔 1 個位元組。最棒的是:這些位元組和它們在 ASCII 的表示完全相同!也就是說 UTF-8 向後相容 ASCII。
- 其他字元會用 2 到 4 個位元組來表示:
- 西里爾字母通常用 2 個位元組。
- 許多歐洲變音字符、阿拉伯文、希伯來文、希臘文等用 2 個位元組。
- 中文/日文/韓文的漢字常用 3 個位元組。
- 罕見字元和某些 emoji 會用到 4 個位元組。
UTF-8 的位元表示範例:
- 字元 'A'(ASCII): 01000001(1 位元組)
- 字元 'я'(俄文): 11010001 10111111(2 位元組)
- 字元 '€'(歐元): 11100010 10000010 10101100(3 位元組)
- 字元 '😂'(表情符號): 11110000 10011111 10011000 10000010(4 位元組)
為什麼 UTF-8 這麼受歡迎?
- 效率:對含大量 ASCII 的文字(像英文、程式碼、設定檔)非常節省空間。
- 向後相容 ASCII:如果一個 UTF-8 檔案只包含 ASCII 字元,你也能用 ASCII 去讀它,一般不會出問題!
- 通常不使用 BOM:跟 UTF-16 比起來,UTF-8 通常不加 BOM。如果出現(例如 EF BB BF),那是個可選的「功能」,但有時會造成麻煩(像在 Linux 腳本或某些格式解析時)。
缺點:
- 可變長度讓某些操作變得麻煩(比如不掃描整個字串就跳到第 N 個字元),不過在 C# 裡通常不是問題:string 處理的是 Unicode 字元,跟檔案編碼分離。
實際應用:
- 網頁(HTML、CSS、JavaScript)、
- API(JSON、XML)、
- 設定檔、
- 大多數程式語言的原始碼、
- Linux/Unix 類系統。
來寫個 UTF-8 的檔案並讀回看看。
string file = "utf8.txt";
string text = "Hello, 世界! 😀 €";
// 寫入 UTF-8(預設沒有 BOM)
File.WriteAllText(file, text, Encoding.UTF8);
// 從 UTF-8 讀取
string readText = File.ReadAllText(file, Encoding.UTF8);
Console.WriteLine(readText); // 一切都正確讀取!
執行後,比較一下檔案大小,你會發現混合文字的 utf8.txt 通常比 UTF-16 的檔案小;如果只有英文,大小會和 ASCII 差不多。
3. UTF-16: 幾乎包山包海的 Unicode
碼頁的「巴比倫塔」問題讓開發者很頭痛,尤其程式開始全球化後。需要一個通用解。於是有了 Unicode。Unicode 不是單一編碼,而是一張巨大的表,給每個已知字元一個唯一的數值(code point)。
它是什麼?
UTF-16(Unicode Transformation Format - 16-bit)是最初打算把所有 Unicode 字元都用兩個位元組(16-bit)表示的編碼。
- 大多數字元(BMP 範圍,直到 65535)用 2 個位元組表示。
- 超出 BMP 的字元用 surrogate pairs(代理項)表示 — 共 4 個位元組。所以 UTF-16 也是可變長度,但常被視為「每個字元兩個位元組」。
位元順序(Endianness)與 BOM:
- Big-Endian (BE):高位元組在前。
- Little-Endian (LE):低位元組在前。
- 為了讓讀取程式知道順序,檔案開頭常放 BOM(Byte Order Mark):
- UTF-16 LE:FF FE
- UTF-16 BE:FE FF
優點:
- 能涵蓋絕大多數世界字元。
- 在 BMP 範圍內處理字元簡單(看起來像固定長度 2 位元組)。
缺點:
- 對英文不夠有效率:每個 ASCII 字元佔 2 位元組。
- BOM 可能造成問題,若讀取端沒預期到它就會出錯。
實際應用:
UTF-16 在 Windows 內部及某些語言(例如 Java)常被用作字串的內部表示。Windows 的記事本(Notepad)在處理帶有西里爾字元的文字檔時,經常會預設存成 UTF-16 LE 並帶有 BOM。
string file = "utf16.txt";
string text = "Hello, 世界! 👋";
// 寫入 UTF-16(預設 Little-Endian,帶 BOM)
File.WriteAllText(file, text, Encoding.Unicode);
// 從 UTF-16 讀取
string readText = File.ReadAllText(file, Encoding.Unicode);
Console.WriteLine(readText); // 一切都正確顯示!
Console.WriteLine($"檔案大小: {new FileInfo(file).Length} 位元組");
執行後你會看到字元正確顯示。英文內容存成 UTF-16 的檔案,大約會比 ASCII 或 UTF-8(若為 ASCII 範圍)大一倍。
4. 編碼比較總表
為了把重點整理清楚,下面把主要特性放在一張表裡對照。
| 編碼 | 最小位元組數/字元 | 最大位元組數/字元 | 是否直接相容 ASCII | .NET 預設是否使用 BOM | 應用範例 |
|---|---|---|---|---|---|
| ASCII | 1 | 1 | 完全相容 | 否 | 舊系統、極簡文字資料、內部協定 |
| UTF-16 | 2 | 4 | 否 | 是(Encoding.Unicode) | Windows 內部字串表示、Java;Windows 文字檔 |
| UTF-8 | 1 | 4 | 完全相容 | 否(在 .NET 5+ 的 Encoding.UTF8);是(在舊版 .NET Framework 的 Encoding.UTF8) | 網路(HTML、JSON)、設定檔、原始碼、Linux/Unix |
關於 Encoding.UTF8 在 .NET 的小補充:
歷史上在 .NET Framework 中 Encoding.UTF8 預設會加上 BOM。在現代的 .NET(Core / 5+)行為改變了:預設不會加入 BOM。如果你需要 BOM,可以用 new UTF8Encoding(true)。
5. 在 C# 中如何指定編碼
你應該已經在範例看到,想告訴 StreamReader 或 StreamWriter 用哪套「字典」時,我們傳一個來自 System.Text.Encoding 的物件給它們。
System.Text.Encoding 提供了幾個常用選項:
- Encoding.ASCII:處理 ASCII。
- Encoding.Unicode:UTF-16 LE(帶 BOM)。
- Encoding.UTF8:UTF-8(在現代 .NET 預設不帶 BOM)。
其他編碼可以透過 Encoding.GetEncoding 取得(例如 "windows-1251", "koi8-r"),但現在我們主要關注 Unicode 系列。
// 寫入 UTF-8
using var writer = new StreamWriter("my_file.txt", false, Encoding.UTF8);
writer.WriteLine("某些文字。");
// 從 UTF-16 讀取
using var reader = new StreamReader("another_file.txt", Encoding.Unicode);
string content = reader.ReadToEnd();
Console.WriteLine(content);
就是這麼簡單!StreamReader 和 StreamWriter 會負責把字元跟位元組互相轉換,依照你指定的編碼規則來處理。
6. 編碼問題:亂碼("Кракозябры")
我們剛才已經看到把俄文寫到 ASCII 時會出現亂碼。但更常見的是:一個檔案用 A 編碼寫,然後用 B 編碼讀,這時候就會熱鬧(也很惱人)。
想像一封俄文信存成 UTF-8,但你的客戶端去用 CP1251 去讀它。位元組序列會被錯誤解讀,原本的 "Привет, мир!" 會變成一堆亂碼(英語叫做 mojibake)。
原因只有一個:寫入與讀取時使用了不同的編碼。記住:只要不是刻意要轉碼,寫跟讀就應該用同一種編碼。
string file = "mismatch.txt";
string russianText = "嗨,世界!";
// 正確地用 UTF-8 寫入!
File.WriteAllText(file, russianText, Encoding.UTF8);
// 錯誤地讀取:把 UTF-8 檔當作 ASCII 來讀
string readAsAscii = File.ReadAllText(file, Encoding.ASCII);
Console.WriteLine($"原文: {russianText}");
Console.WriteLine($"當作 ASCII 讀取: {readAsAscii}"); // 這裡就會出現亂碼!
執行後你會看到西里爾字或其他非 ASCII 字元變成問號或一堆無意義的符號。在下一課我們會講更靈活的編碼處理方法、如何避免這些坑,以及怎麼判斷一個檔案實際上是用哪種編碼儲存的。
GO TO FULL VERSION