字节构成的字符串


在〈认识预定义类型〉中略略谈过字符串,表面看来,用双引号(")或反引号(`)括起来的文本就是字符串,默认类型为string,实际在 Go 中,字符串是由只读的 UTF-8 编码字节所组成。

字符串入门

先从简单的开始,在 Go 源码中,如果你编写"Go语言"这么一段文本,那么会产生一个字符串,默认类型为string,字符串是只读的,一旦创建,就无法改变字符串内容。

使用string定义变量若无指定初值,默认是空字符串"",可以使用+对两个字符串进行连接,由于字符串是只读的,因此实际上连接的动作,会产生新的字符串,如果想比较两个字符串的相等性,可以使用==!=<<=>>=依字典顺序比较。

package main

import "fmt"

func main() {
    text1 := "Go语言"
    text2 := "Cool"
    var text3 string
    fmt.Println(text1 + text2) // Go语言Cool
    fmt.Printf("%q\n", text3)  // ""
    fmt.Println(text1 > text2) // true
}

上面的例子中,由于使用fmt.Println显示空字符串时看不到什么,因此改用fmt.Printf,并使用%q来脱离无法显示的字符。

使用""时不可换行,如果你的字符串想要换行,方法之一是分两个字符串,并用+连接。例如:

text := "Go语言" +
            "Cool"

另一个方式是以重音符 ` 定义字符串,例如:

package main

import "fmt"

func main() {
    text := `Go语言
                 Cool`
    fmt.Printf("%q\n", text) // "Go语言\n                  Cool"
}

使用 ` 定义的字符串,会完全保留换行与空白,因此,在上头你可以看到被保留的换行与空白字符,如果使用fmt.Println(text),显示时也会看到对应的换行与空白。使用 ` 定义的字符串,也不会转译字符,例如:

package main

import "fmt"

func main() {
    text := `Go语言\nCool`
    fmt.Println(text)  // Go语言\nCool
}

在这个例子中可以看到,使用 ` 时,不会对\n做转译的动作,因此,你会直接看到显示了「Go语言\nCool」。

在 Go 中可以使用的转译有:

  • \a:U+0007,警告或响铃
  • \b:U+0008,倒退(backspace)
  • \f:U+000C,馈页(form feed)
  • \n:U+000A,换行(newline)
  • \r:U+000D,归位(carriage return)
  • \t:U+0009,水平 tab
  • \v:U+000b,垂直 tab
  • \\:U+005c,反斜线(backslash)
  • \":U+0022,双引号
  • \ooo:字节表示,o 为八进制数字
  • \xhh:字节表示,h 为十六进制数字
  • \uhhhh:Unicode 点点表示,使用四个 16 进制数字
  • \Uhhhhhhhh:Unicode 点点表示,使用八个 16 进制数字

只读字节片段

那么,想知道一个字符串的长度该怎么做呢?Go 中有个len函数,当它作用于字符串时,结果可能会令一些从其他程序语言,像是 Java 过来的人感到讶异:

package main

import "fmt"

func main() {
    fmt.Println(len("Go语言")) // 8
}

显示的结果是 8 而不是 3,给个提示,Go 的字符串实现使用 UTF-8,是的!len返回的是字节长度,因为 Go 的字符串,本质上是 UTF-8 编码后的字节组成,如果你使用fmt.Printf("%x", "Go语言"),会显示 476fe8aa9ee8a880,47 是「G」的字节以 16 进制数字表示的结果,6f 是 o,e8aa9e 是「语」的三个字节分别以 16 进制数字表示的结果,e8a880 是「言」。

不单是如此,Go 中可以使用[]与索引来获取字符串的字节数据,是的,字节!返回的类型是byteuint8),"Go语言"[0]获取的是 G 的字节数据,"Go语言"[1]获取的是 o 的字节数据,"Go语言"[2]呢?获取的是「语」的 UTF-8 实现中,第一个字节数据,也就是 e8。可以用以下这个程序片段来印证:

package main

import "fmt"

func main() {
    text := "Go语言"
    for i := 0; i < len(text); i++ {
        fmt.Printf("%x ", text[i])
    }
}

虽然还没正确介绍for循环,不过程序应该很清楚,用循环递增的i值来获取指定索引处的数据,结果是显示「47 6f e8 aa 9e e8 a8 80 」。

这个字节序列是怎么决定的?当你写下"Go语言",你的 .go 源码文件是什么编码呢?是的!UTF-8,Go 就是从这当中获取"Go语言"字节序列,每个字节就是 UTF-8 的一个码元(code unit)。

