Go 博客
Go 语言中的字符串、字节、字符和符文
简介
上一篇博文 解释了 Go 语言中切片的工作原理,并使用大量示例说明了其实现机制背后的原理。基于此背景,本文将讨论 Go 语言中的字符串。乍一看,字符串似乎是一个过于简单的主题,不值得专门写一篇博文,但要熟练使用它们,不仅需要了解它们的工作原理,还需要理解字节、字符和符文之间的区别、Unicode 和 UTF-8 之间的区别、字符串和字符串字面量之间的区别,以及其他一些更细微的差别。
解决此问题的一种方法是将其视为对以下常见问题的解答:“当我索引 Go 字符串中位置 n 处的元素时,为什么得不到第 n 个字符?” 正如您将看到的,这个问题将引导我们了解文本在现代世界中是如何工作的许多细节。
与 Go 无关,Joel Spolsky 的一篇著名博文 每个软件开发人员绝对必须了解的关于 Unicode 和字符集的绝对最低限度(没有借口!) 很好地介绍了其中的一些问题。他提出的许多观点都将在本文中得到呼应。
什么是字符串?
让我们从一些基础知识开始。
在 Go 语言中,字符串实际上是字节的只读切片。如果您不确定字节切片是什么或它是如何工作的,请阅读 上一篇博文;我们在此假设您已经阅读过了。
需要明确说明的是,字符串保存的是任意字节。它不需要保存 Unicode 文本、UTF-8 文本或任何其他预定义格式。就字符串的内容而言,它与字节切片完全等效。
这是一个字符串字面量(稍后将详细介绍),它使用 \xNN
表示法定义了一个包含一些特殊字节值的字符串常量。(当然,字节的十六进制值范围是从 00 到 FF,包括这两个值。)
const sample = "\xbd\xb2\x3d\xbc\x20\xe2\x8c\x98"
打印字符串
由于我们示例字符串中的一些字节不是有效的 ASCII,甚至不是有效的 UTF-8,因此直接打印字符串将产生丑陋的输出。简单的打印语句
fmt.Println(sample)
会产生这样的混乱(其确切外观因环境而异)
��=� ⌘
要找出该字符串真正包含的内容,我们需要将其分解并检查各个部分。有几种方法可以做到这一点。最明显的方法是循环遍历其内容并单独提取字节,如以下 for
循环所示
for i := 0; i < len(sample); i++ { fmt.Printf("%x ", sample[i]) }
如前所述,索引字符串会访问单个字节,而不是字符。我们将在下面详细讨论该主题。现在,让我们先坚持使用字节。以下是字节循环的输出
bd b2 3d bc 20 e2 8c 98
请注意,各个字节如何与定义字符串的十六进制转义符匹配。
生成混乱字符串的可显示输出的更简短方法是使用 fmt.Printf
的 %x
(十六进制)格式动词。它只是将字符串的连续字节作为十六进制数字转储,每个字节两个数字。
fmt.Printf("%x\n", sample)
将其输出与上面的输出进行比较
bdb23dbc20e28c98
一个不错的技巧是在该格式中使用“空格”标志,在 %
和 x
之间添加一个空格。将此处使用的格式字符串与上面的格式字符串进行比较,
fmt.Printf("% x\n", sample)
并注意字节是如何以空格分隔输出的,这使得结果不那么令人费解
bd b2 3d bc 20 e2 8c 98
还有更多。%q
(带引号)动词将转义字符串中任何不可打印的字节序列,以便输出是明确的。
fmt.Printf("%q\n", sample)
当字符串的大部分内容可以理解为文本但存在需要查找的特殊情况时,此技术非常实用;它会生成
"\xbd\xb2=\xbc ⌘"
如果我们仔细观察,可以看到在噪声中隐藏了一个 ASCII 等号,以及一个普通空格,最后出现了著名的瑞典“兴趣点”符号。该符号的 Unicode 值为 U+2318,由空格(十六进制值为 20
)后的字节(UTF-8 编码):e2
8c
98
编码。
如果我们不熟悉或对字符串中的奇怪值感到困惑,我们可以对 %q
动词使用“加号”标志。此标志导致输出不仅转义不可打印的序列,还转义所有非 ASCII 字节,同时解释 UTF-8。结果是,它会显示正确格式化的 UTF-8 中非 ASCII 数据的 Unicode 值,这些数据表示字符串中的非 ASCII 数据
fmt.Printf("%+q\n", sample)
使用该格式,瑞典符号的 Unicode 值显示为 \u
转义符
"\xbd\xb2=\xbc \u2318"
这些打印技术在调试字符串内容时非常有用,并且在接下来的讨论中也会派上用场。还值得指出的是,所有这些方法对字节切片的行为与对字符串的行为完全相同。
以下是我们列出的所有打印选项,以您可以直接在浏览器中运行(和编辑)的完整程序的形式呈现
package main import "fmt" func main() { const sample = "\xbd\xb2\x3d\xbc\x20\xe2\x8c\x98" fmt.Println("Println:") fmt.Println(sample) fmt.Println("Byte loop:") for i := 0; i < len(sample); i++ { fmt.Printf("%x ", sample[i]) } fmt.Printf("\n") fmt.Println("Printf with %x:") fmt.Printf("%x\n", sample) fmt.Println("Printf with % x:") fmt.Printf("% x\n", sample) fmt.Println("Printf with %q:") fmt.Printf("%q\n", sample) fmt.Println("Printf with %+q:") fmt.Printf("%+q\n", sample) }
[练习:修改上面的示例,使用字节切片而不是字符串。提示:使用转换创建切片。]
[练习:使用 %q
格式循环遍历字符串中的每个字节。输出告诉你什么?]
UTF-8 和字符串字面量
正如我们所看到的,索引字符串会产生其字节,而不是其字符:字符串只是一堆字节。这意味着当我们在字符串中存储字符值时,我们存储的是其逐字节表示。让我们看一个更受控的示例,以了解它是如何发生的。
这是一个简单的程序,它以三种不同的方式打印一个包含单个字符的字符串常量,一次作为普通字符串,一次作为仅 ASCII 的带引号字符串,一次作为十六进制的单个字节。为了避免任何混淆,我们创建了一个“原始字符串”,用反引号括起来,因此它只能包含文字文本。(用双引号括起来的常规字符串可以包含我们上面显示的转义序列。)
func main() { const placeOfInterest = `⌘` fmt.Printf("plain string: ") fmt.Printf("%s", placeOfInterest) fmt.Printf("\n") fmt.Printf("quoted string: ") fmt.Printf("%+q", placeOfInterest) fmt.Printf("\n") fmt.Printf("hex bytes: ") for i := 0; i < len(placeOfInterest); i++ { fmt.Printf("%x ", placeOfInterest[i]) } fmt.Printf("\n") }
输出为
plain string: ⌘
quoted string: "\u2318"
hex bytes: e2 8c 98
这提醒我们,Unicode 字符值 U+2318,“兴趣点”符号 ⌘,由字节 e2
8c
98
表示,这些字节是十六进制值 2318 的 UTF-8 编码。
根据您对 UTF-8 的熟悉程度,它可能是显而易见的,也可能是微妙的,但值得花点时间解释字符串的 UTF-8 表示是如何创建的。简单的事实是:它是在编写源代码时创建的。
Go 语言的源代码定义为 UTF-8 文本;不允许使用其他表示形式。这意味着,当我们在源代码中编写文本时
`⌘`
用于创建程序的文本编辑器将符号 ⌘ 的 UTF-8 编码放入源文本中。当我们打印出十六进制字节时,我们只是转储编辑器放在文件中的数据。
简而言之,Go 语言源代码是 UTF-8,因此字符串字面量的源代码是 UTF-8 文本。如果该字符串字面量不包含转义序列(原始字符串不能包含转义序列),则构造的字符串将保存引号之间的确切源文本。因此,根据定义和构造,原始字符串将始终包含其内容的有效 UTF-8 表示。类似地,除非包含破坏 UTF-8 的转义符(如上一节中的转义符),否则常规字符串字面量也将始终包含有效的 UTF-8。
有些人认为 Go 语言字符串始终是 UTF-8,但事实并非如此:只有字符串字面量是 UTF-8。正如我们在上一节中所示,字符串值可以包含任意字节;正如我们在本节中所示,字符串字面量始终包含 UTF-8 文本,只要它们没有字节级转义符。
总而言之,字符串可以包含任意字节,但当从字符串字面量构造时,这些字节(几乎总是)是 UTF-8。
代码点、字符和符文
到目前为止,我们在使用“字节”和“字符”这两个词时非常谨慎。这部分是因为字符串保存字节,部分是因为“字符”的概念有点难以定义。Unicode 标准使用术语“代码点”来指代由单个值表示的项目。代码点 U+2318,十六进制值为 2318,表示符号 ⌘。(有关该代码点的更多信息,请参阅 其 Unicode 页面。)
为了选择一个更通俗易懂的示例,Unicode 代码点 U+0061 是小写拉丁字母“A”:a。
但是,对于带重音符的小写字母“A”,à 呢?它是一个字符,也是一个代码点(U+00E0),但它还有其他表示形式。例如,我们可以使用“组合”重音符代码点 U+0300,并将其附加到小写字母 a(U+0061)上,以创建相同的字符 à。通常,一个字符可以用许多不同的代码点序列表示,因此可以用不同的 UTF-8 字节序列表示。
因此,在计算中,“字符”的概念模棱两可,或者至少令人困惑,因此我们谨慎使用它。为了使事情变得可靠,存在一些规范化技术,可以保证给定的字符始终由相同的代码点表示,但这个主题现在让我们偏离了主题太远。以后的博文将解释 Go 库如何处理规范化。
“代码点”这个词有点拗口,因此 Go 引入了一个更短的术语来表示这个概念:rune。这个术语出现在库和源代码中,与“代码点”完全相同,并增加了一个有趣的方面。
Go 语言将单词rune
定义为类型int32
的别名,因此程序可以清楚地表明整数值表示代码点。此外,您可能认为的字符常量在 Go 中称为rune 常量。表达式的类型和值
'⌘'
是rune
,整数值为0x2318
。
总而言之,以下是要点
- Go 源代码始终为 UTF-8。
- 字符串包含任意字节。
- 字符串字面量(没有字节级转义)始终包含有效的 UTF-8 序列。
- 这些序列表示 Unicode 代码点,称为 runes。
- Go 不保证字符串中的字符是规范化的。
范围循环
除了 Go 源代码是 UTF-8 的公理细节之外,Go 实际上只有一种特殊处理 UTF-8 的方式,那就是在字符串上使用for
range
循环时。
我们已经看到了使用常规for
循环时会发生什么。相比之下,for
range
循环在每次迭代时都会解码一个 UTF-8 编码的 rune。每次循环时,循环的索引都是当前 rune 的起始位置(以字节为单位),代码点是其值。这是一个使用另一个方便的Printf
格式%#U
的示例,它显示了代码点的 Unicode 值及其打印表示形式
const nihongo = "日本語" for index, runeValue := range nihongo { fmt.Printf("%#U starts at byte position %d\n", runeValue, index) }
输出显示每个代码点如何占用多个字节
U+65E5 '日' starts at byte position 0
U+672C '本' starts at byte position 3
U+8A9E '語' starts at byte position 6
[练习:将无效的 UTF-8 字节序列放入字符串中。(如何操作?)循环的迭代会发生什么?]
库
Go 的标准库为解释 UTF-8 文本提供了强大的支持。如果for
range
循环不足以满足您的需求,那么您需要的功能很可能由库中的某个包提供。
最重要的此类包是unicode/utf8
,其中包含用于验证、分解和重新组装 UTF-8 字符串的辅助例程。这是一个等效于上述for
range
示例的程序,但使用该包中的DecodeRuneInString
函数来完成工作。函数的返回值是 rune 及其在 UTF-8 编码字节中的宽度。
const nihongo = "日本語" for i, w := 0, 0; i < len(nihongo); i += w { runeValue, width := utf8.DecodeRuneInString(nihongo[i:]) fmt.Printf("%#U starts at byte position %d\n", runeValue, i) w = width }
运行它以查看它是否执行相同的操作。for
range
循环和DecodeRuneInString
被定义为生成完全相同的迭代序列。
查看unicode/utf8
包的文档,以了解它提供的其他功能。
结论
为了回答开头提出的问题:字符串由字节构建而成,因此索引它们会产生字节,而不是字符。字符串甚至可能不包含字符。事实上,“字符”的定义是模棱两可的,试图通过定义字符串由字符组成来解决这种模棱两可将是一个错误。
关于 Unicode、UTF-8 和多语言文本处理的世界,还有很多话要说,但可以留到以后的博文中再说。现在,我们希望您能更好地理解 Go 字符串的行为方式,以及尽管它们可能包含任意字节,但 UTF-8 仍然是其设计中的核心部分。
下一篇文章:Go 的四年
上一篇文章:数组、切片(和字符串):'append' 的机制
博客索引