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

栏目: IT技术 · 发布时间: 4年前

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

  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切片元素能够完整的容纳一个UTF-8编码的中文字符(3个字节);而在[]byte中,由于1个byte切片元素只有1个字节,所以需要3个byte切片元素来表示一个中文字符。这样,用[]byte表示的字符串就要比[]rune表示的字符串,切片长度多4(6 - 2),打印结果符合预期。

所以,我个人认为设计rune类型的目的,就是为了更方便的表示类似中文的非英文字符,处理起来更加方便;而byte类型则对英文字符的处理更加友好。在这里引用一句不错的解释:

  1. 一个值在从string类型向[]byte类型转换时代表着以 UTF-8 编码的字符串会被拆分成零散、独立的字节。
  2. 一个值在从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三种类型之间相互转换的过程,也很好理解了:

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

其实个人认为,使用[]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:

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

除此之外,COW机制也体现了一个“懒”的思想,把分配内存空间这种耗时操作推迟到最晚(也就是修改后必须分离)的时候才完成,减少了内存分配的次数、最大化复用同一个底层数组的时间。

我们再回顾之前字符串的不可变性,它给多个字符串、共享相同的底层数据结构带来了最大程度的优化。同时也保证了在Go的多协程状态下,操作字符串的安全性。

下期预告

【Go语言踩坑系列(三)】数组与切片


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

An Introduction to the Analysis of Algorithms

An Introduction to the Analysis of Algorithms

Robert Sedgewick、Philippe Flajolet / Addison-Wesley Professional / 1995-12-10 / CAD 67.99

This book is a thorough overview of the primary techniques and models used in the mathematical analysis of algorithms. The first half of the book draws upon classical mathematical material from discre......一起来看看 《An Introduction to the Analysis of Algorithms》 这本书的介绍吧!

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具