字符串

和很多其它编程语言一样,字符串类型是Go中的一种重要类型。本文将列举出关于字符串的各种事实。

字符串类型的内部结构定义

对于标准编译器,字符串类型的内部结构声明如下:

  1. type _string struct {
  2. elements *byte // 引用着底层的字节
  3. len int // 字符串中的字节数
  4. }

从这个声明来看,我们可以将一个字符串的内部定义看作为一个字节序列。事实上,我们确实可以把一个字符串看作是一个元素类型为byte的(且元素不可修改的)切片。

注意,前面的文章已经提到过多次,内置类型byte是内置类型uint8的一个别名。

关于字符串的一些简单事实

从前面的若干文章,我们已经了解到下列关于字符串的一些事实:

  • 字符串值(和布尔以及各种数值类型的值)可以被用做常量。
  • Go支持两种风格的字符串字面表示形式:双引号风格(解释型字面表示)和反引号风格(直白字面表示)。
  • 字符串类型的零值为空字符串。一个空字符串在字面上可以用""或者``来表示。
  • 我们可以用运算符++=来衔接字符串。
  • 字符串类型都是可比较类型。同一个字符串类型的值可以用==!=比较运算符来比较。并且和整数/浮点数一样,同一个字符串类型的值也可以用><>=<=比较运算符来比较。当比较两个字符串值的时候,它们的底层字节将逐一进行比较。如果一个字符串是另一个字符串的前缀,并且另一个字符串较长,则另一个字符串为两者中的较大者。一个例子:
  1. package main
  2. import "fmt"
  3. func main() {
  4. const World = "world"
  5. var hello = "hello"
  6. // 衔接字符串。
  7. var helloWorld = hello + " " + World
  8. helloWorld += "!"
  9. fmt.Println(helloWorld) // hello world!
  10. // 比较字符串。
  11. fmt.Println(hello == "hello") // true
  12. fmt.Println(hello > helloWorld) // false
  13. }

更多关于字符串类型和值的事实:

  • 和Java语言一样,字符串值的内容(即底层字节)是不可更改的。字符串值的长度也是不可独立被更改的。一个可寻址的字符串只能通过将另一个字符串赋值给它来整体修改它。
  • 字符串类型没有内置的方法。我们可以
    • 使用strings标准库提供的函数来进行各种字符串操作。
    • 调用内置函数len来获取一个字符串值的长度(此字符串中存储的字节数)。
    • 使用容器元素索引语法aString[i]来获取aString中的第i个字节。表达式aString[i]是不可寻址的。换句话说,aString[i]不可被修改。
    • 使用子切片语法aString[start:end]来获取aString的一个子字符串。这里,start(包括)和end(不包括)均为aString中存储的字节的下标。
  • 对于标准编译器来说,一个字符串的赋值完成之后,此赋值中的目标值和源值将共享底层字节。一个子切片表达式aString[start:end]的估值结果也将和基础字符串aString共享一部分底层字节。 注意:如果在aString[i]aString[start:end]中,aString和各个下标均为常量,则编译器将在编译时刻验证这些下标的合法性,但是这样的元素访问和子切片表达式的估值结果总是非常量。 一个例子:
  1. package main
  2. import (
  3. "fmt"
  4. "strings"
  5. )
  6. func main() {
  7. var helloWorld = "hello world!"
  8. var hello = helloWorld[:5] // 取子字符串
  9. // 104是英文字符h的ASCII(和Unicode)码。
  10. fmt.Println(hello[0]) // 104
  11. fmt.Printf("%T \n", hello[0]) // uint8
  12. // hello[0]是不可寻址和不可修改的,所以下面
  13. // 两行编译不通过。
  14. /*
  15. hello[0] = 'H' // error
  16. fmt.Println(&hello[0]) // error
  17. */
  18. // 下一条语句将打印出:5 12 true
  19. fmt.Println(len(hello), len(helloWorld),
  20. strings.HasPrefix(helloWorld, hello))
  21. }

字符串编码和Unicode码点

Unicode标准为全球各种人类语言中的每个字符制定了一个独一无二的值。但Unicode标准中的基本单位不是字符,而是码点(code point)。大多数的码点实际上就对应着一个字符。但也有少数一些字符是由多个码点组成的。

码点值在Go中用rune值来表示。内置rune类型为内置int32类型的一个别名。

