.NET 中的字元編碼

本文提供 .NET 所使用的字元編碼系統簡介。 本文說明 StringCharRuneStringInfo 型別如何與 Unicode、UTF-16 和 UTF-8 搭配運作。

「字元」一詞在此以「讀者認知為單一顯示元素」的一般意義使用。 常見的範例包括字母 "a"、符號 "@" 和表情圖示 "🐂"。 有時候看起來是一個字元,但實際上是由多個獨立顯示元素所組成,如字素叢集一節所述。

string 和 char 型別

string 類別的執行個體代表某些文字。 string 在邏輯上是 16 位元值的序列,每個值都是 char 結構的執行個體。 string.Length 屬性會傳回 string 執行個體中 char 執行個體的數目。

下列範例函式會以十六進位標記法列印出 string 中的所有 char 執行個體:

void PrintChars(string s)
{
    Console.WriteLine($"\"{s}\".Length = {s.Length}");
    for (int i = 0; i < s.Length; i++)
    {
        Console.WriteLine($"s[{i}] = '{s[i]}' ('\\u{(int)s[i]:x4}')");
    }
    Console.WriteLine();
}

將 string "Hello" 傳遞至此函式,即會取得下列輸出:

PrintChars("Hello");
"Hello".Length = 5
s[0] = 'H' ('\u0048')
s[1] = 'e' ('\u0065')
s[2] = 'l' ('\u006c')
s[3] = 'l' ('\u006c')
s[4] = 'o' ('\u006f')

每個字元都是以單一 char 值表示。 世界上的大部分語言都適用該模式。 例如,以下是兩個中文字元的輸出,其發音類似 nǐ hǎochar,意指 Hello:

PrintChars("你好");
"你好".Length = 2
s[0] = '你' ('\u4f60')
s[1] = '好' ('\u597d')

不過,對於某些語言以及某些符號和表情圖示來說,則需要兩個 char 執行個體來代表單一字元。 例如歐塞奇族語言中意指「歐塞奇」的一詞,請比較該詞中的字元與 char 執行個體數目:

PrintChars("𐓏𐓘𐓻𐓘𐓻𐓟 𐒻𐓟");
"𐓏𐓘𐓻𐓘𐓻𐓟 𐒻𐓟".Length = 17
s[0] = '�' ('\ud801')
s[1] = '�' ('\udccf')
s[2] = '�' ('\ud801')
s[3] = '�' ('\udcd8')
s[4] = '�' ('\ud801')
s[5] = '�' ('\udcfb')
s[6] = '�' ('\ud801')
s[7] = '�' ('\udcd8')
s[8] = '�' ('\ud801')
s[9] = '�' ('\udcfb')
s[10] = '�' ('\ud801')
s[11] = '�' ('\udcdf')
s[12] = ' ' ('\u0020')
s[13] = '�' ('\ud801')
s[14] = '�' ('\udcbb')
s[15] = '�' ('\ud801')
s[16] = '�' ('\udcdf')

在上述範例中,除了空格以外,每個字元都是由兩個 char 執行個體表示。

單一個 Unicode 表情圖示也是由兩個 char 表示,如下列範例所示的是公牛表情圖示:

"🐂".Length = 2
s[0] = '�' ('\ud83d')
s[1] = '�' ('\udc02')

這些範例顯示出 string.Length 的值是指出 char 執行個體數目,而不一定代表顯示的字元數目。 單一 char 執行個體本身不一定代表一個字元。

對應至單一字元的 char 字組稱為「代理字組」。 若要瞭解其運作方式,則必須理解 Unicode 和 UTF-16 編碼。

Unicode 字碼指標

Unicode 是國際編碼標準,可用於各種平台,並與各種語言和指令碼搭配使用。

Unicode 標準定義了超過 110 萬個字碼指標。 字碼指標是介於 0 到 U+10FFFF (十進位 1,114,111) 的整數值。 部分字碼指標是指派給字母、符號或表情圖示。 其他字碼指標則是指派給控制文字或字元顯示方式的動作,例如前進到新的一行。 有許多字碼指標尚未指派。

以下是一些字碼指標的指派範例,並附上所位在的 Unicode 字元表連結:

Decimal Hex 範例 描述
10 U+000A N/A 換行字元
97 U+0061 a 拉丁小寫字母 A
562 U+0232 Ȳ 具有長音符號的拉丁大寫字母 Y
68,675 U+10C43 𐱃 舊土耳其文字母 ORKHON AT
127,801 U+1F339 🌹 玫瑰表情圖示

字碼指標是使用 U+xxxx 語法來自訂參考,其中 xxxx 是十六進位編碼的整數值。

在完整的字碼指標範圍內,有兩個子範圍:

  • 範圍 U+0000..U+FFFF 中的基本多語系平面 (BMP)。 這個 16 位元範圍提供 65,536 個字碼指標,足以涵蓋全球大部分的書寫系統。
  • 範圍 U+10000..U+10FFFF 中的增補字碼指標。 這個 21 位元範圍提供超過一百萬個額外的字碼指標,可用於較不知名的語言和其他用途,例如表情圖示。

下圖說明 BMP 與增補字碼指標之間的關聯性。

BMP 與增補字碼指標

UTF-16 字碼單位

16 位元 Unicode 轉換格式 (UTF-16) 是一種字元編碼系統,使用 16 位元字碼單位來代表 Unicode 字碼指標。 .NET 使用 UTF-16 來編碼 string 中的文字。 char 執行個體代表一個 16 位元字碼單位。

單個 16 位元字碼單位可用來代表基本多語系平面 16 位元範圍內的任何字碼指標。 但若為增補範圍中的字碼指標,則需要兩個 char 執行個體。

代理字組

透過稱為「代理字碼指標」的特殊範圍,從 U+D800U+DFFF (十進位 55,296 到 57,343,包含頭尾),更有利於將兩個 16 位元值轉譯成單一個 21 位元值。

下圖說明 BMP 與代理字碼指標之間的關聯性。

BMP 和代理字碼指標

當「高代理」字碼指標 (U+D800..U+DBFF) 後面緊接著「低代理」字碼指標 (U+DC00..U+DFFF) 時,會使用下列公式將字組轉譯為增補字碼指標:

code point = 0x10000 +
  ((high surrogate code point - 0xD800) * 0x0400) +
  (low surrogate code point - 0xDC00)

以下是使用十進位標記法的相同公式:

code point = 65,536 +
  ((high surrogate code point - 55,296) * 1,024) +
  (low surrogate code point - 56,320)

代理字碼指標的數值不高於低代理字碼指標。 高代理字碼指標稱為「高」,是因為這是用來計算 20 位元字碼指標範圍內較高順序的 10 位元。 低代理字碼指標用來計算順序較低的 10 位元。

例如,對應至代理字組 0xD83C0xDF39 的實際字碼指標會如下計算:

actual = 0x10000 + ((0xD83C - 0xD800) * 0x0400) + (0xDF39 - 0xDC00)
       = 0x10000 + (          0x003C  * 0x0400) +           0x0339
       = 0x10000 +                      0xF000  +           0x0339
       = 0x1F339

以下是使用十進位標記法的相同計算:

actual =  65,536 + ((55,356 - 55,296) * 1,024) + (57,145 - 56320)
       =  65,536 + (              60  * 1,024) +             825
       =  65,536 +                     61,440  +             825
       = 127,801

上述範例示範 "\ud83c\udf39" 是稍早所述 U+1F339 ROSE ('🌹') 字碼指標的 UTF-16 編碼。

Unicode 純量值

Unicode 純量值一詞是指代理字碼指標以外的所有字碼指標。 換句話說,純量值是獲指派某個字元,或未來能獲指派字元的任何字碼指標。 此處的「字元」是指可指派給字碼指標的任何內容,其中包括控制文字或字元顯示方式的動作之類的事項。

純量值字碼指標的圖解如下。

純量值

Rune 型別作為純量值

從 .NET Core 3.0 開始,System.Text.Rune 型別代表 Unicode 純量值。 .NET Core 2.x 或 .NET Framework 4.x 中無法使用 Rune

Rune 建構函式會確認產生的執行個體為有效的 Unicode 純量值,否則會擲回例外狀況。 下列範例顯示成功具現化 Rune 執行個體的程式碼,因為輸入代表有效的純量值:

Rune a = new Rune('a');
Rune b = new Rune(0x0061);
Rune c = new Rune('\u0061');
Rune d = new Rune(0x10421);
Rune e = new Rune('\ud801', '\udc21');

下列範例會擲回例外狀況,因為字碼指標位於代理範圍,而且不是代理字組的一部分:

Rune f = new Rune('\ud801');

下列範例會擲回例外狀況,因為字碼指標超出增補範圍:

Rune g = new Rune(0x12345678);

Rune 使用方式範例:變更字母大小寫

如果 char 是來自代理字組,而 API 採用 char 並假設它在處理本身為純量值的字碼指標,該 API 便無法正確運作。 例如,請參考下列在 string 中的每個 char 上呼叫 Char.ToUpperInvariant 的方法:

// THE FOLLOWING METHOD SHOWS INCORRECT CODE.
// DO NOT DO THIS IN A PRODUCTION APPLICATION.
static string ConvertToUpperBadExample(string input)
{
    StringBuilder builder = new StringBuilder(input.Length);
    for (int i = 0; i < input.Length; i++) /* or 'foreach' */
    {
        builder.Append(char.ToUpperInvariant(input[i]));
    }
    return builder.ToString();
}

如果 inputstring 包含小寫的德瑟雷特字母 er (𐑉),則此程式碼不會將它轉換成大寫 (𐐡)。 程式碼會在每個代理字碼指標 U+D801U+DC49 上個別呼叫 char.ToUpperInvariant。 但 U+D801 本身沒有足夠的資訊,無法將其識別為小寫字母,因此 char.ToUpperInvariant 不會加以處理, 而且會以相同方式處理 U+DC49。 結果便是 inputstring 中的小寫 '𐑉' 不會轉換成大寫 '𐐡'。

以下有兩種選項能將 string 正確轉換成大寫:

  • 在輸入 string 上呼叫 String.ToUpperInvariant,而不是逐一查看 charchar。 方法 string.ToUpperInvariant 可以存取每個代理字詞的全部兩個部分,因此能正確處理所有的 Unicode 字碼指標。

  • 逐一查看 Unicode 純量值做為 Rune 執行個體,而不是 char 執行個體,如下列範例所示。 由於 Rune 執行個體是有效的 Unicode 純量值,因此可以傳遞給預期會在純量值上運作的 API。 如下列範例所示,呼叫 Rune.ToUpperInvariant 會產生正確的結果:

    static string ConvertToUpper(string input)
    {
        StringBuilder builder = new StringBuilder(input.Length);
        foreach (Rune rune in input.EnumerateRunes())
        {
            builder.Append(Rune.ToUpperInvariant(rune));
        }
        return builder.ToString();
    }
    

其他 Rune API

Rune 型別會公開許多 char API 的類比。 例如,下列方法會鏡像處理 char 型別上的靜態 API:

若要從 Rune 執行個體取得原始純量值,請使用 Rune.Value 屬性。

若要將 Rune 執行個體轉換回 char 序列,請使用 Rune.ToStringRune.EncodeToUtf16 方法。

由於任何 Unicode 純量值都可以由單一 char 或一個代理字組來表示,因此任何 Rune 執行個體最多可由 2 個 char 執行個體表示。 請使用 Rune.Utf16SequenceLength 來查看代表 Rune 執行個體所需的 char 執行個體數目。

如需 .NET Rune 型別的詳細資訊,請參閱 RuneAPI 參考

字素叢集

即使看起來像是一個字元,實際上可能是多個字碼指標組合產生的結果,因此通常會使用字素叢集這個更具描述性的詞彙來取代「字元」。 .NET 中的對等詞彙是文字元素

