CodeGym /課程 /C# SELF /主要編碼類型: UTF-8,

主要編碼類型: UTF-8, UTF-16, ASCII

C# SELF
等級 37, 課堂 1
開放

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
  • 其他字元會用 24 個位元組來表示:
    • 西里爾字母通常用 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 這麼受歡迎?

  1. 效率:對含大量 ASCII 的文字(像英文、程式碼、設定檔)非常節省空間。
  2. 向後相容 ASCII:如果一個 UTF-8 檔案只包含 ASCII 字元,你也能用 ASCII 去讀它,一般不會出問題!
  3. 通常不使用 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

碼頁的「巴比倫塔」問題讓開發者很頭痛,尤其程式開始全球化後。需要一個通用解。於是有了 UnicodeUnicode 不是單一編碼,而是一張巨大的表,給每個已知字元一個唯一的數值(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
    在 C# 與 Windows 預設通常使用 UTF-16 LE

優點:

  • 能涵蓋絕大多數世界字元。
  • 在 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 的檔案,大約會比 ASCIIUTF-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# 中如何指定編碼

你應該已經在範例看到,想告訴 StreamReaderStreamWriter 用哪套「字典」時,我們傳一個來自 System.Text.Encoding 的物件給它們。

System.Text.Encoding 提供了幾個常用選項:

  • Encoding.ASCII:處理 ASCII。
  • Encoding.UnicodeUTF-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);

就是這麼簡單!StreamReaderStreamWriter 會負責把字元跟位元組互相轉換,依照你指定的編碼規則來處理。

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 字元變成問號或一堆無意義的符號。在下一課我們會講更靈活的編碼處理方法、如何避免這些坑,以及怎麼判斷一個檔案實際上是用哪種編碼儲存的。

2
任務
C# SELF, 等級 37, 課堂 1
上鎖
比較不同編碼下的檔案大小
比較不同編碼下的檔案大小
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION