简单谈谈Golang中的字符串与字节数组
前⾔
字符串是 Go 语⾔中最常⽤的基础数据类型之⼀,虽然字符串往往都被看做是⼀个整体,但是实际上字符串是⼀⽚连续的内存空间,我们也可以将它理解成⼀个由字符组成的数组,Go 语⾔中另外⼀个与字符串关系⾮常密切的类型就是字节(Byte)了,相信各位读者也都⾮常了解,这⾥也就不展开介绍。
我们在这⼀节中就会详细介绍这两种基本类型的实现原理以及它们的转换关系,但是这⾥还是会将介绍的重点主要放在字符串上,因为这是我们接触最多的⼀种基本类型并且后者就是⼀个简单的 uint8 类型,所以会给予 string 最⼤的篇幅,需要注意的是这篇⽂章不会使⽤⼤量的篇幅介绍 UTD-8 以及编码等知识,主要关注的还是字符串的结构以及常见操作的实现。
字符串虽然在 Go 语⾔中是基本类型 string ,但是它其实就是字符组成的数组,C 语⾔中的字符串就可以⽤ char[] 来表⽰,作为数组来说它会占⽤⼀⽚连续的内存空间,这⽚连续的内存空间就存储了⼀些字节,这些字节共同组成了字符串, Go 语⾔中的字符串是⼀个只读的字节数组切⽚,下⾯就是⼀个只读的 "hello" 字符串在内存中的结构:
如果是代码中存在的字符串,会在编译期间被标记成只读数据 SRODATA 符号,假设我们有以下的⼀段代
码,其中包含了⼀个字符串,当我们将这段代码编译成汇编语⾔时,就能够看到 hello 字符串有⼀个 SRODATA 的标记:
$
package main
func main() {
str := "hello"
println([]byte(str))
}
$ GOOS=linux GOARCH=amd64 go tool compile -
...
go.string."hello" SRODATA dupok size=5
0x0000 68 65 6c 6c 6f    hello
...
不过这只能表明编译期间存在的字符串会被直接分配到只读的内存空间并且这段内存不会被更改,但是在运⾏时我们其实还是可以将这段内存拷贝到其他的堆或者栈上,同时将变量的类型修改成 []byte 在修改之后再通过类型转换变成 string ,不过如果想要直接修改 string 类型变量的内存空间,Go 语⾔是不⽀持这种操作的。
除了今天的主⾓字符串之外,另外的配⾓ byte 也还是需要简单介绍⼀下的,byte 其实⾮常好理解,每⼀个 byte 就是 8 个bit,相信稍微对编程有所了解的⼈应该都对这个概念⼀清⼆楚,⽽字节数组也没什么值得介绍的,所以这⾥就直接跳过了。
字符串在 Go 语⾔中的接⼝其实⾮常简单,每⼀个字符串在运⾏时都会使⽤如下的 StringHeader 结构体去表⽰,在运⾏时包的内部其实有⼀个私有的结构 stringHeader ,它有着完全相同的结构只是⽤于存储数据的 Data 字段使⽤了 unsafe.Pointer 类型:
type StringHeader struct {
Data uintptr
Len int
}
为什么我们会说字符串其实是⼀个只读类型的切⽚呢,我们可以看⼀下切⽚在 Go 语⾔中的运⾏时表⽰:
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
这个表⽰切⽚的结构 SliceHeader 和字符串的结构 StringHeader ⾮常类似,与切⽚的结构相⽐,字符串少了⼀个表⽰容量的Cap 字段,这是因为字符串作为只读的类型,我们并不会直接向字符串直接追加元素改变其本⾝的内存空间,所有追加的操作都是通过拷贝来完成的。
字符串的解析⼀定是解析器在词法分析时就完成的,词法分析阶段会对源⽂件中的字符串进⾏切⽚和分
组,将原有⽆意义的字符流转换成 Token 序列,在 Go 语⾔中,有两种字⾯量的⽅式可以声明⼀个字符串,⼀种是使⽤双引号,另⼀种是使⽤反引号:
str1 := "this is a string"
str2 := `this is another
string`
使⽤双引号声明的字符串其实和其他语⾔中的字符串没有太多的区别,它只能⽤于简单、单⾏的字符串并且如果字符串内部出现双引号时需要使⽤ \ 符号避免编译器的解析错误,⽽反引号声明的字符串就可以摆脱单⾏的限制,因为双引号不再标记字符串的开始和结束,我们可以在字符串内部直接使⽤ " ,在遇到需要写 JSON 或者其他数据格式的场景下⾮常⽅便。
两种不同的声明⽅式其实也意味着 Go 语⾔的编译器需要在解析的阶段能够区分并且正确解析这两种不同的字符串格式,解析字符串使⽤的 scanner 扫描器,它的主要作⽤就是将输⼊的字符流转换成 Token 流, stdString ⽅法就是它⽤来解析使⽤双引号包裹的标准字符串:
func (s *scanner) stdString() {
s.startLit()
for {
r := s.getr()
if r == '"' {
break
}
if r == '\\' {
s.escape('"')
continue
}
if r == '\n' {
s.ungetr()
<("newline in string")
break
}
if r < 0 {
break
}
}
s.nlsemi = true
s.lit = string(s.stopLit())
s.kind = StringLit
}
从这个⽅法中我们其实能够看出 Go 语⾔处理标准字符串的逻辑:
1.标准字符串使⽤双引号表⽰开头和结尾;
2. 标准字符串中需要使⽤反斜杠 \ 来 escape 双引号;
3. 标准字符串中不能出现换⾏符号 \n ;
原始字符串解析的规则就⾮常简单了,它会将⾮反引号的所有字符都划分到当前字符串的范围中,所以我们可以使⽤它来⽀持复杂的多⾏字符串字⾯量,例如 JSON 等数据格式。
func (s *scanner) rawString() {
s.startLit()
for {
r := s.getr()
if r == '`' {
break
}
if r < 0 {
break
}
}
s.nlsemi = true
s.lit = string(s.stopLit())
s.kind = StringLit
}
⽆论是标准字符串还是原始字符串最终都会被标记成 StringLit 类型的 Token 并传递到编译的下⼀个阶段 —语法分析,在语法分析的阶段,与字符串相关的表达式都会使⽤如下的⽅法 BasicLit 对字符串进⾏处理:
func (p *noder) basicLit(lit *syntax.BasicLit) Val {
switch s := lit.Value; lit.Kind {
case syntax.StringLit:
if len(s) > 0 && s[0] == '`' {
s = strings.Replace(s, "\r", "", -1)
}
u, _ := strconv.Unquote(s)
return Val{U: u}
}
}
⽆论是 import 语句中包的路径、结构体中的字段标签还是表达式中的字符串都会使⽤这个⽅法将原⽣字符串中最后的换⾏符删除并对字符串 Token 进⾏ Unquote,也就是去掉字符串两遍的引号等⽆关⼲扰,还原其本来的⾯⽬。
strconv.Unquote ⽅法处理了很多边界条件导致整个函数⾮常复杂,不仅包括各种不同引号的处理,还包括 UTF-8 等编码的相关问题,所以在这⾥也就不展开介绍了,感兴趣的读者可以在 Go 语⾔中到 strconv.Unquote ⽅法详细了解它的执⾏过程。
介绍完了字符串的的解析过程,这⼀节就会继续介绍字符串的常见操作了,我们在这⾥要介绍的字符串常见操作包括字符串的拼接和类型转换,字符串相关功能的主要是通过 Go 语⾔运⾏时或者 strings 包完成的,我们会重点介绍运⾏时字符串的操作,想要了解 strings 包的读者可以阅读相关的代码,这⾥就不多介绍了。
Go 语⾔中拼接字符串会使⽤ + 符号,当我们使⽤这个符号对字符串进⾏拼接时,编译器会在类型检查
阶段将 OADD 节点转换成 OADDSTR 类型的节点,随后在 SSA 中间代码⽣成的阶段调⽤ addstr 函数:
func walkexpr(n *Node, init *Nodes) *Node {
switch n.Op {
// ...
case OADDSTR:
n = addstr(n, init)
}
}
addstr 函数就是帮助我们在编译期间选择合适的函数对字符串进⾏拼接,如果需要拼接的字符串⼩于或者等于 5 个,那么就会直接调⽤ concatstring{2,3,4,5} 等⼀系列函数,如果超过 5 个就会直接选择 concatstrings 传⼊⼀个数组切⽚。
func addstr(n *Node, init *Nodes) *Node {
c := n.List.Len()
buf := nodnil()
args := []*Node{buf}
for _, n2 := range n.List.Slice() {
args = append(args, conv(n2, types.Types[TSTRING]))
}
var fn string
if c <= 5 {
fn = fmt.Sprintf("concatstring%d", c)
} else {
fn = "concatstrings"
t := types.NewSlice(types.Types[TSTRING])
slice := nod(OCOMPLIT, nil, typenod(t))
slice.List.Set(args[1:])
args = []*Node{buf, slice}
}
cat := syslook(fn)
r := nod(OCALL, cat, nil)
r.List.Set(args)
// ...
return r
}
其实⽆论使⽤ concatstring{2,3,4,5} 中的哪⼀个,最终都会调⽤ concatstrings ,在这个函数中我们会先对传⼊的切⽚参数进⾏遍历,⾸先会过滤空字符串并获取拼接后字符串的长度。
func concatstrings(buf *tmpBuf, a []string) string {
idx := 0
l := 0
count := 0
for i, x := range a {
n := len(x)
if n == 0 {
continue
}
throw("string concatenation too long")
}
l += n
count++
idx = i
}
if count == 0 {
return ""
}
if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
return a[idx]
}
s, b := rawstringtmp(buf, l)
for _, x := range a {
copy(b, x)
b = b[len(x):]
}
return s
}
如果⾮空字符串的数量为 1 并且当前的字符串不在栈上或者没有逃逸出调⽤堆栈,那么就可以直接返回该字符串,不需要进⾏任何的耗时操作。
但是在正常情况下,原始的多个字符串都会被调⽤ copy 将所有的字符串拷贝到⽬标字符串所在的内存空间中,新的字符串其实就是⼀⽚新的内存空间,与原来的字符串没有任何关联。
类型转换
当我们使⽤ Go 语⾔做⼀些 JSON 等数据格式的解析和序列化时,可能经常会将这些变量在字符串和字节数组之间来回转换,类型之间转换的开销并没有想象的这么⼩,我们经常会看到 slicebytetostring 等函数出现在⽕焰图中,这个函数就是将字节数组转换成字符串所使⽤的函数,也就是⼀个类似 string(bytes) 的操作会在编译期间转换成 slicebytetostring 的函数调⽤,这个函数在函数体中⾸先会处理两种⽐较常见的情况,也就是字节长度为 0 或者 1 的情况:
func slicebytetostring(buf *tmpBuf, b []byte) (str string) {
l := len(b)
if l == 0 {
return ""
}
if l == 1 {
stringStructOf(&str).str = unsafe.Pointer(&staticbytes[b[0]])
stringStructOf(&str).len = 1
return
}
var p unsafe.Pointer
if buf != nil && len(b) <= len(buf) {
p = unsafe.Pointer(buf)
} else {
p = mallocgc(uintptr(len(b)), nil, false)
}
stringStructOf(&str).str = p
stringStructOf(&str).len = len(b)
memmove(p, (*(*slice)(unsafe.Pointer(&b))).array, uintptr(len(b)))go语言字符串转数组
return
}
处理过后会根据传⼊的缓冲区⼤⼩决定是否需要为新的字符串分配⼀⽚内存空间, stringStructOf 会将传⼊的字符串指针转换成 stringStruct 结构体指针,然后设置结构体持有的指针 str 和字符串长度 len ,最后通过 memmove 将原字节数组中的字节全部复制到新的内存空间中。
从字符串到字节数组的转换使⽤的就是 stringtoslicebyte 函数了,这个函数的实现⾮常简单:
func stringtoslicebyte(buf *tmpBuf, s string) []byte {
var b []byte
if buf != nil && len(s) <= len(buf) {
*buf = tmpBuf{}
b = buf[:len(s)]
} else {
b = rawbyteslice(len(s))
}
copy(b, s)
}
它会使⽤传⼊的缓冲区或者根据字符串的长度调⽤ rawbyteslice 创建⼀个新的字节切⽚, copy 关键字就会将字符串中的内容拷贝到新的字节数组中。
字符串和字节数组中的内容虽然⼀样,但是字符串的内容是只读的,我们不能通过下标或者其他形式改变其内存存储的数据,⽽字节切⽚中的内容都是可以读写的,所以⽆论是从哪种类型转换到另⼀种都需要对其中的内容进⾏拷贝,内存拷贝的性能损耗会随着字符串数组和字节长度的增长⽽增长,所以在做这种类型转换时⼀定要注意性能上的问题。
字符串是 Go 语⾔中相对来说⽐较简单的⼀种数据结构,作为只读的数据类型,我们⽆法改变其本⾝的结构,但是在做类型转换的操作时⼀定要注意性能上的瓶颈,遇到需要极致性能的场景⼀定要尽量减少不同类型的转换,避免额外的开销。
相关⽂章
本作品采⽤进⾏许可。转载时请注明原⽂链接,图⽚在使⽤时请保留图⽚中的全部内容,可适当缩放并在引⽤处附上图⽚所在的⽂章链接,图⽚使⽤ Sketch 进⾏绘制。如果对本⽂的内容有疑问,请在下⾯的评论系统中留⾔,谢谢。
好了,以上就是这篇⽂章的全部内容了,希望本⽂的内容对⼤家的学习或者⼯作具有⼀定的参考学习价值,谢谢⼤家对的⽀持。

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。