假設有 string 執行個體 "a"、"á"、"á" 和 "👩🏽‍🚒"。 如果作業系統依照 Unicode 標準的指定方式來處理,則每個 string 執行個體都會顯示為單一的文字元素或字素叢集。 但最後兩個是以一個以上的純量值字碼指標表示。

  • string "a" 是以一個純量值表示,並包含一個 char 執行個體。

    • U+0061 LATIN SMALL LETTER A
  • string "á" 是以一個純量值表示,並包含一個 char 執行個體。

    • U+00E1 LATIN SMALL LETTER A WITH ACUTE
  • string "á" 看起來與 "á" 相同,但以兩個純量值表示,並包含兩個 char 執行個體。

    • U+0061 LATIN SMALL LETTER A
    • U+0301 COMBINING ACUTE ACCENT
  • 最後,string "👩🏽‍🚒" 會以四個純量值表示,並包含七個 char 執行個體。

    • U+1F469 WOMAN (增補範圍,需要代理字組)
    • U+1F3FD EMOJI MODIFIER FITZPATRICK TYPE-4 (增補範圍,需要代理字組)
    • U+200D ZERO WIDTH JOINER
    • U+1F692 FIRE ENGINE (增補範圍,需要代理字組)

在上述部分範例中,例如結合有重音修飾符或外觀音調修飾符,字碼指標不會在畫面上顯示為獨立元素。 相反地,這是用來修改位在前方的文字元素外觀。 這些範例顯示,這可能需要多個純量值來構成我們認為是單一「字元」或「字素叢集」的內容。

若要列舉 string 的字素叢集,請使用 StringInfo 類別,如下列範例所示。 如果熟悉 Swift,.NET StringInfo 型別在概念上類似於 Swift 的 character 型別

範例:計數 char、Rune 和文字元素執行個體

在 .NET API 中,字素叢集稱為「文字元素」。 下列方法示範 charRunestring 中的文字元素執行個體之間的差異:

static void PrintTextElementCount(string s)
{
    Console.WriteLine(s);
    Console.WriteLine($"Number of chars: {s.Length}");
    Console.WriteLine($"Number of runes: {s.EnumerateRunes().Count()}");

    TextElementEnumerator enumerator = StringInfo.GetTextElementEnumerator(s);

    int textElementCount = 0;
    while (enumerator.MoveNext())
    {
        textElementCount++;
    }

    Console.WriteLine($"Number of text elements: {textElementCount}");
}
PrintTextElementCount("a");
// Number of chars: 1
// Number of runes: 1
// Number of text elements: 1

PrintTextElementCount("á");
// Number of chars: 2
// Number of runes: 2
// Number of text elements: 1

PrintTextElementCount("👩🏽‍🚒");
// Number of chars: 7
// Number of runes: 4
// Number of text elements: 1

如果在 .NET Framework 或 .NET Core 3.1 或更早版本中執行此字碼,表情圖示文字元素計數會顯示 4。 這是因為 .NET 5 中已修正 StringInfo 類別中的錯誤 (bug)。

範例:分割 string 執行個體

分割 string 執行個體時,請避免分割代理字組和字素叢集。 請參考下列不正確的字碼範例,其想在 string 中每隔 10 個字元插入分行符號:

// THE FOLLOWING METHOD SHOWS INCORRECT CODE.
// DO NOT DO THIS IN A PRODUCTION APPLICATION.
static string InsertNewlinesEveryTencharsBadExample(string input)
{
    StringBuilder builder = new StringBuilder();

    // First, append chunks in multiples of 10 chars
    // followed by a newline.
    int i = 0;
    for (; i < input.Length - 10; i += 10)
    {
        builder.Append(input, i, 10);
        builder.AppendLine(); // newline
    }

    // Then append any leftover data followed by
    // a final newline.
    builder.Append(input, i, input.Length - i);
    builder.AppendLine(); // newline

    return builder.ToString();
}

由於此字碼會列舉 char 執行個體,因此會分割剛好跨越 10-char 界限的代理字組,並在兩者之間插入分行符號。 這個插入行為會導致資料損毀,因為代理字碼指標需要成對才有意義。

如果列舉 Rune 執行個體 (純量值) 而不是 char 執行個體,並無法完全避免資料損毀的可能性。 一組 Rune 執行個體可能會組成一個跨越 10-char 界限的字素叢集。 如果該組字素叢集受到分割,就無法正確解譯。

更好的方法是計算 grapheme 叢集或文字元素來切割 string,如以下範例所示:

