【Go语言踩坑系列(二)】字符串

声明

本系列文章并不会停留在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编码方式如下:

  1. 单字节的字符,字节的第一位设为0,对于英文,UTF-8码只占用一个字节,和ASCII码完全相同;
  2. 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类型则对英文字符的处理更加友好。这里总结一下:

  1. 一个值在从string类型向[]byte类型转换时代表着以 UTF-8 编码的字符串,会被拆分成零散、独立的字节。可能一个完整的字符(如中文),会由多个byte切片中的元素组成。
  2. 一个值在从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语言踩坑系列(三)】数组与切片

关注我们

欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~
NoSay

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值