声明
本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。
要点
本文关注Go语言字符串相关的语言特性、以及相关的[]byte、[]rune数据类型。
从字符编码说起
ASCII
计算机是为人类服务的,我们自然有表示我们人类所有语言与符号的需求。由于计算机底层实现全部为二进制,为了用计算机表示并存储人类文明所有的符号,我们需要构造一个“符号” => “唯一编码”的映射表,且这个编码能够用二进制来表示。这样就实现了用计算机来表示人类的文字与符号。最早的映射表叫做ASCII码表,如:a => 97。这个相信大家都很熟悉了,它是由美国人发明的,自然首先需要满足容纳所有英文字符的需求,所以并没有考虑其他国家的语言与符号要如何用计算机来表示。
但是随着计算机的发展,其他国家也陆续有了使用计算机的需求。由于ASCII码只用1个字节存储,所以最多只能表示256种符号,无法表示其他国家的文字(如中文等)。为了解决ASCII表示范围有限的问题,以容纳其他国家的文字与符号,Unicode出现了。
Unicode
Unicode究竟有多强大?我们举一个例子来直观的感受一下:中文的“世”字,若用Unicode映射规则来表示,为“U+4E16”。U+代表Unicode,我们先不用管。“4E16”就是“世”字在所有人类的字符集中的唯一编码了,可以把这个编码看成数据库中的id,唯一确定“世”这个符号。Unicode能够存储目前世界上所有的文字与符号。
我始终在强调"映射规则"。ASCII、Unicode只是定义了一个“符号” => “唯一编码”的映射规则而已,并不关心具体计算机底层是如何用二进制存储的。
Unicode的存储实现
我们先自己实现一个
接下来我们关注究竟如何用二进制,来表示并存储“世”字这个Unicode编码“4E16”:先抛开业界已有的方案,我们先自己设计一个。按照惯性思维,我们可以直接想到,直接在底层将“4E16"转为二进制进行存储:即01001110 00010110,共2个字节。我们可以看到,这里Unicode规则和计算机二进制编码一一对应,不加任何优化与修改,这就是最早的UTF-16编码方案。
但是UTF-16编码存在一定的问题:无论是ASCII中定义的英文字符,还是复杂的中文字符,它都采用2个字节来存储。如果严格按照2个字节存储,编码号比较小的(如英文字母)的许多高位都为0(如字母t:00000000 01110100)。
这样一来,由于很多英文编码的高位都是0,但仍需要固定的2个字节来存储,所以UTF-16编码就造成了大量的空间浪费。我们怎么优化呢?我们想到,没有必要所有符号都统一都用2个字节来表示。编码号较小的,如英文字符,仅用1个字节表示就可以了;而编码号较大的中文字符,则用3个字节来表示。这种规则就是我们所熟知的UTF-8编码方式。Unicode的实现方式称为Unicode转换格式(Unicode Transformation Format,简称为UTF)
UTF-8
UTF-8编码方式如下:
- 单字节的字符,字节的第一位设为0,对于英文,UTF-8码只占用一个字节,和ASCII码完全相同;
- n个字节的字符(n>1),第一个字节的前n位设为1,第n+1位设为0,后面字节的前两位都设为10,这n个字节的其余空位填充该字符unicode码,高位用0补足。
对于我们之前的例子,“世”需要用3个字节来存储,在UTF-8中以“E4B896”来存储。而对于英文字符“t”则以“74”来存储。所以,我们可以看到,虽然中文所需的存储空间比UTF-16多了1个字节,但是英文字符却减少了一个字节。综合考虑,由于我们使用英文字符的频率远远高于中文字符,所以这种改动是利大于弊的。相较前文的UTF-16编码方式,UTF-8的灵活度更大,也更节省存储空间。
编程范式
综上,UTF-16、UTF-8、还有其他五花八门的编码存储方式,都是Unicode的底层存储实现。用编程范式的语言来描述:Unicode是接口,定义了有哪些映射规则;而UTF-8、UTF-16则是Unicode这个接口的实现,它们在计算机底层实现了这些映射规则。
Go语言的字符串
字符串的长度是什么
为什么我们上文要讲编码呢?请看下面一个例子:
func main() {
s := "hello世界"
fmt.Println(len(s)) // 11
}
这里的结果并不符合我们预期的结果8。Go语言中的字符串实现,基于UTF-8编码。按照前文的描述,“世界”的编码共需要6个字节,加上hello,共需要11个字节,这样就能够解释len(s)的返回值了。
所以,从这里我们也能够回答标题中的问题,字符串的长度究竟代表什么?“长度”并没有一个标准的定义。通过这个例子来看,求字符串的长度函数len()的返回值,是这个字符串所占用的字节数,并不是字符的总个数。我们暂且把长度定义为字符串的字节数。
为什么需要byte和rune
我们知道,Go语言中有两种特殊的别名类型,是byte和rune,分别代表uint8和int32类型,即1个字节和4个字节。我们在开发中,常常会用到string类型和[]byte、[]rune类型的转换。它可能长下面这个样子:
func main() {
s := "hello 世界"
runeSlice := []rune(s) // len = 8
byteSlice := []byte(s) // len = 12
// 打印每个rune切片元素
for i:= 0; i < len(runeSlice); i++ {
fmt.Println(runeSlice[i])
// 输出104 101 108 108 111 32 19990 30028
}
fmt.Println()
// 打印每个byte切片元素
for i:= 0; i < len(byteSlice); i++ {
fmt.Println(byteSlice[i])
// 输出104 101 108 108 111 32 228 184 150 231 149 140
}
}
我们可以看到,因为Go中的字符串采用UTF-8编码,且由于rune类型是4个字节,所以切片[]rune中,一个rune切片中的单个元素(4个字节),就能够完整的容纳一个UTF-8编码的中文字符(3个字节);而在[]byte中,由于每个byte切片元素只有1个字节,所以需要3个byte切片元素来表示一个中文字符。这样,用[]byte表示的字符串就要比[]rune表示的字符串,切片长度多4(6 - 2),打印结果符合预期。
所以,我个人认为设计rune类型的目的,就是为了更方便的表示类似中文的非英文字符,处理起来更加方便;而byte类型则对英文字符的处理更加友好。这里总结一下:
- 一个值在从string类型向[]byte类型转换时代表着以 UTF-8 编码的字符串,会被拆分成零散、独立的字节。可能一个完整的字符(如中文),会由多个byte切片中的元素组成。
- 一个值在从string类型向[]rune类型转换时代表着以 UTF-8 编码字符串,会被拆分成一个个完整的字符。
字符串的底层实现
那么,既然[]byte和[]rune都能够表示一个字符串,那么Go语言底层是如何存储字符串的呢?
// string is the set of all strings of 8-bit bytes, conventionally but not
// necessarily representing UTF-8-encoded text. A string may be empty, but
// not nil. Values of string type are immutable.
type string string
我们看英文注释,关键在于:string是一个8bit字节的集合,且是不可变的。所以,Go语言字符串的底层实现为[]byte:
type stringStruct struct {
str unsafe.Pointer // 指针,指向底层存储数据的[]byte
len int // 长度
}
我们看到,Go语言底层并没有像C语言一样,类似直接定义一个char[]来表示字符串,直接定义一个[]byte切片,而是采用了一个指针,这个指针相当于C语言的void *,可以指向任何地方,这给Go语言的字符串操作带来了极大的灵活性;而第二个字段则是字符串的长度,也很好理解。讲完了Go的字符串结构,那么我们用一张图,总结一下字符串、[]byte、[]rune三种类型之间的转换过程:
个人认为,使用[]rune来做string的底层存储结构理论上来说也是可以的。但是由于rune为4个字节,只对中文比较友好;对于英文字符来说,灵活度较差。而我们使用英文字符的频率更高,所以Go就选择了[]byte切片类型作为底层存储类型。
字符串的不可变性
Go语言的字符串是不可变的。那么怎么理解这个不可变性呢?答案是Go语言官方禁止str[0] = 'a',这种直接对字符串中的字符做修改操作。那么,为什么要这样做呢?
我们知道,字符串底层是用一个[]byte存储的。个人理解,如果不同字符串所要表示的字面量相同,不同字符串就可以复用这个字面量的底层存储空间。那么,如何最大化的复用呢?就源于这个字符串“不可变性”的约定。
在计算机领域,有一个很经典的存储空间复用机制COW(copy on write)。举一个简单的例子:假设某两个字符串均为:“hello世界”,当我们仅仅对字符串进行只读操作:比如赋值、读取数据,是不会重新分配内存的;而对字符串进行连接等写操作,由于写操作之后两个字符串并不再相同,实在没办法再复用下去了,我们就会为连接后的新字符串分配新的存储空间,并用字符串结构体中的指针str字段,指向这块新的存储空间,这样才能正确表示并存储两个不同的字符串。Go语言字符串的不可变性最大化的成全了COW机制,同时也能够体现出在底层stringStruct结构设计,指针所带来的的灵活性,我们感受一下:
package main
import (
"fmt"
"unsafe"
)
type stringStruct struct {
str unsafe.Pointer
len int
}
func main() {
a := "hello世界"
b := a
pa := (*stringStruct)(unsafe.Pointer(&a))
pb := (*stringStruct)(unsafe.Pointer(&b))
// 0x10cd9cd 0x10cd9cd
fmt.Println(pa.str, pb.str)
b = a[:5]
pa = (*stringStruct)(unsafe.Pointer(&a))
pb = (*stringStruct)(unsafe.Pointer(&b))
// 0x10cd9cd 0x10cd9cd
fmt.Println(pa.str, pb.str)
b += "baiyan"
pa = (*stringStruct)(unsafe.Pointer(&a))
pb = (*stringStruct)(unsafe.Pointer(&b))
// 0x10cd9cd 0xc000016060
fmt.Println(pa.str, pb.str)
}
这里unsafe.Pointer相当于C语言中的void *,可以将某个指针转换为任一指定类型。这里我们指定一个stringStruct,也就是Go字符串的底层存储结构。
我们重点关注以下几行代码:
b := a // 只读,复用
b = a[:5] // 只读,复用
b += "baiyan" // 写,无法继续复用
通过我们最终打印stringStruct中的str字段的地址,我们发现前两个只读操作打印的地址均相同,说明变量a和b会复用同一个底层[]byte;而进行字符串连接操作之后,b变量最终还是与a变量分离,进行内存拷贝,使用两个独立的[]byte:
除此之外,COW机制也体现了一个“懒”的思想,把分配内存空间这种耗时操作推迟到最晚(也就是修改后必须分离)的时候才完成,减少了内存分配的次数、最大化复用同一个底层数组的时间。
我们再回顾之前字符串的不可变性,它给多个字符串、共享相同的底层数据结构带来了最大程度的优化。同时也保证了在Go的多协程状态下,操作字符串的安全性。
下期预告
【Go语言踩坑系列(三)】数组与切片
关注我们
欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~