static string InsertNewlinesEveryTenTextElements(string input)
{
    StringBuilder builder = new StringBuilder();

    // Append chunks in multiples of 10 chars

    TextElementEnumerator enumerator = StringInfo.GetTextElementEnumerator(input);

    int textElementCount = 1;
    while (enumerator.MoveNext())
    {
        builder.Append(enumerator.Current);
        if (textElementCount % 10 == 0 && textElementCount > 0)
        {
            builder.AppendLine(); // newline
        }
        textElementCount++;
    }

    // Add a final newline.
    builder.AppendLine(); // newline
    return builder.ToString();

}

如先前所述,在 .NET 5 之前,StringInfo 類別有 Bug 導致無法正確處理某些字素叢集。

UTF-8 和 UTF-32

上述各節著重於 UTF-16,因為這是 .NET 用來編碼 string 執行個體的編碼系統。 Unicode 還有其他編碼系統:UTF-8UTF-32。 這些編碼分別使用 8 位元字碼單位和 32 位元字碼單位。

如同 UTF-16,UTF-8 需要多個字碼單位來代表某些 Unicode 純量值。 UTF-32 可用單一 32 位元字碼單位來代表任何純量值。

以下是一些範例,顯示出如何以這三個 Unicode 編碼系統呈現相同的 Unicode 字碼指標:

Scalar: U+0061 LATIN SMALL LETTER A ('a')
UTF-8 : [ 61 ]           (1x  8-bit code unit  = 8 bits total)
UTF-16: [ 0061 ]         (1x 16-bit code unit  = 16 bits total)
UTF-32: [ 00000061 ]     (1x 32-bit code unit  = 32 bits total)

Scalar: U+0429 CYRILLIC CAPITAL LETTER SHCHA ('Щ')
UTF-8 : [ D0 A9 ]        (2x  8-bit code units = 16 bits total)
UTF-16: [ 0429 ]         (1x 16-bit code unit  = 16 bits total)
UTF-32: [ 00000429 ]     (1x 32-bit code unit  = 32 bits total)

Scalar: U+A992 JAVANESE LETTER GA ('ꦒ')
UTF-8 : [ EA A6 92 ]     (3x  8-bit code units = 24 bits total)
UTF-16: [ A992 ]         (1x 16-bit code unit  = 16 bits total)
UTF-32: [ 0000A992 ]     (1x 32-bit code unit  = 32 bits total)

Scalar: U+104CC OSAGE CAPITAL LETTER TSHA ('𐓌')
UTF-8 : [ F0 90 93 8C ]  (4x  8-bit code units = 32 bits total)
UTF-16: [ D801 DCCC ]    (2x 16-bit code units = 32 bits total)
UTF-32: [ 000104CC ]     (1x 32-bit code unit  = 32 bits total)

如先前所述,代理字組中的單一 UTF-16 字碼單位本身並不具意義。 同樣地,如果單一 UTF-8 字碼單位是位於用來計算純量值的兩個、三個或四個的序列中,單一 UTF-8 字碼單位本身不具意義。

注意

從 C# 11 開始,可在常值 string 上使用 "u8" 尾碼來表示 UTF-8 string 常值。 如需 UTF-8 string 常值的詳細資訊,請參閱 C# 指南中內建參考型別文章的「string 常值」一節。

位元組序

在 .NET 中,string 的 UTF-16 字碼單位會儲存在連續記憶體中,形式為一連串的 16 位元整數 (char 執行個體)。 個別字碼單位的位元會根據目前架構的位元組序來配置。

在位元組由小到大排列的架構上,由 UTF-16 字碼指標 [ D801 DCCC ] 組成的 string 會在記憶體中配置為位元組 [ 0x01, 0xD8, 0xCC, 0xDC ]。 在位元組由大到小排列的架構上,相同的 string 會在記憶體中配置為位元組 [ 0xD8, 0x01, 0xDC, 0xCC ]

彼此通訊的電腦系統,必須對跨網路的資料表示法有所共識。 大部分的網路通訊協定在傳輸文字時,會使用 UTF-8 做為標準,一部分是為了避免位元組由大到小排列的機器與由小到大排列的機器互相通訊時會造成問題。 由 UTF-8 字碼指標 [ F0 90 93 8C ] 組成的 string 一律會以位元組 [ 0xF0, 0x90, 0x93, 0x8C ] 表示,不論位元組如何排序。

