Go语言之父带你重新认识字符串、字节、rune和字符

文章篇幅还是挺长的,大家时间都很宝贵所以我先把文章探究的问题的结论放在前面,有时间的同学还是建议整篇读一下。

Go 源代码始终为 UTF-8。

字符串可以包含任意字节。

字符串文字中不包含字节级转义符时字符串始终包含有效的 UTF-8 序列。

代表 Unicode 码点的字节序列称为 rune。

在 Go 中不会保证字符串中的字符被规范化。

原文的语法、句式都很好学习Go 语言的同时还能加强一下英文阅读推荐去读英文原文,有翻译不清楚的欢迎指正。

介绍

上一篇博客文章使用许多示例说明了切片在其实现背后的机制,从而说明了切片在 Go 中的工作方式。以此为背景,本文会讨论 Go 中的字符串。一开始会让人觉得,字符串这个话题对于一篇博客文章来说似乎太简单了,但是要很好地使用它们,不仅需要了解它们的工作原理,还需要了解字节,字符和 rune 的区别,以及 Unicode 和 UTF- 8,字符串和字符串直接量之间的区别,以及其他甚至更细微的区别。

展开讨论这个话题的一种方法是将其视为对以下常见问题的解答:“当我索引 Go 字符串时,在 n 个位置为什么没有得到第 n 个字符?” 如您所见,这个问题将我们引向了许多文本在现实世界中是如何工作的细节中。

独立于 Go 语言之外,Joel Spolsky 的著名博客文章绝对绝对是每个软件开发人员绝对绝对肯定地了解 Unicode 和字符集 (无借口!) 很好地介绍了这些问题的细节。他提出的许多观点将在这里进行阐述。

什么是字符串?

让我们从一些基础知识开始。

在 Go 中,字符串实际上是只读的字节切片。如果你完全不知道一个字节切片是什么以及它是如何工作的,请阅读上一篇博客文章 ; 我们在这里假设你已经知道这些。

预先说明字符串可以包含任意字节很重要,字符串没有规定只能包含 Unicode 文本,UTF-8 文本或任何其他预定义格式。就字符串的内容而言,它完全相当于一个字节切片。

下面一个字符串文字 (稍后将进一步介绍),该文字使用.NN 表示法定义了一个包含某些特殊字节值的字符串常量。 (当然,一个字节的范围是十六进制值 00 到 FF)。

const sample =“ .bd.b2.3d.bc.20.e2.8c.98”

打印字符串

由于字符串常量 sample 中的某些字节不是有效的 ASCII,甚至不是有效的 UTF-8,因此直接打印字符串将产生诡异的输出。下面使用简单的打印语句打印 sample

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.”, sample)

将其输出与上面的输出进行比较:

bdb23dbc20e28c98

一个不错的技巧是在格式标记符中使用 “空格” 标志,在%和 x 之间放置一个空格。然后将此处使用的格式字符串与上面的格式字符串进行比较,

fmt.Printf(“% x.”, sample)

注意字节之间留有的空格,从而使结果不那么难以理解:

bd b2 3d bc 20 e2 8c 98

还有一件事。 %q(带引号) 动词将转义字符串中所有不可打印的字节序列,会让输出无歧义。

fmt.Printf(“%q.”, sample)

当字符串的大部分为可理解文本,但有一些特殊的含义可以根除时,这个技巧很方便。它会输出:

“.bd.b2=.bc ⌘”

如果斜视一下,我们可以看到噪声点中隐藏的是一个 ASCII 等号以及一个规则的空格,最后出现了著名的瑞典 “景点” 符号。该符号的 Unicode 值为 U + 2318,由空格后的字节编码为 UTF-8 (十六进制值 20):e2 8c 98。

如果我们不熟悉字符串或对字符串中奇奇怪怪的值感到困惑,可以在%q 动词上使用 “加号” 标志。此标志使输出在解释 UTF-8 时不仅转义不可打印的序列,而且还会转义所有非 ASCII 字节。结果是它输出了格式正确的 UTF-8 的 Unicode 值,该值表示字符串中的非 ASCII 数据:

fmt.Printf(“%+q.”, sample)

使用这种格式时,瑞典符号的 Unicode 值显示为. 转义符:

“.bd.b2=.bc .2318”

在调试字符串的内容时,这些打印技巧会很有用,并且在下面的讨论中使用也会很方便。值得指出的是,所有这些方法对于字节切片的行为与对字符串的行为完全相同。

下面是我们已列出的所有打印选项的全集,以完整的程序形式呈现出来,您可以在浏览器中直接运行 (和编辑):

译注:指的是在 go playground 的浏览器运行环境中。

package main

import “fmt”

func main() {

    const sample = “.bd.b2.3d.bc.20.e2.8c.98”

    fmt.Println(“Println:”)

    fmt.Println(sample)

    fmt.Println(“Byte loop:”)

    for i := 0; i < len(sample); i++ {

        fmt.Printf(“%x “, sample[i])

    }

    fmt.Printf(“.”)

    fmt.Println(“Printf with %x:”)

    fmt.Printf(“%x.”, sample)

    fmt.Println(“Printf with % x:”)

    fmt.Printf(“% x.”, sample)

    fmt.Println(“Printf with %q:”)

    fmt.Printf(“%q.”, sample)

    fmt.Println(“Printf with %+q:”)

    fmt.Printf(“%+q.”, sample)

}

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

[练习:循环遍历字符串在每个字节上使用%q 格式化标记符。看看输出告诉您什么?]

UTF-8和字符串直接量

如我们所见,索引字符串会产生其字节,而不是其字符:字符串只是一堆字节。这意味着,当我们将字符存储在字符串中时,将存储其字节表示。让我们通过一个更容易控制的示例,看看这个过程是如何发生。

下面是一个简单的程序,使用了三种不同的方式打印一个只有一个字符的字符串常量。一次作为普通字符串,一次是用引号括起来的纯 ASCII 字符串,一次是十六进制的单个字节。为避免混淆,我们创建了一个 “原始字符串”,并用反引号将其括起来,因此它只能包含文字文本。 (在上面的例子中我们已经见过,用双引号括起来的常规字符串可以包含转义序列。)

func main() {

    const placeOfInterest = `⌘`

    fmt.Printf(“plain string: “)

    fmt.Printf(“%s”, placeOfInterest)

    fmt.Printf(“.”)

    fmt.Printf(“quoted string: “)

    fmt.Printf(“%+q”, placeOfInterest)

    fmt.Printf(“.”)

    fmt.Printf(“hex bytes: “)

    for i := 0; i < len(placeOfInterest); i++ {

        fmt.Printf(“%x “, placeOfInterest[i])

    }

    fmt.Printf(“.”)

}

输出为:

plain string: ⌘

quoted string: “.2318”

hex bytes: e2 8c 98

这使我们想起 Unicode 字符值 U + 2318,即⌘,由字节 e2 8c 98 表示,并且这些字节是十六进制值 2318 的 UTF-8 编码。

根据你对 UTF-8 的熟悉程度,上面的结果对你来说可能很明显,也可能很微妙,但是这值得花一点时间来解释字符串的 UTF-8 表示形式是如何被创建。一个简单的事实是:它是在编写源代码时创建的。