Go 博客

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

Rob Pike
2013 年 10 月 23 日

引言

上一篇 博文 解释了 Go 语言中 slice 的工作原理,并通过多个示例说明了其实现机制。基于此背景,本文将讨论 Go 语言中的字符串。起初,字符串可能显得过于简单,不至于需要一篇博文来讲解,但要用好它们,就需要理解它们的工作原理,以及字节、字符和 rune 之间的区别,Unicode 和 UTF-8 之间的区别,字符串和字符串字面量之间的区别,以及其他更细微的差别。

理解这个主题的一种方法,可以将其视为对一个常见问题的回答:“为什么当我索引 Go 字符串的第 n 个位置时,得不到第 n 个字符?” 正如你将看到的,这个问题会引出许多关于现代世界中文本如何工作的细节。

一个极好的介绍这些问题的独立于 Go 的资源,是 Joel Spolsky 的著名博文 《每个软件开发人员绝对、肯定必须知道的关于 Unicode 和字符集的最低限度(无借口!)》。他提出的许多观点在这里也会被提及。

什么是字符串?

让我们从一些基本概念开始。

在 Go 语言中,字符串本质上是一个只读的字节 slice。如果你对字节 slice 是什么或它是如何工作的还不确定,请阅读 上一篇博文;我们这里将假设你已阅读。

需要一开始就明确的是,字符串包含任意字节。它不一定包含 Unicode 文本、UTF-8 文本或任何其他预定义格式。就字符串的内容而言,它与字节 slice 完全等价。

这是一个字符串字面量(稍后会详细介绍),它使用了 `\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。结果是,它会暴露字符串中表示非 ASCII 数据的、格式正确的 UTF-8 的 Unicode 值

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

使用这种格式,瑞典符号的 Unicode 值会显示为 `\u` 转义码

"\xbd\xb2=\xbc \u2318"

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

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


// 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)
}

[练习:修改上面的示例,使用字节 slice 代替字符串。提示:使用转换来创建 slice。]

[练习:使用 `%q` 格式循环遍历字符串中的每个字节。输出告诉你什么?]

UTF-8 和字符串字面量

正如我们所见,索引字符串会得到其字节,而不是其字符:字符串只是字节的集合。这意味着当我们把一个字符值存储在字符串中时,我们存储的是它的逐字节表示。让我们看一个更受控的例子,看看它是如何发生的。

这是一个简单的程序,它以三种不同的方式打印一个包含单个字符的字符串常量:一次作为普通字符串,一次作为仅 ASCII 的带引号字符串,一次作为十六进制的单个字节。为避免任何混淆,我们创建了一个“原始字符串”,用反引号括起来,因此它只能包含字面文本。(如上所示,用双引号括起来的常规字符串可以包含转义序列。)


// 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。

码点、字符和 rune

到目前为止,我们在使用“字节”和“字符”这两个词时非常谨慎。这部分是因为字符串包含字节,部分是因为“字符”这个概念有点难以定义。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 码点,称为 rune。
  • Go 语言不保证字符串中的字符是规范化的。

Range 循环

除了 Go 源代码是 UTF-8 的基本细节之外,Go 语言真正特殊处理 UTF-8 的方式只有一种,那就是在使用字符串的 `for range` 循环时。

我们已经看到了常规 `for` 循环的情况。相比之下,`for range` 循环在每次迭代时都会解码一个 UTF-8 编码的 rune。每次循环时,循环的索引是当前 rune 的起始位置(以字节为单位),而 code point 是其值。这里有一个例子,使用了另一个方便的 `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 的机制
博客索引