若要使用 UTF-8 來傳輸文字,.NET 應用程式通常會使用類似下列範例的程式碼:

string stringToWrite = GetString();
byte[] stringAsUtf8Bytes = Encoding.UTF8.GetBytes(stringToWrite);
await outputStream.WriteAsync(stringAsUtf8Bytes, 0, stringAsUtf8Bytes.Length);

在上述範例中,Encoding.UTF8.GetBytes 方法會將 UTF-16 string 解碼回到一系列的 Unicode 純量值,然後將這些純量值重新編碼為 UTF-8,並將產生的序列放入 byte 陣列中。 Encoding.UTF8.GetString 方法會執行相反的轉換,將 UTF-8 byte 陣列轉換成 UTF-16 string

警告

由於 UTF-8 在網際網路上相當普遍,可能會因此想從網路讀取原始位元組,並將資料視為 UTF-8 處理。 不過,建議要驗證這是否確實格式正確。 惡意用戶端可能會將格式不正確的 UTF-8 提交到您的服務。 如果將其當作格式正確的資料處理,可能會導致應用程式發生錯誤或導致安全性漏洞。 若要驗證 UTF-8 資料,可使用 Encoding.UTF8.GetString 之類的方法,在將傳入的資料轉換為 string 時執行驗證。

格式正確的編碼

格式正確的 Unicode 編碼是一種 string 字碼單位,可以明確解碼,而且不會錯誤解碼成 Unicode 純量值序列。 格式正確的資料可以任意在 UTF-8、UTF-16 與 UTF-32 之間來回轉碼。

編碼序列的格式是否正確的問題,與機器架構的位元組順序無關。 格式不正確的 UTF-8 序列在位元組由大到小排序和由小到大排序的機器上,都是維持相同的錯誤格式。

以下是格式不正確編碼的一些範例:

  • 在 UTF-8 中,序列 [ 6C C2 61 ] 的格式不正確,因為 C2 後面不能接著 61

  • 在 UTF-16 中,序列 [ DC00 DD00 ] (或 C# 中的 string"\udc00\udd00") 格式不正確,因為低代理 DC00 後面不能接著另一個低代理 DD00

  • 在 UTF-32 中,序列 [ 0011ABCD ] 的格式不正確,因為 0011ABCD 超出 Unicode 純量值的範圍。

在 .NET 中,string 執行個體所包含的 UTF-16 資料格式幾乎一律正確,但不能完全保證。 下列範例示範在 string 執行個體中建立了格式不正確的 UTF-16 資料的有效 C# 程式碼。

  • 格式不正確的常值:

    const string s = "\ud800";
    
  • 分割代理字組的子 string:

    string x = "\ud83e\udd70"; // "🥰"
    string y = x.Substring(1, 1); // "\udd70" standalone low surrogate
    

Encoding.UTF8.GetString 之類的 API 永遠不會傳回格式不正確的 string 執行個體。 Encoding.GetStringEncoding.GetBytes 方法會偵測輸入中格式不正確的序列,並在產生輸出時執行字元替代。 例如,如果 Encoding.ASCII.GetString(byte[]) 在輸入中 (U+0000...U+007F 範圍外) 看到非 ASCII 位元組,則會將 '?' 插入傳回的 string 執行個體中。 Encoding.UTF8.GetString(byte[]) 會將傳回的 string 執行個體中格式不正確的 UTF-8 序列取代為 U+FFFD REPLACEMENT CHARACTER ('�')。 如需詳細資訊,請參閱 Unicode 標準的 5.22 和 3.9 小節。

也可以將內建 Encoding 類別設為在發現格式錯誤的序列時擲回例外狀況,而不是執行字元替代。 此方法通常用於注重安全性而可能無法接受字元替代的應用程式。

byte[] utf8Bytes = ReadFromNetwork();
UTF8Encoding encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
string asString = encoding.GetString(utf8Bytes); // will throw if 'utf8Bytes' is ill-formed

如需如何使用內建 Encoding 類別的詳細資訊,請參閱如何在 .NET 中使用字元編碼類別

另請參閱