在具体应用中,码点值的编码方式有很多,比如UTF-8编码和UTF-16编码等。目前最流行编码方式为UTF-8编码。在Go中,所有的字符串常量都被视为是UTF-8编码的。在编译时刻,非法UTF-8编码的字符串常量将导致编译失败。在运行时刻,Go运行时无法阻止一个字符串是非法UTF-8编码的。

在UTF-8编码中,一个码点值可能由1到4个字节组成。比如,每个英语码点值(均对应一个英语字符)均由一个字节组成,而每个中文字符(均对应一个中文字符)均由三个字节组成。

字符串相关的类型转换

常量和变量一文中,我们已经了解到整数可以被显式转换为字符串类型(但是反之不行)。 这里介绍两种新的字符串相关的类型转换规则:

  • 一个字符串值可以被显式转换为一个字节切片(byte slice),反之亦然。一个字节切片类型是一个元素类型为内置类型byte的切片类型。或者说,一个字节切片类型的底层类型为[]byte(亦即[]uint8)。
  • 一个字符串值可以被显式转换为一个码点切片(rune slice),反之亦然。一个码点切片类型是一个元素类型为内置类型rune的切片类型。或者说,一个码点切片类型的底层类型为[]rune(亦即[]int32)。 在一个从码点切片到字符串的转换中,码点切片中的每个码点值将被UTF-8编码为一到四个字节至结果字符串中。如果一个码点值是一个不合法的Unicode码点值,则它将被视为Unicode替换字符(码点)值0xFFFD(Unicode replacement character)。替换字符值0xFFFD将被UTF-8编码为三个字节0xef 0xbf 0xbd

当一个字符串被转换为一个码点切片时,此字符串中存储的字节序列将被解读为一个一个码点的UTF-8编码序列。非法的UTF-8编码字节序列将被转化为Unicode替换字符值0xFFFD

当一个字符串被转换为一个字节切片时,结果切片中的底层字节序列是此字符串中存储的字节序列的一份深复制。即Go运行时将为结果切片开辟一块足够大的内存来容纳被复制过来的所有字节。当此字符串的长度较长时,此转换开销是比较大的。同样,当一个字节切片被转换为一个字符串时,此字节切片中的字节序列也将被深复制到结果字符串中。当此字节切片的长度较长时,此转换开销同样是比较大的。在这两种转换中,必须使用深复制的原因是字节切片中的字节元素是可修改的,但是字符串中的字节是不可修改的,所以一个字节切片和一个字符串是不能共享底层字节序列的。 请注意,在字符串和字节切片之间的转换中,

  • 非法的UTF-8编码字节序列将被保持原样不变。
  • 标准编译器做了一些优化,从而使得这些转换在某些情形下将不用深复制。这样的情形将在下一节中介绍。Go并不支持字节切片和码点切片之间的直接转换。我们可以用下面列出的方法来实现这样的转换:
  • 利用字符串做为中间过渡。这种方法相对方便但效率较低,因为需要做两次深复制。
  • 使用unicode/utf8标准库包中的函数来实现这些转换。这种方法效率较高,但使用起来不太方便。
  • 使用bytes标准库包中的Runes函数来将一个字节切片转换为码点切片。但此包中没有将码点切片转换为字节切片的函数。一个展示了上述各种转换的例子:
  1. package main
  2. import (
  3. "bytes"
  4. "unicode/utf8"
  5. )
  6. func Runes2Bytes(rs []rune) []byte {
  7. n := 0
  8. for _, r := range rs {
  9. n += utf8.RuneLen(r)
  10. }
  11. n, bs := 0, make([]byte, n)
  12. for _, r := range rs {
  13. n += utf8.EncodeRune(bs[n:], r)
  14. }
  15. return bs
  16. }
  17. func main() {
  18. s := "颜色感染是一个有趣的游戏。"
  19. bs := []byte(s) // string -> []byte
  20. s = string(bs) // []byte -> string
  21. rs := []rune(s) // string -> []rune
  22. s = string(rs) // []rune -> string
  23. rs = bytes.Runes(bs) // []byte -> []rune
  24. bs = Runes2Bytes(rs) // []rune -> []byte
  25. }

字符串和字节切片之间的转换的编译器优化

上面已经提到了字符串和字节切片之间的转换将深复制它们的底层字节序列。标准编译器做了一些优化,从而在某些情形下避免了深复制。至少这些优化在当前(Go SDK 1.13)是存在的。这样的情形包括:

  • 一个for-range循环中跟随range关键字的从字符串到字节切片的转换;
  • 一个在映射元素索引语法中被用做键值的从字节切片到字符串的转换;
  • 一个字符串比较表达式中被用做比较值的从字节切片到字符串的转换;
  • 一个(至少有一个被衔接的字符串值为非空字符串常量的)字符串衔接表达式中的从字节切片到字符串的转换。一个例子:
  1. package main
  2. import "fmt"
  3. func main() {
  4. var str = "world"
  5. // 这里,转换[]byte(str)将不需要一个深复制。
  6. for i, b := range []byte(str) {
  7. fmt.Println(i, ":", b)
  8. }
  9. key := []byte{'k', 'e', 'y'}
  10. m := map[string]string{}
  11. // 这里,转换string(key)将不需要一个深复制。
  12. // 即使key是一个包级变量,此优化仍然有效。
  13. m[string(key)] = "value"
  14. fmt.Println(m[string(key)]) // value
  15. }

另一个例子:

  1. package main
  2. import "fmt"
  3. import "testing"
  4. var s string
  5. var x = []byte{1024: 'x'}
  6. var y = []byte{1024: 'y'}
  7. func fc() {
  8. // 下面的四个转换都不需要深复制。
  9. if string(x) != string(y) {
  10. s = (" " + string(x) + string(y))[1:]
  11. }
  12. }
  13. func fd() {
  14. // 两个在比较表达式中的转换不需要深复制,
  15. // 但两个字符串衔接中的转换仍需要深复制。
  16. // 请注意此字符串衔接和fc中的衔接的差别。
  17. if string(x) != string(y) {
  18. s = string(x) + string(y)
  19. }
  20. }
  21. func main() {
  22. fmt.Println(testing.AllocsPerRun(1, fc)) // 1
  23. fmt.Println(testing.AllocsPerRun(1, fd)) // 3
  24. }

使用for-range循环遍历字符串中的码点

for-range循环控制中的range关键字后可以跟随一个字符串,用来遍历此字符串中的码点(而非字节元素)。字符串中非法的UTF-8编码字节序列将被解读为Unicode替换码点值0xFFFD。 一个例子:

  1. package main
  2. import "fmt"
  3. func main() {
  4. s := "éक्षिaπ囧"
  5. for i, rn := range s {
  6. fmt.Printf("%2v: 0x%x %v \n", i, rn, string(rn))
  7. }
  8. fmt.Println(len(s))
  9. }

此程序的输出如下:

  1. 0: 0x65 e
  2. 1: 0x301 ́
  3. 3: 0x915
  4. 6: 0x94d
  5. 9: 0x937
  6. 12: 0x93f ि
  7. 15: 0x61 a
  8. 16: 0x3c0 π
  9. 18: 0x56e7
  10. 21

从此输出结果可以看出:

  • 下标循环变量的值并非连续。原因是下标循环变量为字符串中字节的下标,而一个码点可能需要多个字节进行UTF-8编码。
  • 第一个字符由两个码点(共三字节)组成,其中一个码点需要两个字节进行UTF-8编码。
  • 第二个字符क्षि由四个码点(共12字节)组成,每个码点需要三个字节进行UTF-8编码。
  • 英语字符a由一个码点组成,此码点只需一个字节进行UTF-8编码。
  • 字符π由一个码点组成,此码点只需两个字节进行UTF-8编码。
  • 汉字由一个码点组成,此码点只需三个字节进行UTF-8编码。那么如何遍历一个字符串中的字节呢?使用传统for循环:
  1. package main
  2. import "fmt"
  3. func main() {
  4. s := "éक्षिaπ囧"
  5. for i := 0; i < len(s); i++ {
  6. fmt.Printf("第%v个字节为0x%x\n", i, s[i])
  7. }
  8. }

当然,我们也可以利用前面介绍的编译器优化来使用for-range循环遍历一个字符串中的字节元素。对于官方标准编译器来说,此方法比刚展示的方法效率更高。

  1. package main
  2. import "fmt"
  3. func main() {
  4. s := "éक्षिaπ囧"
  5. // 这里,[]byte(s)不需要深复制底层字节。
  6. for i, b := range []byte(s) {
  7. fmt.Printf("The byte at index %v: 0x%x \n", i, b)
  8. }
  9. }

从上面几个例子可以看出,len(s)将返回字符串s中的字节数。len(s)的时间复杂度为O(1)。如何得到一个字符串中的码点数呢?使用刚介绍的for-range循环来统计一个字符串中的码点数是一种方法,使用unicode/utf8标准库包中的RuneCountInString是另一种方法。这两种方法的效率基本一致。第三种方法为使用len([]rune(s))来获取字符串s中码点数。标准编译器从1.11版本开始,对此表达式做了优化以避免一个不必要的深复制,从而使得它的效率和前两种方法一致。注意,这三种方法的时间复杂度均为O(n)

