.NET 中的字元編碼
本文介紹了 .NET 使用的字元編碼系統。 本文說明 String、Char、Rune 和 StringInfo 型別如何與 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ǎ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 圖表的連結:
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 與增補字碼指標之間的關聯性。
UTF-16 字碼單位
16 位 Unicode 轉換格式 (UTF-16) 是一種字元編碼系統,使用 16 位代碼單元來表示 Unicode 字碼指標。 .NET 使用 UTF-16 來編碼 string
中的文字。 char
執行個體代表一個 16 位元字碼單位。
單個 16 位元字碼單位可用來代表基本多語系平面 16 位元範圍內的任何字碼指標。 但若為增補範圍中的字碼指標,則需要兩個 char
執行個體。
代理字組
透過稱為「代理字碼指標」的特殊範圍,從 U+D800
到 U+DFFF
(十進位 55,296 到 57,343,包含頭尾),更有利於將兩個 16 位元值轉譯成單一個 21 位元值。
下圖說明 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 位元。
例如,對應至代理字組 0xD83C
和 0xDF39
的實際字碼指標會如下計算:
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 使用方式範例:變更字母大小寫
如果 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();
}
如果 input
string 包含小寫的德瑟雷特字母 er
(𐑉
),則此程式碼不會將它轉換成大寫 (𐐡
)。 程式碼會在每個代理字碼指標 U+D801
和 U+DC49
上個別呼叫 char.ToUpperInvariant
。 但 U+D801
本身沒有足夠的資訊,無法將其識別為小寫字母,因此 char.ToUpperInvariant
不會加以處理, 而且會以相同方式處理 U+DC49
。 結果便是 input
string 中的小寫 '𐑉' 不會轉換成大寫 '𐐡'。
以下有兩種選項能將 string 正確轉換成大寫:
在輸入 string 上呼叫 String.ToUpperInvariant,而不是逐一查看
char
和char
。 方法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.ToString 或 Rune.EncodeToUtf16 方法。
由於任何 Unicode 純量值都可以由單一 char
或一個代理字組來表示,因此任何 Rune
執行個體最多可由 2 個 char
執行個體表示。 請使用 Rune.Utf16SequenceLength 來查看代表 Rune
執行個體所需的 char
執行個體數目。
如需 .NET Rune
型別的詳細資訊,請參閱 Rune
API 參考。
字素叢集
看起來像一個字元的內容可能是由多個字碼指標組合而成的,因此經常用來代替「字元」的更具描述性的術語是字形叢集。 .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 中,字素叢集稱為「文字元素」。 下列方法示範 char
、Rune
與 string
中的文字元素執行個體之間的差異:
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
界限的字素叢集。 如果該組字素叢集受到分割,就無法正確解譯。
更好的方法是計算 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-8 和 UTF-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 x = "\ud83e\udd70"; // "🥰" string y = x.Substring(1, 1); // "\udd70" standalone low surrogate
Encoding.UTF8.GetString
之類的 API 永遠不會傳回格式不正確的 string
執行個體。 Encoding.GetString
和 Encoding.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 中使用字元編碼類別。