.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”,它们表示“Hello”:
PrintChars("你好");
"你好".Length = 2
s[0] = '你' ('\u4f60')
s[1] = '好' ('\u597d')
但是,对于某些语言以及某些符号和表情符号,需要两个 char
实例来表示一个字符。 例如,比较奥塞治文中表示 Osage 的单词中的字符和 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
表示,如以下示例中所示的 ox 表情符号:
"🐂".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 图表的链接:
十进制 | Hex | 示例 | 描述 |
---|---|---|---|
10 | U+000A |
不可用 | 换行 |
97 | U+0061 |
a | 拉丁文小写字母 a |
562 | U+0232 |
Ȳ | 带长音符的拉丁文大写字母 Y |
68,675 | U+10C43 |
𐱃 | 古突厥文字母鄂尔浑文 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
来自代理项对,则采用 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) 正确地转换为大写形式:
对 input 字符串 (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 的类似 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 中,字形群集称为“文本元素”。 下面的方法演示 string
中 char
、Rune
和文本元素实例之间的差异:
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 && textElementCount > 0)
{
builder.AppendLine(); // newline
}
textElementCount++;
}
// Add a final newline.
builder.AppendLine(); // newline
return builder.ToString();
}
如前所述,在 .NET 5 之前,StringInfo
类存在一个 bug,导致错误处理了某些 grapheme 群集。
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 代码单位处于由两个、三个或四个用于计算标量值的一连串代码单位中,那么它自身也毫无意义。
注意
从 C# 11 开始,可以对文本 string 使用“u8”后缀来表示 UTF-8 string 文本。 有关 UTF-8 string 文本的详细信息,请参阅 C# 指南中关于内置引用类型的文章的“string 文本”部分。
字节排序方式
在 .NET 中,string 的 UTF-16 代码单位以 16 位整数(char
实例)的序列形式存储在连续内存中。 各个代码单位的位数根据当前体系结构的字节顺序布局。
在 little-endian 体系结构中,由 UTF-16 码位 [ D801 DCCC ]
组成的 string 会在内存中以 [ 0x01, 0xD8, 0xCC, 0xDC ]
字节进行布局。 在 big-endian 体系结构中,同一 string 将在内存中以 [ 0xD8, 0x01, 0xDC, 0xCC ]
字节进行布局。
相互通信的计算机系统必须就跨网络数据的表示形式达成共识。 大多数网络协议在传输文本时都使用 UTF-8 标准,部分原因是为了避免 big-endian 计算机与 little-endian 计算机通信可能导致的问题。 不管字节顺序如何,由 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 在 Internet 上很普遍,因此从网络读取原始字节并将数据视为 UTF-8 会很有吸引力。 但是,应验证它的格式是否正确。 恶意客户端可能向你的服务提交格式错误的 UTF-8。 如果你按正确的格式处理数据,可能会导致应用程序出错或存在安全漏洞。 要验证 UTF-8 数据,可以使用类似于 Encoding.UTF8.GetString
的方法,此方法在将传入数据转换为 string
时将执行验证。
格式正确的编码
格式正确的 Unicode 编码是一串 (string) 代码单位,可以将它毫无歧义地正确解码为一系列 Unicode 标量值。 格式正确的数据可以在 UTF-8、UTF-16 和 UTF-32 之间自由地来回转码。
编码序列格式是否正确的问题与计算机体系结构的字节顺序无关。 在 big-endian 和 little-endian 计算机上,格式错误的 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.GetString
和 Encoding.GetBytes
方法检测输入中格式错误的序列,并在生成输出时执行字符替换。 例如,如果 Encoding.ASCII.GetString(byte[])
在输入中发现非 ASCII 字节(超出 U+0000..U+007F 的范围),它会在返回的 string
实例中插入一个“?”。 Encoding.UTF8.GetString(byte[])
在返回的 string
实例中将格式错误的 UTF-8 序列替换为 U+FFFD REPLACEMENT CHARACTER ('�')
。 有关详细信息,请参阅 5.22 和 3.9 小节中的 Unicode 标准。
在出现格式错误的序列时,也可以将内置 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 中使用字符编码类。