更多字符串衔接方法

除了使用+运算符来衔接字符串,我们也可以用下面的方法来衔接字符串:

  • fmt标准库包中的Sprintf/Sprint/Sprintln函数可以用来衔接各种类型的值的字符串表示,当然也包括字符串类型的值。
  • 使用strings标准库包中的Join函数。
  • bytes标准库包提供的Buffer类型可以用来构建一个字节切片,然后我们可以将此字节切片转换为一个字符串。
  • 从Go 1.10开始,strings标准库包中的Builder类型可以用来拼接字符串。和bytes.Buffer类型类似,此类型内部也维护着一个字节切片,但是它在将此字节切片转换为字符串时避免了底层字节的深复制。 标准编译器对使用+运算符的字符串衔接做了特别的优化。所以,一般说来,在被衔接的字符串的数量是已知的情况下,使用+运算符进行字符串衔接是比较高效的。

语法糖:将字符串当作字节切片使用

上一篇文章中,我们了解到内置函数copyappend可以用来复制和添加切片元素。事实上,做为一个特例,如果这两个函数的调用中的第一个实参为一个字节切片的话,那么第二个实参可以是一个字符串。(对于append函数调用,字符串实参后必须跟随三个点。)换句话说,在此特例中,字符串可以当作字节切片来使用。 一个例子:

  1. package main
  2. import "fmt"
  3. func main() {
  4. hello := []byte("Hello ")
  5. world := "world!"
  6. // helloWorld := append(hello, []byte(world)...) // 正常的语法
  7. helloWorld := append(hello, world...) // 语法糖
  8. fmt.Println(string(helloWorld))
  9. helloWorld2 := make([]byte, len(hello) + len(world))
  10. copy(helloWorld2, hello)
  11. // copy(helloWorld2[len(hello):], []byte(world)) // 正常的语法
  12. copy(helloWorld2[len(hello):], world) // 语法糖
  13. fmt.Println(string(helloWorld2))
  14. }

更多关于字符串的比较

上面已经提到了比较两个字符串事实上逐个比较这两个字符串中的字节。Go编译器一般会做出如下的优化:

  • 对于==!=比较,如果这两个字符串的长度不相等,则这两个字符串肯定不相等(无需进行字节比较)。
  • 如果这两个字符串底层引用着字符串切片的指针相等,则比较结果等同于比较这两个字符串的长度。 所以两个相等的字符串的比较的时间复杂度取决于它们是否引用着同一个底层字节序列。如果它们引用着同一个底层字节序列,则对它们的比较的时间复杂度为O(1),否则时间复杂度为O(n)

上面已经提到了,对于标准编译器,一个字符串赋值完成之后,目标字符串和源字符串将共享同一个底层字节序列。所以比较这两个字符串的代价很小。 一个例子:

  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. func main() {
  7. bs := make([]byte, 1<<26)
  8. s0 := string(bs)
  9. s1 := string(bs)
  10. s2 := s1
  11. // s0、s1和s2是三个相等的字符串。
  12. // s0的底层字节序列是bs的一个深复制。
  13. // s1的底层字节序列也是bs的一个深复制。
  14. // s0和s1底层字节序列为两个不同的字节序列。
  15. // s2和s1共享同一个底层字节序列。
  16. startTime := time.Now()
  17. _ = s0 == s1
  18. duration := time.Now().Sub(startTime)
  19. fmt.Println("duration for (s0 == s1):", duration)
  20. startTime = time.Now()
  21. _ = s1 == s2
  22. duration = time.Now().Sub(startTime)
  23. fmt.Println("duration for (s1 == s2):", duration)
  24. }

输出如下:

  1. duration for (s0 == s1): 10.462075ms
  2. duration for (s1 == s2): 136ns

1ms等于1000000ns!所以请尽量避免比较两个很长的不共享底层字节序列的相等的(或者几乎相等的)字符串。

Go语言101项目目前同时托管在GithubGitlab上。欢迎各位在这两个项目中通过提交bug和PR的方式来改进完善Go语言101中的各篇文章。

本书微信公众号名称为"Go 101"。每个工作日此公众号将尽量发表一篇和Go语言相关的原创短文。各位如果感兴趣,可以搜索关注一下。

赞赏