虽说字符串是只读的字节片段,不过,实际的字节是隐藏在字符串底层,如果你想获取,必须转为[]byte,例如:

package main

import "fmt"

func main() {
    text1 := "Go语言"
    bs := []byte(text1)
    bs[0] = 103
    text2 := string(bs)
    fmt.Println(text1) // Go语言
    fmt.Println(text2) // go语言
}

注意,你不是真的获取字符串底层的字节数据,只是获取复本,因此,在范例中可以看到,虽然对text2的字节做了修改,text1是不受影响的,记得,字符串是只读的,一旦创建,没有方式可以改变其内容。

string 与索引

实际上,Go 的字符串支持片段操作,slice 操作时的索引是针对字节,然而,返回的类型还是string,例如,"Go语言"[0:2],返回"Go",因为指定要切割出索引 0 开始,索引 2 结束(但不包括 2)的部份,也就是 47 与 6f 这两个字节,但是以string返回。

那么,如果是"Go语言"[2:3]呢?嗯,返回的字符串是"\xe8"!这是什么?事实上,Go 中的字符串可以是任意字节片段,因此,你可以如下定义字符串:

package main

import "fmt"

func main() {
    text := "\x47\x6f\xe8\xaa\x9e\xe8\xa8\x80"
    fmt.Println(text)  // Go语言
}

片段操作时,如果省略冒号之后的数字,则默认获取至字符串尾端的子字符串,例如"Go语言"[3:]会返回"\xaa\x9e\xe8\xa8\x80"的字符串,如果省略冒号之前的数字,默认从索引 0 开始,例如"Go语言"[:2]会获取"Go"的字符串,也就是"\x47\x6f"的字符串,如果是"Go语言"[:],那么就是获取全部字符串内容了。

strings中有不少字符串可用的方法,想做字符串操作时,可以多加利用,不过要看清楚是针对什么在操作。例如strings.Index

package main

import "fmt"
import "strings"

func main() {
    text := "Go语言"
    fmt.Printf("%d\n", strings.Index(text, "言"))  // 5
}

返回的索引值是 5 而不是 3,这是因为"言"的第一个字节,是在"Go语言"UTF-8 编码后的字节组成中第 5 个索引位置。

问题来了,如果对于"Go语言",想逐一获取'G''o''语''言'该怎么办?当然不能用text[n],这只会获取第 n 个字节,可以将字符串类型转换为[]rune

package main

import "fmt"

func main() {
    text := "Go语言"
    cs := []rune(text)
    fmt.Printf("%c\n", cs[2]) // 语
    fmt.Println(len(cs))      // 4
}

字符串类型转换为[]rune时,会将 UTF-8 编码的字节,转换为 Unicode 码,在这个例子中可以看到,cs[2]确实地获取了第三个文本「语」,而len也确实获取数量 4。

如〈认识预定义类型〉中谈过的,在 Go 中并没有字符对应的类型,只有码的概念,runeint32的别名,可用来存储 Unicode 码(code point),如果使用fmt.Printf("%d\n", cs[2]),会显示 35486,这就是「语」的 Unicode 码,35486 的 16 进制表示是 8a9e,因此,如果你写'\u8a9e',也会得到一个rune代表着「语」,fmt.Printf("%c", '\u8a9e')也会显示「语」,当然,直接写'语'也是可以得到一个rune

想从rune得到一个string,可以直接写string('语')就可以了。如果想以rune为单位来遍历字符串,而不是以字节遍历,可以使用for range,例如:

package main

import "fmt"

func main() {
    text := "Go语言"
    for index, runeValue := range text {
        fmt.Printf("%#U 位起始位置 %d\n", runeValue, index)
    }
}

可以看到,for range可以同时获取每个rune在字符串中的位起始位置,以及rune值,%U可以用 16 进制显示rune,如果是%#U,还会一并显示码的打印形式。

这个程序的执行结果会显示:

U+0047 'G' 位起始位置 0
U+006F 'o' 位起始位置 1
U+8A9E '语' 位起始位置 2
U+8A00 '言' 位起始位置 5

总而言之,Go 的字符串是由 UTF-8 编码的字节构成,在〈Strings, bytes, runes and characters in Go〉谈到了这么设计的理由是,「字符」的定义太模棱两可了,Go 为了避免模棱两可,就将字符串定义为 UTF-8 编码的字节构成,而rune用于存储码。

PS. 这大概也是为何,我会整理出〈乱码 1/2〉的原因 … XD


展开阅读全文