字符串
介绍
一个字符串是一个不可改变的字节序列,字符串通常是用来包含人类可读的文本数据。
和数组不同的是,字符串的元素不可修改,是一个只读的字节数组。每个字符串的长度虽然也是固定的,但是字符串的长度并不是字符串类型的一部分。
由于Go语言的源代码要求是UTF8编码,导致Go源代码中出现的字符串面值常量一般也是UTF8编码的。
源代码中的文本字符串通常被解释为采用UTF8编码的Unicode码点(rune)序列。
因为字节序列对应的是只读的字节序列,因此字符串可以包含任意的数据,包括byte值0。
我们也可以用字符串表示GBK等非UTF8编码的数据,不过这种时候将字符串看作是一个只读的二进制数组更准确,因为 for range
等语法并不能支持非UTF8编码的字符串的遍历。
字符串是不可变字节(byte)序列,其本身是一个复合结构
一个字符串是一个不可改变的字节序列。字符串可以包含任意的数据,包括byte值0,但是通常是用来包含人类可读的文本。
文本字符串通常被解释为采用UTF8编码的Unicode码点(rune)序列
内置的len函数可以返回一个字符串中的字节数目(不是rune字符数目),索引操作 s[i]
返回第i个字节的字节值,i必须满足 0 ≤ i< len(s)
条件约束。
1 2 3 |
|
第i个字节并不一定是字符串的第i个字符,因为对于非ASCII字符的UTF8编码会要两个或多个字节。我们先简单说下字符的工作方式。
子字符串操作 s[i:j]
基于原始的s字符串的第i个字节开始到第j个字节(并不包含j本身)生成一个新字符串。生成的新字符串将包含j-i个字节。
1 |
|
其中 +
操作符将两个字符串链接构造一个新字符串:
1 |
|
字符串可以用 ==
和 <
进行比较;比较通过逐个字节比较完成的,因此比较的结果是字符串自然编码的顺序。
字符串的值是不可变的:一个字符串包含的字节序列永远不会被改变,当然我们也可以给一个字符串变量分配一个新字符串值。
可以像下面这样将一个字符串追加到另一个字符串:
1 2 3 |
|
这并不会导致原始的字符串值被改变,但是变量 s 将因为 +=
语句持有一个新的字符串值,但是t依然是包含原先的字符串值。
1 2 |
|
因为字符串是不可修改的,因此尝试修改字符串内部数据的操作也是被禁止的:
1 |
|
实现原理
1 2 3 4 |
|
头部指针指向字节数组,但没有 NULL 结尾。
默认以 UTF-8 编码存储 Unicode 字符,字面量里允许使用十六进制、八进制和 UTF 编码格式
1 2 3 4 5 6 7 8 9 10 11 |
|
内置函数 len 返回字节数组长度,cap 不接受字符串类型参数
字符串默认值不是 nil,而是 ""
1 2 3 4 5 6 |
|
使用如下方式定义不做转义处理的原始字符串,支持跨行
1 2 3 4 5 6 7 8 9 |
|
输出:
1 2 |
|
编译器不会解析原始字符串内的注释语句,且前置缩进空格也属字符串内容
支持!=、==、<、>、+、+=
操作符
1 2 3 4 5 6 7 8 9 |
|
允许以索引号访问字节数组(非字符),但不能获取元素地址
1 2 3 4 5 6 |
|
以切片语法(起始和结束索引号)返回子串时,其内部依旧指向原字节数组
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
输出:
1 2 3 |
|
Go语言字符串的底层结构在 reflect.StringHeader
中定义:
1 2 3 4 |
|
字符串结构由两个信息组成:第一个是字符串指向的底层字节数组,第二个是字符串的字节的长度。
字符串其实是一个结构体,因此字符串的赋值操作也就是 reflect.StringHeader
结构体的复制过程,并不会涉及底层字节数组的复制。
[2]string
字符串数组对应的底层结构和 [2]reflect.StringHeader
对应的底层结构是一样的,可以将字符串数组看作一个结构体数组。
我们可以看看字符串 “Hello, world”
本身对应的内存结构:
分析可以发现,“Hello, world”
字符串底层数据和以下数组是完全一致的:
1 2 3 |
|
字符串虽然不是切片,但是支持切片操作,不同位置的切片底层也访问的同一块内存数据(因为字符串是只读的,相同的字符串面值常量通常是对应同一个字符串常量):
1 2 3 4 5 6 |
|
字符串和数组类似,内置的len函数返回字符串的长度。也可以通过 reflect.StringHeader
结构访问字符串的长度(这里只是为了演示字符串的结构,并不是推荐的做法):
1 2 3 |
|
转换
字符串相关的强制类型转换主要涉及到 []byte
和 []rune
两种类型。
每个转换都可能隐含重新分配内存的代价,最坏的情况下它们的运算时间复杂度都是O(n)。
不过字符串和 []rune
的转换要更为特殊一些,因为一般这种强制类型转换要求两个类型的底层内存结构要尽量一致,显然它们底层对应的 []byte
和 []int32
类型是完全不同的内部布局,因此这种转换可能隐含重新分配内存的操作。
下面分别用伪代码简单模拟Go语言对字符串内置的一些操作,这样对每个操作的处理的时间复杂度和空间复杂度都会有较明确的认识。
for range
对字符串的迭代模拟实现
1 2 3 4 5 6 7 8 |
|
for range
迭代字符串时,每次解码一个Unicode字符,然后进入for循环体,遇到崩坏的编码并不会导致迭代停止。
[]byte(s)
转换模拟实现
1 2 3 4 5 6 7 8 |
|
模拟实现中新创建了一个切片,然后将字符串的数组逐一复制到了切片中,这是为了保证字符串只读的语义。
当然,在将字符串转为 []byte
时,如果转换后的变量并没有被修改的情形,编译器可能会直接返回原始的字符串对应的底层数据。
string(bytes)
转换模拟实现
1 2 3 4 5 6 7 8 9 10 11 12 |
|
因为Go语言的字符串是只读的,无法直接同构构造底层字节数组生成字符串。
在模拟实现中通过unsafe包获取了字符串的底层数据结构,然后将切片的数据逐一复制到了字符串中,这同样是为了保证字符串只读的语义不会受切片的影响。
如果转换后的字符串在生命周期中原始的 []byte
的变量并不会发生变化,编译器可能会直接基于 []byte
底层的数据构建字符串。
[]rune(s)
转换模拟实现
1 2 3 4 5 6 7 8 9 |
|
因为底层内存结构的差异,字符串到 []rune
的转换必然会导致重新分配 []rune
内存空间,然后依次解码并复制对应的Unicode码点值。
这种强制转换并不存在前面提到的字符串和字节切片转化时的优化情况。
string(runes)
转换模拟实现
1 2 3 4 5 6 7 8 9 |
|
同样因为底层内存结构的差异,[]rune
到字符串的转换也必然会导致重新构造字符串。这种强制转换并不存在前面提到的优化情况。
要修改字符串,须将其转换为可变类型([]rune
或[]byte
),待完成后再转换回来。
但不管如何转换,都须重新分配内存,并复制数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
输出:
1 2 3 4 5 |
|
某些时候,转换操作会拖累算法性能,可尝试用“非安全”方法进行改善
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
输出:
1 2 |
|
该方法利用了[]byte
和 string 头结构“部分相同”,以非安全的指针类型转换来实现类型“变更”,从而避免了底层数组复制。
在很多 Web Framework 中都能看到此类做法,在高并发压力下,此种做法能有效改善执行性能。
只是使用 unsafe 存在一定的风险,须小心谨慎
用 append 函数,可将 string 直接追加到[]byte
内
1 2 3 4 5 6 7 8 9 10 |
|
输出:
1 |
|
考虑到字符串只读特征,转换时复制数据到新分配内存是可以理解的。
当然,性能同样重要,编译器会为某些场合进行专门优化,避免额外分配和复制操作:
- 将
[]byte
转换为 string key,去map[string]
查询的时候 - 将 string 转换为
[]byte
,进行 for range 迭代时,直接取字节赋值给局部变量
遍历
根据Go语言规范,Go语言的源文件都是采用UTF8编码。因此,Go源文件中出现的字符串面值常量一般也是UTF8编码的(对于转义字符,则没有这个限制)。
提到Go字符串时,我们一般都会假设字符串对应的是一个合法的UTF8编码的字符序列。
可以用内置的 print
调试函数或 fmt.Print
函数直接打印,也可以用 for range
循环直接遍历UTF8解码后的 Unicode 码点值。
下面的 “Hello, 世界”
字符串中包含了中文字符,可以通过打印转型为字节类型来查看字符底层对应的数据:
1 |
|
输出的结果是:
1 |
|
分析可以发现 0xe4, 0xb8, 0x96
对应中文 “世”
,0xe7, 0x95, 0x8c
对应中文 “界”
。
我们也可以在字符串面值中直指定UTF8编码后的值(源文件中全部是ASCII码,可以避免出现多字节的字符)。
1 2 |
|
下图展示了 “Hello, 世界”
字符串的内存结构布局:
Go语言的字符串中可以存放任意的二进制字节序列,而且即使是UTF8字符序列也可能会遇到坏的编码。
如果遇到一个错误的UTF8编码输入,将生成一个特别的 Unicode 字符 ‘\uFFFD’
,这个字符在不同的软件中的显示效果可能不太一样,在印刷中这个符号通常是一个黑色六角形或钻石形状,里面包含一个白色的问号 ‘�’
。
下面的字符串中,我们故意损坏了第一字符的第二和第三字节,因此第一字符将会打印为 “�”
,第二和第三字节则被忽略,后面的“abc”依然可以正常解码打印(错误编码不会向后扩散是UTF8编码的优秀特性之一)。
1 |
|
不过在 for range
迭代这个含有损坏的UTF8字符串时,第一字符的第二和第三字节依然会被单独迭代到,不过此时迭代的值是损坏后的0:
1 2 3 4 5 6 7 8 9 10 |
|
如果不想解码UTF8字符串,想直接遍历原始的字节码,可以将字符串强制转为 []byte
字节序列后再行遍历(这里的转换一般不会产生运行时开销):
1 2 3 |
|
或者是采用传统的下标方式遍历字符串的字节数组:
1 2 3 4 |
|
Go语言除了 for range
语法对UTF8字符串提供了特殊支持外,还对字符串和 []rune
类型的相互转换提供了特殊的支持。
1 2 |
|
从上面代码的输出结果来看,我们可以发现 []rune
其实是 []int32
类型,这里的 rune
只是 int32
类型的别名,并不是重新定义的类型。
rune用于表示每个Unicode码点,目前只使用了 21
个bit位。
使用 for 遍历字符串时,分 byte 和 rune 两种方式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
输出:
1 2 3 4 5 6 7 8 |
|
字符串的遍历包括按字节遍历和按字符遍历
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
|
输出结果:
1 2 3 4 5 6 7 8 9 10 |
|
如果字符串涉及中文,遍历字符串推荐使用rune
。
因为一个 byte 存不下一个汉语文字的 unicode 值
反转字符串:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
strconv 包
一般编程语言包含的字符串处理库功能的区别不是很大,高级的语言提供的函数会更多,掌握基本的字符串处理函数后,更丰富的字符串处理函数都是通过封装基本的处理函数实现。
因此熟悉 Go 的 strings 包后基本就能借此封装符合自己需求的、应用于特定场景的字符串处理函数了。
而 strconv 包实现了字符串与其他基本数据类型之间的类型转换。
https://golang.org/pkg/strconv/
字符串转数值
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
|
输出:
1 2 3 4 5 6 |
|
整数转字符串
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
1 2 3 4 5 6 7 8 9 10 11 12 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
Unicode
类似 rune 专门用来存储 Unicode 码点,它是 int32 的别名,相当于 UCS-4/UTF-32 编码格式。
使用单引号的字面量,其默认类型就是 rune
1 2 3 4 5 6 7 8 |
|
输出:
1 |
|
除[]rune
外,还可直接在 rune、byte、string 间进行转换
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
输出:
1 |
|
要知道字符串存储的字节数组,不一定就是合法的 UTF-8 文本。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
输出:
1 |
|
标准库 unicode 里提供了丰富的操作函数。
除验证函数外,还可用 RuneCountInString 代替 len 返回准确的 Unicode 字符数量
1 2 3 4 5 6 7 8 9 10 11 |
|
输出:
1 |
|
官方扩展库golang.org/x/test/encoding/unicode
提供了对 BOM 的支持
双引号与反单引号的区别
字符串可以使用双引号赋值,也可以使用反单引号赋值(注意不是单引号),它们的区别在于对特殊字符的处理
假如,我们希望 string 变量表示下面的字符串,它包括换行符和双引号
1 2 |
|
使用双引号表示时,需要对特殊字符转义,如下所示:
1 |
|
使用反单引号表示时,不需要对特殊字符转义,如下所示:
1 2 |
|
使用反单引号表示字符串比较直观,可以清晰地看出字符串内容。
在 Kubernetes 项目中就大量存在这种用法,在监控指标相关的单元测试中,我们常常会定义期望的指标,如下所示:
1 2 3 4 5 |
|
监控指示中往往会存在双引号、换行符等特殊字符,使用反单引号表示的字符串与用户实际看到的字符串完全一致,这将使单元测试非常清晰和直观
UTF 编码
string 使用 8 比特字节的集合来存储字符,而且存储的是字符的 UTF-8 编码,例如每个汉字字符的 UTF-8 编码将占用多个字节
在使用 for-ranage 遍历字符串时,每次迭代将返回字符 UTF-8 编码的首个字符的下标及字节值,这意味着下标可能不连续
比如下面的函数:
1 2 3 4 5 6 |
|
函数将输出:
1 2 |
|
此外,字符串的长度是指字节数,而非字符串,比如汉字 "中" 和 "国" 的 UTF-8 编码各占 3 个字节,字符串 "中国" 的长度为 6 而不是 2
标准库函数
标准库 strings 包提供了大量的字符串操作函数。下面仅列举一些常见的函数,如下表所示:
函数 | 描述 |
---|---|
func Contains(s, substr string) bool |
检查字符串 s 中是否包含子串 substr |
func Split(s, sep string) []string |
将字符串 s 根据分隔符 sep 拆分并生成子串的切片 |
func Join(elems []string, sep string) string |
将字符串切片 elems 中的元素使用分隔符 sep 拼接成单个字符串 |
func HasPrefix(s, prefix string) bool |
检查字符串 s 是否包含前缀 prefix |
func HasSuffix(s, suffix string) bool |
检查字符串 s 是否包含后缀 suffix |
func ToUpper(s string) string |
将字符串 s 的所有字符转成大写 |
func ToLower(s string) string |
将字符串 s 的所有字符转成小写 |
func Trim(s string, cutset string) string |
从字符串 s 首部和尾部清除所有包含在字符集 cutset 中的字符 |
func TrimLeft(s string, cutset string) string |
从字符串 s 首部清除所有包含在字符集 cutset 中的字符 |
func TrimRight(s string, cutset string) string |
从字符串 s 尾部清除所有包含在字符集 cutset 中的字符 |
func TrimSpace(s string) string |
从字符串 s 首部和尾部清除所有空白字符 |
func TrimPrefix(s, prefix string) |
清除字符串 s 中的前缀 prefix |
func TrimSuffix(s, suffix string) |
清除字符串 s 中的后缀 suffix |
func Replace(s, old, new string, n int) string |
将字符串 s 的前 n 个子串中的 old 替换成子串 new |
func ReplaceAll(s, old, new string) string |
将字符串 s 的所有子串中的 old 替换成子串 new |
func EqualFold(s, t string) bool |
忽略大小写,比较两个子串是否相等 |
实现原理
Go 标准库 builtin 中定义了 string 类型
1 2 3 4 |
|
可见 string 时 8bit 字节的集合,通常是 UTF-8 编码的文本。
另外,还提到了非常重要的两点:
- string 可以为空(长度为 0),但不会是 nil
- string 对象不可以修改
数据结构
源码包中 src/runtime/string.go:stringStruct
定义了 string 的数据结构:
1 2 3 4 |
|
string 的数据结构很简单,只包含两个成员。
stringStruct.str
: 字符串的首地址stringStruct.len
: 字符串的长度
string 的数据结构跟切片有些类似,只不过切片还有一个表示容量的成员,事实上 string 和切片(准确地说是 byte 切片)经常发生转换。
在 runtime 包中使用 gostringnocopy() 函数来生成字符串
如以下代码所示,声明一个 string 变量并赋予初值;
1 2 |
|
字符串生成时,会先构建 stringStruct 对象,再转换成 string。转换的源码如下:
1 2 3 4 5 6 7 |
|
string 在 runtime 包中是 stringStruct 类型,对外呈现为 string 类型
字符串表示
字符串使用 Unicode 编码存储字符,对于英文字符来说,每个字符的 Unicode 编码只用一个字节即可表示,此时字符串的长度等于字符数。
而对于非 ASCII 字符来说,其 Unicode 编码可能需要由多个字节表示,此时字符串的长度会大于实际字符数,字符串的长度实际上表现的是字节数。
字符串拼接
字符串可以很方便地拼接,像下面这样:
1 |
|
即便有非常多的字符串需要拼接,性能上也有比较好的保证,因为新字符串的内存空间是一次分配完成的,所以性能消耗主要在拷贝数据上。
在 runtime 包中,使用 concatstrings() 函数来拼接字符串。
在一个拼接语句中,所有待拼接字符串都被编译器组织到一个切片中并传入 concatstrings() 函数,拼接过程需要遍历两次切片,第一次遍历获取总的字符串长度,据此申请内存,第二次遍历会把字符串逐个拷贝过去
concatstrings() 函数的伪代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
因为 string 是无法直接修改的,所以这里使用 rawstring() 方法初始化一个指定大小的 string, 同时返回一个切片,二者共享一块内存空间,后面向切片中拷贝数据,也就间接地修改了 string
rawstring() 的源代码如下:
1 2 3 4 5 6 7 8 9 10 |
|
类型转换
1) []byte
转 string
byte 切片可以很方便地转换成 string:
1 2 3 |
|
需要注意的是,这种转换需要一次内存拷贝
转换过程如下:
(1) 根据切片的长度申请内存空间,假设内存地址为 p, 长度为 len
(2) 构建 string(string.str = p; string.len = len;)
(3) 拷贝数据(切片中将数据拷贝到新申请的内存空间)
在 runtime 包中使用 slicebytetostring()
函数将 []byte
转换成 string
。
slicebytetostring() 函数实现的伪代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
slicebytetostring() 函数会优先使用一个固定大小的 buf,当 buf 长度不够时才会申请新的内存
2) string 转 []byte
string 也可以方便地转换成 byte 切片:
1 2 3 |
|
string 转换成 byte 切片也需要一次内存拷贝,其过程如下:
(1) 申请切片内存空间
(2) 将 string 拷贝到切片中
在 runtime 包中,使用 stringtoslicebyte() 函数将 string 转换成 []byte
, stringtoslicebyte() 函数实现的伪代码如下;
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
stringtoslicebyte() 函数中也使用了预留 buf,并只在该 buf 长度不够时才会申请内存,其中 rawbyteslice() 函数用于申请新的未初始化的切片。
由于字符串内容将完整覆盖切片的存储空间,所以可以不初始化切片从而提升分配效率
3) 编译优化
byte 切片转换成 string 的场景有很多,出于性能上的考虑,有时候只是应用在临时需要字符串的场景下。
byte 切片转换成 string 时并不会拷贝内存,而是直接返回一个 string, 这个 string 的指针(string.str) 指向切片的内存
比如,编译器会识别如下临时场景。
- 使用
m[string(b)]
来查找 map(map 的 key 的类型是 string 时,临时把切片 b 转成 string) - 字符串拼接,如
<" + "string(b)" + ">;
- 字符串比较:
string(b) == "foo"
由于只是临时把 byte 切片转换成 string,也就避免了因 byte 切片内容修改而导致 string 数据变化的问题,所以此时可以不必拷贝内存
小结
(1) 为什么不允许修改字符串
像 C++ 语言中的 string,其本身拥有内存空间,修改 string 是支持的。
但在 Go 的实现中,string 不包含内存空间,只有一个内存的指针,这样做的好处是 string 变得非常轻量,可以很方便地进行传递而不用担心内存拷贝
因为 string 通常指向字符串字面量,而字符串字面量存储的位置是只读段,而不是堆或栈上,所以才有了 string 不可修改的约定
(2) string 和 []byte
如何取舍
string 和 []byte
都可以表示字符串,但因数据结构不同,其衍生出来的方法也不同,要根据实际应用场景来选择。
string 擅长的场景:
- 需要字符串比较的场景
- 不需要 nil 字符串的场景
[]byte
擅长的场景:
- 修改字符串,尤其是修改粒度为 1 个字节的场景
- 函数返回值,需要用 nil 表示含义的场景
- 需要切片操作的场景
虽然看起来 string 适用的场景不如 []byte
多,但因为 string 直观,在实际应用中还是大量存在的,在偏底层的实现中 []byte
使用得更多
性能
除类型转换外,动态构建字符串也容易造成性能问题。
用加法操作符拼接字符串时,每次都须重新分配内存。如此,在构建“超大”字符串时,性能就显得极差。
改进思路时预分配足够的内存空间。常用方法是用 strings.Join 函数,它会统计所有参数长度,并一次性完成内存分配操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
1 2 3 4 5 6 7 8 |
|
编译器对s1 + s2 + s3
这类表达式的处理方式和 strings.Join 类似
另外,bytes.Buffer 也能完成类似操作,且性能相当
1 2 3 4 5 6 7 8 9 |
|
对于数量较少的字符串格式化拼接,可使用 fmt.Sprintf、text/template 等方法
字符串操作通常在堆上分配内存,这会对 Web 等高并发应用会造成较大影响,会有大量字符串对象要做垃圾回收。
建议使用[]byte
缓存池,或在栈上自行拼装等方式来实现 zerogarbage
Format
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
|
输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
1 2 3 4 5 6 7 8 9 10 |
|
输出:
1 |
|