大家好,我是大厂后端程序员阿煜。回望这一路的学习和成长,我深知技术学习过程中的难点与迷茫,希望通过文章让你在技术学习的路上少走弯路,轻松掌握关键知识!
今天,我将从带你全面解析 Go 语言的 string,让你对它有一个系统、深入的认识。
字符编码基础
首先,我们都知道,在计算机中所有的数据最终都会转换为二进制来进行存储和处理。但问题是,字母、数字、符号等本身并不是二进制的,因此需要通过一种编码方式将这些字符映射为计算机能够理解的二进制形式。
字符编码就是用来解决这个问题的,它将字符集中的每个字符与一个特定的二进制数关联,从而让计算机能够存储和处理这些字符。
我们可以把编码方式理解为一本字典,我们需要这本字典把我们人类的语言翻译为计算机的二进制语言,反之亦然。
这本字典很有多版本,常见的是 ASCII 编码和 Unicode 编码。
ASCII 编码
ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)是一种早期的字符编码方案,它使用7个比特来表示字符,最多支持128个字符,主要涵盖了英文字母、数字以及一些常用的控制字符。
例如,字母'A'的ASCII码是65(二进制表示为01000010),字母'a'的ASCII码是97(二进制表示为01100001)。它最初是为英语设计的,因此只支持英文字符和一些符号。
Unicode编码
随着计算机应用的全球化,ASCII仅支持英语字符的局限性逐渐显现出来。为了解决这个问题,Unicode应运而生。Unicode也是一种字符编码标准,旨在为世界上所有语言的字符提供唯一的编码。
无论是拉丁字母、汉字还是符号,它为每一个字符分配了一个独特的二进制数,也叫做代码点。 Unicode编码可以支持超过百万个字符,解决了多种语言混用时的编码问题。
UTF-8是一种字符编码方式,它是Unicode的一种实现,可以理解为这本“字典”在计算机中的表示方式。UTF-8特别的地方在于它是变长的,这意味着不同的字符可能会用不同长度的字节来表示。 例如,英文字符可能只需要1个字节,而一些特殊字符可能需要3个或4个字节。
这样做的好处是它兼容ASCII码,也就是原来那本ASCII码“字典”的内容也能使用。
值得一提的是,被转换的整数值应该需要代表一个有效的 Unicode 代码点,否则转换的结果就将会是�,我相信很多小伙伴遇到过这种情况。
另外,在Go语言中,源代码文件(.go文件)需要以UTF-8编码来保存。如果你的源代码文件里包含了不是UTF-8编码的字符,那么在进行构建、安装或者运行时,go工具会提示一个“illegal UTF-8 encoding”的错误。
如何在Go中定义string?
方式一:
采用双引号赋值字符串,其中的转义字符(\t和\n)会进行转义,也就是被计算机解读为增加几个空格和换行。
方式二:
采用反引号定义的字符串中,转义字符不会进行转义,也就是定义成啥样就是啥样。
Tips
- 反引号可创建多行字符串。
- 若双引号字符串需要换行,则需要将拼接符放在末尾,实现跳行的字符串拼接,最终结果为"hellostring"
string在底层如何表示?
首先,源码包在src/runtime/string.go: stringStruct中定义了string的数据结构:
可看到,string底层包含一个指向字节数组的指针以及一个表示字符串长度的变量。
另外,也可从源码src/builtin/ builtin.go中的对string类型的描述可知,string是一个保存在字节数组中的文本字符串,一般是utf-8格式,但并不绝对。需要注意的是,string类型是不可修改的。
这么看有点抽象,我们来通过一个例子来理解文本字符串是如何在string类型变量中存储的。 首先定义“阿煜Go”字符串,现在我们知道该字符串底层就是一个字节数组,所以其可按照字节byte类型(1个字节)进行拆分,也可按照rune类型(4个字节)进行拆分。
用rune拆分时,无论是中文还是英文都会用rune类型表示。
rune类型是int32类型的别称,即四个字节。一个rune类型的值其实就是一个 UTF-8 编码值。
通过上图可以清晰的看到,字符串在底层会转换为UTF-8编码值,并以字节序列存储,即[e9 98 bf e7 85 9c 47 6f]。
使用string要注意什么?
- 通过string下标访问中文时,需要转为rune序列,否则结果为乱码。
- 带有range子句的for语句会把字符串值拆成一个字节序列,然后找出这个字节序列中包含的每一个UTF-8编码值,即每一个 Unicode 字符。
我之前也很困惑,为什么遍历时的下标对应不上,这其实是go的优化,其内在机制是跳过了一个unicode字符。
string创建过程
var str string
str = "Hello Ayu Go"
之前,我们已经知道如何定义和初始化string,那么string具体是如何构建的呢?
字符串构建会调用$GOROOT/src /runtime/string.go中gostringnocopy方法来创建字符串,它接收一个指向byte类型的指针作为参数,并返回一个string类型的值。
func gostringnocopy(str *byte) string {
ss := stringStruct{str: unsafe.Pointer(str), len: findnull(str)}
s := *(*string)(unsafe.Pointer(&ss))
return s
}
unsafe.Pointer用于将*byte类型的指针转换为unsafe.Pointer,从而允许我们绕过Go的安全检查机制,直接访问内存中的数据。
findnull(str)是一个辅助函数,它的作用是查找从str开始的第一个空字符(即ASCII码为0),以此确定字符串的实际长度。
最后给s赋值的操作实际上是在告诉编译器如何解释ss所代表的内存布局,使其被视为一个合法的string对象。
为什么需要stringStruct?
你是不是好奇,为什么不直接创建string?反而要绕一圈,先创建stringStruct之后,再转换为string?
type stringStruct struct {
str unsafe.Pointer
len int
}
stringStruct就像是string的图纸,相当于字节序列和string的中间层,它包含两个关键信息:
- 指向数据的指针:告诉你字符串的实际内容在哪里。
- 长度:告诉你字符串有多长。
有了这个中间层最大的作用是,当你需要将一个大的字符串切片成多个小字符串时,如果每次都复制整个字符串的数据,将会非常低效,而操作stringStruct只需要调整指针和长度即可,提高了性能。
当然,以上都是Go运行时内部实现,我们用户只需要看到string类型的值,而不需要了解后面的实现细节。
Go是否存在string常量池?
首先,我们来做一个实验验证下Go是否存在常量池。让我们定义两个相同的字符串,通过观察他们的地址来确定是否是同一字符串。
str1 := "Hello Go"
str2 := "Hello Go"
sh1 := (*reflect.StringHeader)(unsafe.Pointer(&str1))
sh2 := (*reflect.StringHeader)(unsafe.Pointer(&str2))
//stringStruct地址
fmt.Println(&str1, &str2)
// 输出 0xc00002c420 0xc00002c430
// 底层数组地址
fmt.Println(sh1.Data, sh2.Data)
fmt.Println(sh1.Data == sh2.Data)
// 输出 10990392 10990392
// 输出 true
可以看到创建的两个string不是同一stringStruct,但是在底层共享同一个字符数组,所以Go虽然没有明确的常量池机制,但是Go编译器会对字符常量进行优化。
如何修改string?
如果要修改string如何操作?我们在 《从零理解Go语言string(上)》 中已经知道,string类型一旦初始化后,是不可更改的。我们现在先尝试一下直接修改string。
str:="Hello Go"
str[0]="X"
//报错:
// cannot assign to str[0] (neither addressable nor a map index expression)
可以看到报错:当前地址既是不可寻址的,也不是字典索引表达式。可见,我们不能直接修改string,那么应该如何正确的修改string呢?
string与[]byte的相互转换
要实现string的修改,可以先将string转为[]byte修改后,再转为string。
str:="Hello Go"
bytes:=[]byte(str)
bytes[0]='X'
str = string(bytes)
fmt.Println(str)
//输出
//Xello Go
值得一提的是,这样的转换存在内存拷贝,例如从[]byte转换为string存在如下图所示的过程,反之亦然:
图中*array和*str都是unsafe.Pointer类型。
为什么不允许修改string?
想要修改字符串也太麻烦了,不如重新创建新的字符串,从底层上来看也是这样。Go为什么要这样设置呢?
主要原因是,由于字符串内容固定不变,Go可以安全地共享相同的字符串实例,减少不必要的内存复制。例如子串提取和拼接可以通过简单的指针调整实现,而无需复制整个数据,显著提升了处理大字符串或频繁操作时的性能。
字符串五大拼接方法
- 加号拼接
最容易想到的方式是通过加号进行拼接。
str1:="Hello"
str2:="Go"
str:=str1+str2
fmt.Println(str)
//输出:"HelloGo"
使用+拼接时,底层会新开辟一块空间,将两个str的内容进行复制,最终将复制的str合并在新的空间中。所以,对于频繁的字符串拼接来说效率不高。
- fmt.Sprintf
fmt.Sprintf是格式化输出函数之一,它可以接受一个格式化字符串和一系列参数,然后返回一个格式化后的字符串。这种方式非常适合需要插入变量或者控制输出格式的场合。
str1:="Hello"
str2:="Go"
str:= fmt.Sprintf("%s%s", str1, str2)
fmt.Println(str)
//输出:"HelloGo"
然而,由于它涉及到解析格式化字符串,性能上会有一定影响。特别是当使用%v或%+v等通用格式化动词时,它需要了解传递给它的值的实际类型以便正确地进行格式化。为了做到这一点,fmt包会使用到反射,对大量数据进行操作时,会对性能会有较大的影响。
- strings.Builder
strings.Builder是Go1.10引入的一个高效可变字符串缓冲区,特别适合于需要进行多次追加操作的场景。
str1:="Hello"
str2:="Go"
var sb strings.Builder
sb.WriteString(str1)
sb.WriteString(str2)
str:= sb.String()
fmt.Println(str)
//输出:"HelloGo"
strings.Builder内部维护一个buf字节切片来存储数据,并提供了高效的写入方法。
type Builder struct {
addr *Builder
buf []byte
}
至于addr指针,主要作用是用于检测当前builder实例是否被复制,若被复制证明同时存在两个builder向一个buf写值,会触发panic。
与+和fmt.Sprintf相比,strings. Builder在处理大量字符串拼接时性能更好,因为它提取分配了buf内存,减少了内存分配次数。
- strings.Join
strings.Join接受一个字符串切片和一个分隔符作为参数,然后将切片中的所有元素用分隔符连接成一个单一的字符串。
当我们有一个已经分割好的字符串列表并且想要用特定字符连接它们时,该方式是最理想选择。
str1:="Hello"
str2:="Go"
strs := []string{str1, str2}
str = strings.Join(strs, "")
fmt.Println(str)
//输出:"HelloGo"
strings.Join是基于strings.builder来实现的,并且可以自定义分隔符,能提前预分配buf的空间,减少了内存分配消耗的性能
- bytes.Buffer
bytes.Buffer是一个实现了io.Writer接口的类型,它可以像strings.Builder一样被用来构建字符串,但它实际上是为处理字节流设计的。
type Buffer struct {
buf []byte // contents are the bytes buf[off : len(buf)]
off int // read at &buf[off], write at &buf[len(buf)]
...
}
在bytes.Buffer也存在字节切片buf,通过WriteString方法在底层的[]byte切片中进行拼接。
str1:="Hello"
str2:="Go"
var buffer bytes.Buffer
buffer.WriteString(str1)
buffer.WriteString(str2)
str:= buffer.String()
fmt.Println(str)
//输出:"HelloGo"
需要注意的是,bytes.Buffer的性能略逊于strings.Builder,因为它不是专门为字符串拼接优化的。
您可能花了5分钟阅读本片文章,但我却花了5天时间整理、验证和书写,各位小伙伴可以帮我点点赞,或者在评论区给我留言讨论,也可以关注我一下,这将是对程序员阿煜产出优质内容的莫大的鼓励~
关注程序员阿煜,轻松掌握关键知识!
最近整理了一下之前学习的资料,涵盖操作系统、计算机网络、AI、云计算等,如果有需要的小伙伴可以通过下面二维码联系我,免费分享,帮助大家节省时间。