go中的strings, bytes, runes 和 characters

1,859 阅读12分钟

前前言

本文翻译自https://blog.golang.org/strings,有些地方可能翻译的不好,如果有疑问欢迎提出,我一定会给出解答的。

前言

前一篇博客中介绍了go中的切片,并通过许多例子来介绍切片实现方式背后的机理。基于此背景,这篇文章主要介绍go中的 string 。虽然专门用一篇博客来介绍string似乎有点小题大做,但是正确的使用 string 不仅仅要求我们懂得它们是如何工作的,也要求我们理解字节(byte)、字符(character)和 rune 之间的区别, Unicode 和 UTF-8 的区别,以及字符串和字符串字面量的区别。

一个比较好的了解此主题的方式是将此主题作为一个经常被问起的的问题的回答,"在使用索引来获取字符串在第n处的元素的时候,为什么获得的不是第 n 个字符?"。正如你将要看到的一样,此问题将引导我们了解当今世界更多关于文本是如何运作的细节。

什么是 string

我们来从一些基本的知识开始。

在 go 中,string 实际上是一个只读的字节切片。此文将假设你已经知道了字节切片是什么或者它是如何工作的。如果不知道可以阅读前一篇博客

在正式介绍之前,有必要直接的提出,string 是可以包含任意字节的。string 并不是非得包含 unicode 文本,UTF-8 文本或者其他定义好的文本格式。对于 string 的内容来说,仅仅是等同于字节切片。

下面是一个字符串(后面会进一步的介绍),使用了 \xNN 来表示 string 常量包含一些特定的字节值。(当然,字节包含了从 00 到 FF 的所有16进制的值)

const sample = "\xbd\xb2\x3d\xbc\x20\xe2\x8c\x98"

打印 strings

由于 sample 字符串中包含的部分字节不是有效的 ASCII,甚至不是有效的 UTF-8,所以打印的时候会是乱码。打印语句如下

fmt.Println(sample)

打印的乱码如下(打印的结果会根据环境的变化会有所不同)

��=� ⌘

为了找出 string 包含的到底是什么,我们可以将其拆分并分别查看。有许多方式可以这样做。最简单的是遍历其内容,单独的打印每个字节,正如下面的for循环代码如下

for i := 0; i < len(sample); i++ {
        fmt.Printf("%x ", sample[i])
}

正如之前提到的一样,通过索引获取 string 中的元素,获得的是字节,而不是字符。我们将在后面回归主题,此时我们仅仅关注字节。下面的输出是一个一个字节遍历的结果

bd b2 3d bc 20 e2 8c 98

单独打印的字节和字符串字面量中16进制转义的字节相匹配。

对于一个乱码的字符串,可以在fmt.Printf中使用%x的格式打印字符串16进制格式。它把字符串中的字节序列,以两个字节为一个16进制数的格式,进行打印输出。

fmt.Printf("%x\n", sample)

可以将如下打印的结果和上述打印的结果进行对比

bdb23dbc20e28c98

如果想要输出一样,一个简单的技巧就是格式化的时候,在 % 和 x 之前使用"space"符号。其使用的语句如下

fmt.Printf("% x\n", sample)

打印的结果中每两个字节之间都会有一个空格,使得输出的结果好理解点:

bd b2 3d bc 20 e2 8c 98

当然,还有跟多的打印格式。比如%q(quoted)可以转义字符串中任何不能打印的字节,所以输出会比较清晰,不会乱码。打印的语句如下

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

在字符串中的只有少部分字节组成的是乱码的时候,需要不打印乱码,这个时候可以使用%q的打印方式。其输出如下

"\xbd\xb2=\xbc ⌘"

通过仔细查看打印的结果我们可以知道,隐藏在乱码之中的是一个 ASCII 码的等于号,一个是空格符号,还有一个在末尾出现的众所周知的瑞典符号 "Place of Interest"。其对应的 Unicode 编码为 U+2318,在空格(16进制的值是20)之后就是其被编码为 UTF-8 的字节: e2 8c 98

如果我们对 string 中的奇怪的值不熟悉或者感到迷惑,我们可以在%q中添加一个"+"的符号,此符号不仅转义不可打印的字节序列,也转义非 ASCII 的字节序列,在此同时也会解释 UTF-8。打印的结果就是字符串中包含 非 ASCII 的UTF-8 编码的表示的 Unicode 字符。

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

使用此格式,瑞典符号的 Unicode 值被 \u所转义:

"\xbd\xb2=\xbc \u2318"

这些打印技巧在我们查看 string 内容的时候是非常有用的,并且也会在接下来的讨论中经常使用。非常值得专门提出的就是这些方法对于字节切片的表现和字符串的相同。

下面就是上面使用过的全部打印选项,这是一个完成的程序,所以你可以在网站中直接编辑和运行。

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

UTF-8 和 字符串

正如我们看到的一样,对字符串使用索引操作得到的是字节,而不是字符:字符串就是一系列字节。这意味着,我们将字符存储在字符串中是时候,我们存储的是表示此字符的字节。我们可以通过一个更为可控的例子来查看到底发生了什么。

这是一个简单的打印一个只包含一个字符的字符串常量的三种不通过方法的程序,一次是作为一个普通的字符串、一次是只打印ASCII码的字符串,一次是打印16进制的字节。为了避免任何迷惑,我们创建一个由反引号包裹的"raw string",所以它包含的就是字面文本。(通常 string 由双引号包括,可以包含如上一节介绍的转义字符串)。

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,就是 "Place of interest" 符号,在go中是由字节 e2 8c 98所表示。这些字节就是此符号由 UTF-8 编码结果的16进制结果。

你对UTF-8的是否熟悉,决定了此输出结果对于你来说是显而易见的,还是十分精巧的。但是这个点仍然值得专门拿出时间来解释 UTF-8 编码的字符串是如何创建的。简单的事实就是:它就在源码被完成的时刻进行了这种转换。

go的源码是 UTF-8 编码的文本,不允许其他的编码方式。这就解释了,当我们在源码中写下了如下文本的时候

`⌘`

我们所使用的编辑器就会将 UTF-8 编码的写到源码文本中去。当我们打印16进制的字节的时候,我们仅仅是把编辑器放在文件中的数据进行输出。

简单的来说,Go 源码就是 UTF-8 编码的,所以源码中的的字符串就是 UTF-8 编码的文本。当一个字符串中不包含转义字符的时候,此字符串就是和源码中的一样。因此根据定义和字符串的构建方式(译者注:刚才所说的字符串在编辑器中是如何存储的),raw string 的内容都是由一个有效的 UTF-8 编码。同样的,除非它包含像前面例子一样的非 UTF-8 编码的内容,否则通常 string 的内容都是有效的 UTF-8 编码的内容。

(此位置删除了一句话,个人觉得会造成误解,想看的可以查看原文)正如我们前面部分所提到的,string 的内容可以使任意的字节序列。我在此部分也展示了, 字符串字面量在不包含字节层面的转义符号的时候都是 UTF-8 编码的字符串。

总而言之,字符串底层可以使任意字节序列,但是当我们通过字符串字面量创建字符串的时候,这些字节就是UTF-8编码的结果。

code points,characters,runes

我们到此为止都非常小心使用"字节"和"字符"。这么做的一部分原因是字符串包含字节, 另外一个部分原因是"字符"的定义有点困难。Unicode 标准使用术语"code point"来表示一个单独的值。所以 U+2318,由16进制的值2318,表示的是

一个更简单的例子就是,Unicode 中的拉丁字母'A'对应的小写'a'的code point是U+0061。

但是字母'A'的对应的带有重音的小写字母'à'又应该如何表示呢?这是一个字符,它的code point 表示是 (U+00E0),但是它也有其他的表示方法。比如说我们可以组合重音符号(U+0300)和小写字母a(U+0061)来表示字母'à'。总的来说,一个字符可以由多个不同的 code point 来进行表示,因而也就有了不同的UTF-8的字节序列。

因而计算中的字符概念就有点含糊了,至少是有歧义的,所以我们需要小心的使用。为了使得字符变得可靠,有些***正常化***的技术可以保证一个指定的字符是由同样的 code point 所表示,但是这个主题对于本文的主题偏离的有点远。之后会有一个博客来介绍 Go 中的库是如何解决正常化的。

"Code point"是有一点拗口的,所以 Go 引入一个更短的术语: rune。此术语出现在 Go 的库和源码中,意义除了对"code point"做了一个有趣的添加外,其他一致。

Go 中把 rune 定义为一个 int32 的类型,所以程序通过一个整型来表示 code point 的时候显的更加清晰。此外,在go中一个字符常量就是一个 rune 类型的常量。下面表达式的类型是 rune, 值是一个整型0x2318。

'⌘'

总的来说有下面的几个要点:

  1. Go的源码是UTF-8编码的
  2. string 可以包含任意的字节
  3. 一个不包含字节层面转义的字符串常面量包含的都是有效的UTF-8编码的序列
  4. 表示Unicode中的 code points的序列称之为 runes
  5. go中不能保证字符串中的字符是正规化的

Range loops

除了Go中的源码是UTF-8编码的,go对待UTF-8编码只有在遍历字符串的时候表现的特别。

我们已经看到了遍历字符串的时候发生了什么。作为一个对比,一个for range的循环,会对每个UTF-8编码的rune进行解码输出。loop 中的每次取值,会返回当前rune的其实位置和底层的bytes,并且此时的code point就是其值。下面是一个会使用过另外一种Printf的打印格式%#U,可以展示Unicode 中的code point 和其打印的结果

const nihongo = "日本語"
for index, runeValue := range nihongo {
  fmt.Printf("%#U starts at byte position %d\n", runeValue, index)
}

打印的结果表示每个code point都占有多个byte:

U+65E5 '日' starts at byte position 0
U+672C '本' starts at byte position 3
U+8A9E '語' starts at byte position 6

Libraries

Go 的标准库提供了对解释 UTF-8 文本的强有力的支持。如果一个for range的循环对于你的目的来说并不充分,你可以使用此库提供的工具。

最终要的库就是 unicode/utf8,可以对UTF-8编码的字符串进行验证、拆解、和组合。下面的例子实现的效果和for 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\n", runeValue, i)
  w = width
}

Conclusion

文中一开始就提出的问题回答是,字符串是由字节组成,所以索引取的值是字节,而不是字符。字符串中甚至可以不包含字符。实际上,字符的定义有点模糊,通过字符串是由字符组成的定义来解释歧义是一个错误。

对于Unicode,UTF-8,多种语言的处理,还有许多要说的,但是这些可以在其他的博客中再做介绍。此时,希望你可以对 go 中的 string 是如何运作的有一个更好的了解,并且即使一个字符串可能包含任意的字节,UTF-8编码仍然是字符串设计的核心。