本文翻译自《Strings, bytes, runes and characters in Go》。
Rob Pike
2013/08/23
介绍
上一篇博客文章解释了切片(slice)在Go中的工作方式,并使用了一些示例来说明它背后的机制。在此背景下,本文将讨论Go中的字符串。起初,字符串对于一篇博客文章来说可能太简单了,但想要很好地使用它们,不仅需要了解它们是如何工作的,还需要了解字节、字符(character)和符文(rune)之间的区别,Unicode和UTF-8之间的区别、字符串和字符串字面量(string literal)之间的区别以及其他更微妙的区别。
编写这个话题的一种方法是,给出常见问题的答案,例如“当我在位置n索引Go字符串时,为什么我得不到第n个字符?”,正如你所看到的,这个问题的答案可以让我们了解文本在现代世界中是如何工作的。
Joel Spolsky的著名博客文章“每个软件开发人员绝对、积极地必须了解的Unicode和字符集”,是对其中一些问题的一个极好的介绍,它独立于Go语言。它提出的许多观点将在这里重复提及。
什么是字符串?
让我们从一些基础知识开始。
在Go中,一个字符串实际上是一个只读字节片。如果你不确定字节片是什么或者它是如何工作的,请阅读上一篇博客文章;我们在这里假设你已阅读。
重要的是要提前声明:一个字符串可以包含任意的字节,不一定是Unicode文本、UTF-8文本或任何其他预定义的格式。就字符串的内容而言,它完全等同于一个字节片。
下面是一个字符串字面量(稍后将详细介绍),它使用\xNN
表示法来定义一个包含一些特殊字节值的字符串常量。(当然,字节的范围从十六进制值00到FF,包括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])
}
正如上面所示,对字符串进行索引访问到的是单个字节,而不是一个个字符(character)。我们将在下面详细讨论这个主题。现在,让我们只使用字节。这是逐字节遍历循环的输出:
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
(带引号)格式将转义字符串中任何不可打印的字节序列,因此输出是明确的。
当字符串的大部分内容可以理解为文本,但也有一些特殊字符需要清除时,这种技巧很方便;对于上文中的字符串,它输出:
"\xbd\xb2=\xbc ⌘"
如果我们注视一下,我们可以看到隐藏在噪音中的是一个ASCII等号和一个普通空格,最后出现了著名的瑞典“兴趣地点(Place of Interest)”符号。该符号的Unicode码值为U+2318,被编码为UTF-8字节:e2 8c 98,位于空格(十六进制值20)之后。
如果我们对字符串中的奇怪字符感到陌生或困惑,我们可以在%q
格式中使用“加号+
”标志。此标志不仅转义不可打印的字节序列,而且转义任何非ASCII字节,都按UTF-8编码来解析。结果是,它打印输出了格式正确的UTF-8编码的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和字符串字面量
正如我们所看到的,对字符串进行索引会返回字节,而不是字符(character):字符串只是一堆字节。这意味着,当我们在字符串中存储一个字符值时,我们是按字节存储它的。让我们看一个更可控的例子,看看这是如何发生的。
这里有一个简单的程序,它用三种不同的方式打印带有单个字符的字符串常量,一种是打印输出纯字符串,一种是只打印输出ASCII字符,还有一种是打印输出十六进制数的单个字节。为了避免混淆,我们创建了一个“原始字符串(raw string)”,用后引号(back quotes)括起来,这样它就只能包含字符串字面量。(用双引号括起来的常规字符串里面可以包含转义字符,如上文所示。但用后引号括起来的原始字符串里面的字符不会被转义。)
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文本。类似地,除非像本文开头示例中的字符串(用\xNN
表示法来定义一个包含一些特殊字节值的字符串字面量)那样包含不能被解析为UTF-8编码的字节序列,否则普通字符串字面量也将始终包含有效的UTF-8文本。
有些人认为Go字符串总是UTF-8文本,但事实并非如此,正如我们在本文开头所展示的,字符串值可以包含任意字节,里面可能包含不能被解析为UTF-8编码的字节序列。
总之,Go字符串可以包含任意字节,但当我们从字符串字面量(非\xNN
表示法)构建字符串时,里面的字节序列(几乎总是)符合UTF-8编码的。
码点(Code point)、字符(character)和rune
到目前为止,我们在使用“字节(byte)”和“字符(character)”这两个词时非常小心。这部分是因为字符串包含字节,部分是因为“字符”的概念有点难以定义。Unicode标准使用术语“码点(code point,也有翻译为‘码值’的)”来指代由单个数字表示的字符。例如码点U+2318,具有十六进制数值2318,表示符号“⌘”。(有关该码点的更多信息,请参阅其Unicode页面。)
举一个更普通的例子,Unicode码点U+0061是小写拉丁字母“a”。
但是小写带重音的字母“à”呢?这也是一个字符,也是一个码点(U+00E0),但它有其他表示形式。例如,我们可以使用“组合”重音码点U+0300,并将其附加到小写字母a(码点是U+0061),来创建相同的字符“à”。通常,一个字符可以由许多不同的码点序列表示,因此也可以编码为不同的UTF-8字节序列。
因此,计算机中的字符(character)的概念是模糊的,或者至少是令人困惑的,所以我们谨慎地使用它。为了使事情变得可靠,有一些规范化的技术可以保证给定的字符总是由相同的码点表示,但这个主题偏离本文的主题太远了。稍后的博客文章将解释Go库如何解决规范化问题。
“码点”这个词有点晦涩难懂,所以Go为这个概念引入了一个较短的术语:rune。这个术语出现在库和源代码中,其含义与“码点”完全相同,还有一个有趣的补充。
Go语言将rune定义为类型int32类型的别名,因此当整数值表示码点时,程序就会很清晰。此外,你可能认为的字符常量在Go中被称为“rune常量”。例如'⌘'
的类型是rune
,值是整数0x2318
。
总之,以下是一些重点:
- Go源代码总是UTF-8文本。
- 字符串可以包含任意字节。
- 字符串字面量,不存在字节级转义字符的话,始终包含有效的UTF-8字节序列。
- 代表Unicode码点的序列,称为rune。
- Go中不能保证字符串中的字符是标准化的。
范围循环
除了Go源代码是UTF-8文本之外,实际上Go还有一个特殊对待UTF-8的地方,那就是在字符串上使用for range循环时。
我们已经看到了普通for循环的情况。相比之下,for range循环在每次迭代中解码一个UTF-8编码的rune。每次循环时,循环的索引是当前rune的起始位置,以字节为单位,循环的值是当前rune的Unicode码点。下面是一个使用另一种方便的Printf
函数的格式%#U
的示例,它显示了rune的Unicode码点的值及其打印输出的字符:
const nihongo = "日本語"
for index, runeValue := range nihongo {
fmt.Printf("%#U starts at byte position %d\n", runeValue, index)
}
输出显示每个Unicode码点如何占用多个字节:
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
,它包含用于验证、反组装(disassemble)和重新组装UTF-8字符串的辅助函数代码。这里有一个与上面的for range示例等效的程序,但使用该包中的DecodeRunInString
函数来完成这项工作。函数的返回值是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循环和DecodeRunInString
函数被定义为生成完全相同的迭代序列。
你可以查看unicode/utf8
包的官方文档,了解它还提供了哪些其他功能。
结论
为了回答开头提出的问题:字符串是从字节构建的,因此对字符串进行索引会产生字节,而不是字符(character)。字符串甚至可能不包含字符(character)。事实上,“字符(character)”这一定义是模糊的,试图通过“定义字符串是由字符组成的”来解决二义性是一种错误的做法。
关于Unicode、UTF-8和多语言文本处理,还有很多话要说,但这应该写成另一篇文章。目前,我们希望你能更好地了解Go字符串的行为,尽管它们可能包含任意字节,但UTF-8是其设计的核心部分。