Go 博客

Go 中的字符串、字节、rune 和字符

Rob Pike
2013 年 10 月 23 日

引言

上一篇博文解释了 Go 中切片(slice)的工作原理,并使用许多示例说明了其实现的机制。在此基础上,本文讨论 Go 中的字符串。乍一看,字符串似乎是一个过于简单的话题,不值得写一篇博文,但要用好字符串,不仅需要理解它们的工作原理,还需要理解字节(byte)、字符(character)和 rune 之间的区别,Unicode 和 UTF-8 之间的区别,字符串(string)和字符串字面量(string literal)之间的区别,以及其他更细微的区别。

探讨这个话题的一种方式是将其视为回答常见问题:“当我通过索引 n 访问 Go 字符串时,为什么我没有得到第 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 字符,直接打印字符串会产生难看的输出。简单的 print 语句

    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。结果是它会显示字符串中表示非 ASCII 数据的、格式正确的 UTF-8 的 Unicode 值

    fmt.Printf("%+q\n", sample)

使用该格式,瑞典符号的 Unicode 值显示为一个 \u 转义序列

"\xbd\xb2=\xbc \u2318"

这些打印技术在调试字符串内容时非常有用,并且在接下来的讨论中会派上用场。值得指出的是,所有这些方法对于字节切片和字符串的行为完全相同。

以下是将列出的所有打印选项,以一个完整的程序形式呈现,您可以在浏览器中直接运行(和编辑)


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.


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 字符的带引号字符串,一次以十六进制形式表示单个字节。为了避免混淆,我们创建了一个用反引号括起来的“原始字符串”(raw string),这样它只能包含字面文本。(像上面所示,用双引号括起来的普通字符串可以包含转义序列。)


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import "fmt"


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 编码的。

码点(Code points)、字符(characters)和 rune

到目前为止,我们在使用“byte”(字节)和“character”(字符)这两个词时非常谨慎。这部分是因为字符串存储的是字节,部分是因为“character”这个概念有点难以定义。Unicode 标准使用术语“code point”(码点)来指代由单个值表示的项。码点 U+2318,十六进制值为 2318,代表符号 ⌘。(有关该码点的更多信息,请参阅其 Unicode 页面。)

举一个更通俗的例子,Unicode 码点 U+0061 是拉丁小写字母 ‘A’:a。

但是带有重音符号的拉丁小写字母 ‘A’,à 又如何呢?它是一个字符,也是一个码点(U+00E0),但它还有其他表示形式。例如,我们可以使用“组合用”重音符号码点 U+0300,并将其附加到小写字母 a(U+0061)后面,从而创建相同的字符 à。一般来说,一个字符可能由多种不同的码点序列表示,因此也可能由不同的 UTF-8 字节序列表示。

因此,计算中的字符概念是模糊的,或者至少是令人困惑的,所以我们谨慎使用它。为了使事情可靠,有一些规范化技术可以保证给定的字符始终由相同的码点表示,但这个主题目前会让我们离题太远。后续的博文将解释 Go 标准库如何处理规范化。

“Code point”(码点)这个词有点拗口,所以 Go 为这个概念引入了一个更短的术语:rune。这个术语出现在标准库和源代码中,其含义与“code point”完全相同,但有一个有趣的补充。

Go 语言将 rune 定义为 int32 类型的一个别名,这样程序就可以清楚地知道一个整数值代表一个码点。此外,你可能认为是字符常量(character constant)的东西在 Go 中称为rune 常量(rune constant)。表达式

'⌘'

的类型是 rune,整数值为 0x2318

总结一下,以下是重点:

  • Go 源代码始终是 UTF-8 编码的。
  • 字符串存储任意字节。
  • 字符串字面量,在没有字节级别转义的情况下,始终包含有效的 UTF-8 序列。
  • 这些序列代表 Unicode 码点,称为 rune。
  • Go 中不对字符串中的字符进行规范化提供保证。

Range 循环

除了 Go 源代码是 UTF-8 这一公理般的细节外,Go 处理 UTF-8 的特殊方式实际上只有一种,那就是在使用 for range 循环遍历字符串时。

我们已经看到了普通 for 循环的情况。相比之下,for range 循环在每次迭代时解码一个 UTF-8 编码的 rune。每次循环时,循环的索引是当前 rune 的起始位置(以字节为单位),而值是它的码点。这是一个示例,使用了另一个方便的 Printf 格式 %#U,它显示了码点的 Unicode 值及其打印表示形式


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import "fmt"

func main() {

    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 编码字节中的宽度。


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
    "unicode/utf8"
)

func main() {

    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' 的机制
博客索引