18

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

 4 years ago
source link: https://studygolang.com/articles/26550
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

以下文章翻译自罗伯·派克发表在Go Blog的文章,文章中为读者详述了Go语言中字符串与我们经常提起的字节、字符还有rune的关系和相互之间的不同。正如派克在文中所说

字符串这个话题对于一篇博客文章来说似乎太简单了,但是要很好地使用它们,不仅需要了解它们的工作原理,还需要了解字节,字符和 rune 的区别,以及 Unicode 和 UTF- 8,字符串和字符串直接量之间的区别,以及其他甚至更细微的区别。

原文地址: https://blog.golang.org/strings

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

rune

原文的语法、句式都很好学习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 表示形式是如何被创建。一个简单的事实是:它是在编写源代码时创建的。

Go 中的源代码被 定义 为 UTF-8 文本;其他字符串表示形式是不被循序的。这意味着当我们在源代码中编写文本时

⌘`

用于创建程序的文本编辑器将符号⌘的 UTF-8 编码放入源文本中。当我们打印出十六进制字节时,我们只是在输出了编辑器放置在源码文件中的数据。

简而言之,Go 源代码为 UTF-8 编码格式的,源代码中的字符串直接量是 UTF-8 文本。如果字符串直接量不包含转移字符序列,就像原始字符串一样,则构造的字符串将精确地保留引号之间的源文本。因此,根据定义和构造,原始字符串将始终包含其内容的有效 UTF-8 表示形式。同样,除非它包含上一节中提到的转义符,否则常规字符串文字也将始终包含有效的 UTF-8 文本。

有人认为 Go 字符串始终是 UTF-8 编码格式的,但不是:只有字符串直接量才始终是 UTF-8 的。如上一节所示,字符串 可以包含任意字节;就像我们在本文中所展示的那样,字符串 literal 只要不包含字节级转义符,就始终包含 UTF-8 文本。

总而言之,字符串可以包含任意字节,但是从字符串直接量构造字符串时,这些字节 (几乎总是) 是 UTF-8 的。

码点,字符和 rune

到目前为止,我们在使用 “字节” 和 “字符” 这两个词时都非常小心。部分原因是字符串包含字节,部分原因是 “字符” 的概念很难定义。 Unicode 标准使用术语 “码点” 来指代由单个 Unicode 值表示的个体。具有十六进制值 2318 的码点 U + 2318 表示符号⌘。 (有关该码点的更多信息,请参见 其 Unicode 页面 。)

译者注:⌘是一个 Unicode 码点,其 Unicode 值是 U2318

举一个比较平淡的例子,Unicode 代码点 U + 0061 是小写拉丁字母 'A':

但是小写的带有重音符号的字母 'A' 怎么办?这是一个字符,它也是一个代码点 (U + 00E0),但是它还有其他表示形式。例如,我们可以使用 “组合” 重音符号代码点 U + 0300,并将其附加到小写字母 a,U + 0061,以创建相同的字符 à。通常,字符可以由许多不同的代码点序列表示,因此也可以由 UTF-8 字节的不同序列表示。

因此,计算中的字符概念是模棱两可的,或者至少是令人困惑的,因此我们谨慎使用它。为了使事情变得可靠,有 标准化 技术保证给定字符始终由相同的代码点表示,但该主题目前离我们这篇博客的主题太远了。稍后的博客文章将解释 Go 库如何解决规范化。

“码点” 有点冗长,因此 Go 为该概念引入了一个较短的术语: rune 。该术语出现在库和源代码中,其含义与 “码点” 完全相同。

Go 语言将单词 rune 定义为类型 int32 的别名,因此当整数值表示码点时,程序会很清晰。此外,你可能会认为是字符常量的常量在 Go 中称为 rune 常量 。下面表达式的类型和值

'⌘'

rune ,它的整数值为 0x2318

总结一下,这是要点:

rune

Range 循环

除了关于 Go 源代码为 UTF-8 的细节外,Go 确实有且只有一种特别对待 UTF-8 的方式,那就是在字符串上使用 for range 循环时。

我们已经看到了常规 for 循环会发生什么。相比之下, range 循环在每次迭代中会解码一个 UTF-8 编码 rune。每次循环时,循环的索引都是当前 rune 的起始位置 (以字节为单位),码点是其值。这是使用另一个方便的 Printf 格式化占位符 %#U 格式化字符串的示例,该格式话输出显示了码点的 Unicode 值及其打印表示形式:

const nihongo = "日本語"
for index, runeValue := range nihongo {
    fmt.Printf("%#U starts at byte position %d.", 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 文本提供了强大的支持。如果用于 range 循环的 ` 不足以满足您的目的,则库中的软件包可能会提供您需要的功能。

最重要的软件包是 unicode / utf8 ,其中包含用于验证,插解和重新组装 UTF-8 字符串的帮助程序。这是一个相当于上面 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.", runeValue, i)
    w = width
}

运行它以查看其执行相同的操作。 range 循环和普通循环中使用 DecodeRuneInString 会产生完全相同的迭代序列。

请查看 文档 中的 unicode / utf8 软件包,以了解它提供了哪些其他功能。

结论

现在回答开始时提出的问题:字符串是由字节构建的,因此对它们进行索引将生成字节,而不是字符。字符串甚至可能不包含字符。实际上,“字符” 的定义是模棱两可的,试图通过定义字符串是由字符组成这种说法来解决歧义是错误的。

关于 Unicode,UTF-8 和多语言文本处理还有很多话要说,但是它可以等待下一篇文章。现在,我们希望你对 Go 字符串的行为有更好的了解,尽管它们可能包含任意字节,但 UTF-8 是其设计的核心部分。

关注下方公众号第一时间获取推送,近期文章推荐:

深入学习用Go编写HTTP服务器

五分钟用Docker快速搭建Go开发环境

十分钟学会用Go编写Web中间件

IfIVzaJ.png!web


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK