.NET 中的字元編碼

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

此處使用字元這個術語的一般含義是讀者視為單一的顯示元素。 常見的範例包括字母 "a"、符號 "@" 和表情圖示 "🐂"。 有時,看起來像一個字元的內容實際上由多個獨立的顯示元素組成,如字形叢集部分所述。

string 和 char 型別

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

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

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ǎo 的中文字輸出,意思是「您好」:

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 圖表的連結:

十進制 十六進位 範例 描述
10 U+000A N/A 換行字元
97 U+0061 拉丁小寫字母 A
562 U+0232 Ȳ 具有長音符號的拉丁大寫字母 Y
68,675 U+10C43 𐱃 古突厥字母 Orkhon AT
127,801 U+1F339 🌹 玫瑰表情圖示

碼位通常以U+xxxx語法習慣性地指稱,其中xxxx是十六進位表示的整數值。

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

  • 基本多語平面(BMP) 在範圍內U+0000..U+FFFF。 這個 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 型別作為純量值

重要

Rune.NET Framework 中無法使用此類型。

在 .NET 中 System.Text.Rune ,類型代表 Unicode 純量值。

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 使用方式範例:變更字母大小寫

當 API 使用 char 並假定其處理的字元碼點是純量值,但 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 (𐑉),則此程式碼不會將它轉換成大寫 (𐐡)。 程式碼會在每個代理字碼指標 char.ToUpperInvariantU+D801 上個別呼叫 U+DC49。 但 U+D801 本身沒有足夠的資訊,無法將其識別為小寫字母,因此 char.ToUpperInvariant 不會加以處理, 而且會以相同方式處理 U+DC49。 結果便是 inputstring 中的小寫 '𐑉' 不會轉換成大寫 '𐐡'。

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

  • 在輸入 String.ToUpperInvariant 上呼叫 string,而不是逐一查看 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 來查看代表 char 執行個體所需的 Rune 執行個體數目。

如需 .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

範例:分割 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 界限的字素叢集。 如果該組字素叢集受到分割,就無法正確解譯。

更好的方法是通過計算字素簇或文字元素來分割 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)
        {
            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 碼點 string 組成的 [ F0 90 93 8C ] 一律會以位元組 [ 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 資料格式幾乎一律正確,但不能完全保證。 下列範例顯示了一些合法的 C# 程式碼,這些程式碼在 string 執行個體中會建立不符合格式的 UTF-16 資料。

  • 格式不正確的字面值:

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

    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[]) 將格式不正確的 UTF-8 序列替換為 U+FFFD REPLACEMENT CHARACTER ('�'),並傳回 string 執行個體。 如需詳細資訊,請參閱 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 中使用字元編碼類別

另請參閱