字符串

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

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

对于标准编译器,字符串类型的内部结构声明如下:
type _string struct {
	elements *byte // 引用着底层的字节
	len      int   // 字符串中的字节数
}

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

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

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

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

import "fmt"

func main() {
	const World = "world"
	var hello = "hello"

	// 衔接字符串。
	var helloWorld = hello + " " + World
	helloWorld += "!"
	fmt.Println(helloWorld) // hello world!

	// 比较字符串。
	fmt.Println(hello == "hello")   // true
	fmt.Println(hello > helloWorld) // false
}

更多关于字符串类型和值的事实: 一个例子:
package main

import (
	"fmt"
	"strings"
)

func main() {
	var helloWorld = "hello world!"

	var hello = helloWorld[:5] // 取子字符串
	// 104是英文字符h的ASCII(和Unicode)码。
	fmt.Println(hello[0])         // 104
	fmt.Printf("%T \n", hello[0]) // uint8

	// hello[0]是不可寻址和不可修改的,所以下面
	// 两行编译不通过。
	/*
	hello[0] = 'H'         // error
	fmt.Println(&hello[0]) // error
	*/

	// 下一条语句将打印出:5 12 true
	fmt.Println(len(hello), len(helloWorld),
			strings.HasPrefix(helloWorld, hello))
}

注意:如果在aString[i]aString[start:end]中,aString和各个下标均为常量,则编译器将在编译时刻验证这些下标的合法性,但是这样的元素访问和子切片表达式的估值结果总是非常量(这是Go语言设计之初的一个失误,但因为兼容性的原因导致难以弥补)。比如下面这个程序将打引出4 0
package main

import "fmt"

const s = "Go101.org" // len(s) == 9

// len(s)是一个常量表达式,但len(s[:])却不是。
var a byte = 1 << len(s) / 128
var b byte = 1 << len(s[:]) / 128

func main() {
	fmt.Println(a, b) // 4 0
}

ab两个变量估值不同的具体原因请阅读移位操作类型推断规则哪些函数调用在编译时刻被估值

字符串编码和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个字节组成。 比如,每个英语码点值(均对应一个英语字符)均由一个字节组成,而每个中文码点值(均对应一个中文字符)均由三个字节组成。

字符串相关的类型转换

常量和变量一文中,我们已经了解到整数可以被显式转换为字符串类型(但是反之不行)。

这里介绍两种新的字符串相关的类型转换规则:
  1. 一个字符串值可以被显式转换为一个字节切片(byte slice),反之亦然。 一个字节切片类型是一个元素类型的底层类型为内置类型byte的切片类型。
  2. 一个字符串值可以被显式转换为一个码点切片(rune slice),反之亦然。 一个码点切片类型是一个元素类型的底层类型为内置类型rune的切片类型。

在一个从码点切片到字符串的转换中,码点切片中的每个码点值将被UTF-8编码为一到四个字节至结果字符串中。 如果一个码点值是一个不合法的Unicode码点值,则它将被视为Unicode替换字符(码点)值0xFFFD(Unicode replacement character)。 替换字符值0xFFFD将被UTF-8编码为三个字节0xef 0xbf 0xbd

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

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

请注意,在字符串和字节切片之间的转换中, Go并不支持字节切片和码点切片之间的直接转换。我们可以用下面列出的方法来实现这样的转换: 一个展示了上述各种转换的例子:
package main

import (
	"bytes"
	"unicode/utf8"
)

func Runes2Bytes(rs []rune) []byte {
	n := 0
	for _, r := range rs {
		n += utf8.RuneLen(r)
	}
	n, bs := 0, make([]byte, n)
	for _, r := range rs {
		n += utf8.EncodeRune(bs[n:], r)
	}
	return bs
}

func main() {
	s := "颜色感染是一个有趣的游戏。"
	bs := []byte(s) // string -> []byte
	s = string(bs)  // []byte -> string
	rs := []rune(s) // string -> []rune
	s = string(rs)  // []rune -> string
	rs = bytes.Runes(bs) // []byte -> []rune
	bs = Runes2Bytes(rs) // []rune -> []byte
}

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

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

import "fmt"

func main() {
	var str = "world"
	// 这里,转换[]byte(str)将不需要一个深复制。
	for i, b := range []byte(str) {
		fmt.Println(i, ":", b)
	}

	key := []byte{'k', 'e', 'y'}
	m := map[string]string{}
	// 这个string(key)转换仍然需要深复制。
	m[string(key)] = "value"
	// 这里的转换string(key)将不需要一个深复制。
	// 即使key是一个包级变量,此优化仍然有效。
	fmt.Println(m[string(key)]) // value
}

注意:在最后一行中,如果在估值string(key)的时候有数据竞争的情况,则这行的输出有可能并不是value。 但是,无论如何,此行都不会造成恐慌(即使有数据竞争的情况发生)。

另一个例子:
package main

import "fmt"
import "testing"

var s string
var x = []byte{1023: 'x'}
var y = []byte{1023: 'y'}

func fc() {
	// 下面的四个转换都不需要深复制。
	if string(x) != string(y) {
		s = (" " + string(x) + string(y))[1:]
	}
}

func fd() {
	// 两个在比较表达式中的转换不需要深复制,
	// 但两个字符串衔接中的转换仍需要深复制。
	// 请注意此字符串衔接和fc中的衔接的差别。
	if string(x) != string(y) {
		s = string(x) + string(y)
	}
}

func main() {
	fmt.Println(testing.AllocsPerRun(1, fc)) // 1
	fmt.Println(testing.AllocsPerRun(1, fd)) // 3
}

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

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

一个例子:
package main

import "fmt"

func main() {
	s := "éक्षिaπ囧"
	for i, rn := range s {
		fmt.Printf("%2v: 0x%x %v \n", i, rn, string(rn))
	}
	fmt.Println(len(s))
}
此程序的输出如下:
 0: 0x65 e
 1: 0x301 ́
 3: 0x915 क
 6: 0x94d ्
 9: 0x937 ष
12: 0x93f ि
15: 0x61 a
16: 0x3c0 π
18: 0x56e7 囧
21
从此输出结果可以看出:
  1. 下标循环变量的值并非连续。原因是下标循环变量为字符串中字节的下标,而一个码点可能需要多个字节进行UTF-8编码。
  2. 第一个字符由两个码点(共三字节)组成,其中一个码点需要两个字节进行UTF-8编码。
  3. 第二个字符क्षि由四个码点(共12字节)组成,每个码点需要三个字节进行UTF-8编码。
  4. 英语字符a由一个码点组成,此码点只需一个字节进行UTF-8编码。
  5. 字符π由一个码点组成,此码点只需两个字节进行UTF-8编码。
  6. 汉字由一个码点组成,此码点只需三个字节进行UTF-8编码。
那么如何遍历一个字符串中的字节呢?使用传统for循环:
package main

import "fmt"

func main() {
	s := "éक्षिaπ囧"
	for i := 0; i < len(s); i++ {
		fmt.Printf("第%v个字节为0x%x\n", i, s[i])
	}
}

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

import "fmt"

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

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

更多字符串衔接方法

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

标准编译器对使用+运算符的字符串衔接做了特别的优化。 所以,一般说来,当所有被衔接的字符串都可以呈现在一条+字符串衔接语句中的情况下,使用+运算符进行字符串衔接是比较高效的。

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

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

一个例子:
package main

import "fmt"

func main() {
	hello := []byte("Hello ")
	world := "world!"

	// helloWorld := append(hello, []byte(world)...) // 正常的语法
	helloWorld := append(hello, world...)            // 语法糖
	fmt.Println(string(helloWorld))

	helloWorld2 := make([]byte, len(hello) + len(world))
	copy(helloWorld2, hello)
	// copy(helloWorld2[len(hello):], []byte(world)) // 正常的语法
	copy(helloWorld2[len(hello):], world)            // 语法糖
	fmt.Println(string(helloWorld2))
}

更多关于字符串的比较

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

所以两个相等的字符串的比较的时间复杂度取决于它们底层引用着字符串切片的指针是否相等。 如果相等,则对它们的比较的时间复杂度为O(1),否则时间复杂度为O(n)

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

一个例子:
package main

import (
	"fmt"
	"time"
)

func main() {
	bs := make([]byte, 1<<26)
	s0 := string(bs)
	s1 := string(bs)
	s2 := s1

	// s0、s1和s2是三个相等的字符串。
	// s0的底层字节序列是bs的一个深复制。
	// s1的底层字节序列也是bs的一个深复制。
	// s0和s1底层字节序列为两个不同的字节序列。
	// s2和s1共享同一个底层字节序列。

	startTime := time.Now()
	_ = s0 == s1
	duration := time.Now().Sub(startTime)
	fmt.Println("duration for (s0 == s1):", duration)

	startTime = time.Now()
	_ = s1 == s2
	duration = time.Now().Sub(startTime)
	fmt.Println("duration for (s1 == s2):", duration)
}
输出如下:
duration for (s0 == s1): 10.462075ms
duration for (s1 == s2): 136ns

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


目录↡

Go101.org网站内容包括Go编程各种相关知识(比如Go基础、Go优化、Go细节、Go实战、Go测验、Go工具等)。后续将不断有新的内容加入。敬请收藏关注期待。

本丛书微信公众号(联系方式一)名称为"Go 101"。二维码在网站首页。此公众号将时不时地发表一些Go语言相关的原创短文。各位如果感兴趣,可以搜索关注一下。

《Go语言101》系列丛书项目目前托管在Github上(联系方式二)。欢迎各位在此项目中通过提交bug和PR的方式来改进完善《Go语言101》丛书中的各篇文章。我们可以在项目目录下运行go run .来浏览和确认各种改动。

本书的twitter帐号为@Golang_101(联系方式三)。玩推的Go友可以适当关注。

你或许对本书作者老貘开发的一些App感兴趣。

The English version of this book is here.
赞赏
(《Go语言101》系列丛书由老貘从2016年7月开始编写。目前此系列丛书仍在不断改进和增容中。你的赞赏是本系列丛书和此Go101.org网站不断增容和维护的动力。)

目录: