内容简介:本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。本文关注Go语言字符串相关的语言特性、以及相关的[]byte、[]rune数据类型。计算机是为人类服务的,我们自然有表示我们人类所有语言与符号的需求。由于计算机底层实现全部为二进制,为了用计算机表示并存储人类文明所有的符号,我们需要构造一个“符号” => “唯一编码”的
声明
本系列文章并不会停留在 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切片元素能够完整的容纳一个UTF-8编码的中文字符(3个字节);而在[]byte中,由于1个byte切片元素只有1个字节,所以需要3个byte切片元素来表示一个中文字符。这样,用[]byte表示的字符串就要比[]rune表示的字符串,切片长度多4(6 - 2),打印结果符合预期。
所以,我个人认为设计rune类型的目的,就是为了更方便的表示类似中文的非英文字符,处理起来更加方便;而byte类型则对英文字符的处理更加友好。在这里引用一句不错的解释:
- 一个值在从string类型向[]byte类型转换时代表着以 UTF-8 编码的字符串会被拆分成零散、独立的字节。
- 一个值在从string类型向[]rune类型转换时代表着字符串会被拆分成一个个 Unicode 字符。
字符串的底层实现
那么,既然[]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语言踩坑系列(三)】数组与切片
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- 查找一个字符串中最长不含重复字符的子字符串,计算该最长子字符串的长度
- 字符串、字符处理总结
- 高频算法面试题(字符串)leetcode 387. 字符串中的第一个唯一字符
- php删除字符串最后一个字符
- (三)C语言之字符串与字符串函数
- 算法笔记字符串处理问题H:编排字符串(2064)
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
迎接互联网的明天
邹静 / 电子工业 / 2011-6 / 55.00元
《迎接互联网的明天-玩转3D Web(附盘)》,全书共5章,第1章主要阐述了国内外空前繁荣的3D互联网技术领域,以及这些领域透射出来的潜在商机;第2章主要用当下比较流行的Flash编程语言ActionScript 3,来向大家介绍面向对象编程语言的思想概念,以及一些3D渲染技术的入门知识;第3章注重建模知识的运用,主要运用WireFusion和3ds Max来制作3D网页;第4章主要介绍3D游戏编......一起来看看 《迎接互联网的明天》 这本书的介绍吧!