string
string
string
是go中一个非常常见的基础数据类型,也是我在go语言中接触到的第一个数据类型
package main
import "fmt"
func main() {
fmt.Println("hello,world!")
}
相信这段代码大多数人在刚接触go时都有敲过。在builtin/builtin.go
中有关于string
的简单描述
// 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
是8位字节的集合string
类型通常是UTF-8
编码string
可以是空的,但不会是nil
string
是不可变的
这几个特点对于经常使用go的人来说应该早就了熟于心了,那么下面就来看点不一样的。
结构
在go中,字符串在运行时由runtime.stringStruct
结构体表示,不过它并不对外暴露,作为替代可以使用reflect.StringHeader
。
提示
虽然StringHeader
在版本go.1.21
已经被废弃了,不过它确实很直观,下面的内容还是会使用它来进行讲述,并不影响理解,详情见Issues · golang/go (github.com)。
// runtime/string.go
type stringStruct struct {
str unsafe.Pointer
len int
}
// reflect/value.go
type StringHeader struct {
Data uintptr
Len int
}
其中的字段释义如下
Data
,是一个指向字符串内存起始地址的指针Len
,字符串的字节数
下面是一个通过unsafe指针访问字符串地址的例子
func main() {
str := "hello,world!"
h := *((*reflect.StringHeader)(unsafe.Pointer(&str)))
for i := 0; i < h.Len; i++ {
fmt.Printf("%s ", string(*((*byte)(unsafe.Add(unsafe.Pointer(h.Data), uintptr(i)*unsafe.Sizeof(str[0]))))))
}
}
不过go现在推荐使用unsafe.StringData
来代替
func main() {
str := "hello,world!"
ptr := unsafe.Pointer(unsafe.StringData(str))
for i := 0; i < len(str); i++ {
fmt.Printf("%s ", string(*((*byte)(unsafe.Add(ptr, uintptr(i)*unsafe.Sizeof(str[0]))))))
}
}
两者输出都是一样的
h e l l o , w o r l d !
字符串其本质上就是一片连续的内存地址,每一个地址上都存储着一个字节,换句话说就是一个字节数组,通过len
函数获取的结果是字节的数量,而非字符串中字符的数量,当字符串中的字符是非ASCII字符是尤其如此。
string
本身只占很小的内存即一个指向真实数据的指针,这样一来传递字符串的成本就会非常低。个人认为,由于只持有一个内存的引用,如果可以被随意修改的话,日后很难知道原来的指向是否还是想要的数据(要么使用反射要么使用unsafe
包),除非说旧数据的使用者在使用过后永远不再需要这个字符串,另一个优点就是天生并发安全,任何人在常规情况下都无法对其进行修改。
拼接
字符串的拼接语法如下所示,直接使用+
运算符进行拼接。
var (
hello = "hello"
dot = ","
world = "world"
last = "!"
)
str := hello + dot + world + last
拼接的操作在运行时由runtime.concatstrings
函数完成,如果是下面这种字面量拼接,编译器会直接推断出结果。
str := "hello" + "," + "world" + "!"
_ = str
通过输出其汇编代码就能知道结果,部分如下所示
LEAQ go:string."hello,world!"(SB), AX
MOVQ AX, main.str(SP)
很显然的是编译器直接将其视作一个完整的字符串,其值在编译期就已经确定了,并不会由runtime.concatstrings
在运行时来拼接,只有拼接字符串变量才会在运行时完成,其函数签名如下,它接收一个字节数组和一个字符串切片。
func concatstrings(buf *tmpBuf, a []string) string
当拼接的字符串变量小于5时,会使用下面的函数代替(个人猜测:由参数和匿名变量传递,它们都是存在栈上,相比于运行时创建的切片更好GC?),虽然其最后还是由concatstrings
来完成拼接。
func concatstring2(buf *tmpBuf, a0, a1 string) string {
return concatstrings(buf, []string{a0, a1})
}
func concatstring3(buf *tmpBuf, a0, a1, a2 string) string {
return concatstrings(buf, []string{a0, a1, a2})
}
func concatstring4(buf *tmpBuf, a0, a1, a2, a3 string) string {
return concatstrings(buf, []string{a0, a1, a2, a3})
}
func concatstring5(buf *tmpBuf, a0, a1, a2, a3, a4 string) string {
return concatstrings(buf, []string{a0, a1, a2, a3, a4})
}
下面来看看concatstrings
函数里面干了些什么
func concatstrings(buf *tmpBuf, a []string) string {
idx := 0
l := 0
count := 0
for i, x := range a {
n := len(x)
// 长度为0跳过
if n == 0 {
continue
}
// 数值计算溢出
if l+n < l {
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
}
首先做的事情是统计要拼接字符串的总长度和数量,然后根据总长度分配内存,rawstringtmp
函数会返回一个字符串s
和字节切片b
,虽然其长度是确定的但它们没有任何内容,因为它们本质上是两个指向新内存地址的指针,分配内存的代码如下
func rawstring(size int) (s string, b []byte) {
// 没有指定类型
p := mallocgc(uintptr(size), nil, false)
// 虽然分配了内存但是上面什么都没有
return unsafe.String((*byte)(p), size), unsafe.Slice((*byte)(p), size)
}
返回的字符串s
是为了方便表示,字节切片b
是为了方便修改字符串,它们两个指向的都是同一个内存地址。
for _, x := range a {
// 复制
copy(b, x)
// 截断
b = b[len(x):]
}
copy
函数在运行时调用的是runtime.slicecopy
,它所做的工作就是直接把src
的内存直接复制到dst
的地址,所有字符串都复制完毕后,整个拼接过程也就结束了。倘若复制的字符串非常大,这个过程将会相当消耗性能。
转换
前面提到过,字符串本身是不可以修改的,如果尝试修改连编译都没法通过,go会如下报错
str := "hello" + "," + "world" + "!"
str[0] = '1'
cannot assign to string (neither addressable nor a map index expression)
想要修改字符串的话,就需要先将其类型转换至字节切片[]byte
,使用起来很简单
bs := []byte(str)
其内部调用了函数runtime.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)
return b
}
如果字符串长度小于缓冲区长度的话就直接返回缓冲区的字节切片,这样在小字符串转换的时候可以节省内存。否则的话,就会开辟一片与字符串长度相当的内存,然后将字符串复制到新的内存地址中,其中函数rawbyteslice(len(s))
所做的事与之前rawstring
函数类似,都是分配内存。
同样的,字节切片在语法上也可以很轻易的转换成字符串
str := string([]byte{'h','e','l','l','o'})
其内部调用的是runtime.slicebytetostring
函数,也很容易理解,代码如下
func slicebytetostring(buf *tmpBuf, ptr *byte, n int) string {
if n == 0 {
return ""
}
if n == 1 {
p := unsafe.Pointer(&staticuint64s[*ptr])
if goarch.BigEndian {
p = add(p, 7)
}
return unsafe.String((*byte)(p), 1)
}
var p unsafe.Pointer
if buf != nil && n <= len(buf) {
p = unsafe.Pointer(buf)
} else {
p = mallocgc(uintptr(n), nil, false)
}
memmove(p, unsafe.Pointer(ptr), uintptr(n))
return unsafe.String((*byte)(p), n)
}
首先处理切片长度为0和1的特殊情况,在这种情况不用进行内存复制。然后就是小于缓冲区长度就用缓冲区的内存,否则就开辟新内存,最后再用memmove
函数把内存直接复制过去,复制过后的内存与源内存没有任何关联,所以可以随意的修改。
值得注意的是,上面两种转换方法,都需要进行内存复制,如果待复制的内存非常大,性能消耗也会很大。在版本更新到go1.20
时,unsafe
包更新了下面几个函数。
// 传入指向内存地址的类型指针和数据长度,返回其切片表达形式
func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType
// 传入一个切片,得到指向其底层数组的指针
func SliceData(slice []ArbitraryType) *ArbitraryType
// 根据传入的地址和长度,返回字符串
func String(ptr *byte, len IntegerType) string
// 传入一个字符串,返回其起始内存地址,不过返回的字节不能被修改
func StringData(str string) *byte
尤其是String
和StringData
函数,它们并不涉及内存复制,也可以完成转换,不过需要注意的是,使用它们的前提是,得确保数据是只读的,后续不会有任何修改,否则的话字符串就会发生变化,看下面的例子。
func main() {
bs := []byte("hello,world!")
s := unsafe.String((*byte)(unsafe.SliceData(bs)), len(bs))
bs[0] = 'b'
fmt.Println(s)
}
首先通过SliceData
获取字节切片的底层数组的地址,然后通过String
获取其字符串表达形式,后续再直接修改字节切片,字符串同样也会发生变化,这显然违背了字符串的初衷。再来看个例子
func main() {
str := "hello,world!"
bytes := unsafe.Slice(unsafe.StringData(str), len(str))
fmt.Println(bytes)
// fatal
bytes[0] = 'b'
fmt.Println(str)
}
获取了字符串其切片表达形式后,如果尝试修改字节切片,就会直接fatal
,下面换个声明字符串的方式看看有什么区别。
func main() {
var str string
fmt.Scanln(&str)
bytes := unsafe.Slice(unsafe.StringData(str), len(str))
fmt.Println(bytes)
bytes[0] = 'b'
fmt.Println(str)
}
hello,world!
[104 101 108 108 111 44 119 111 114 108 100 33]
bello,world!
从结果可以看出来,确实修改成功了。之前所以fatal
,在于变量str
存储的是字符串字面量,字符串字面量都存储在只读数据段,而非堆栈,从根本上就断绝了字面量声明的字符串后续会被修改的可能性,对于一个普通的字符串变量而言,本质上来说它确实可以被修改,但是这种写法编译器不允许。总之,使用unsafe
函数来操作字符串转换并不安全,除非能保证永远不会对数据进行修改。
遍历
s := "hello world!"
for i, r := range s {
fmt.Println(i, r)
}
为了处理多字节字符的情况,遍历字符串一般会使用for range
循环。当使用for range
遍历字符串时,编译器会在编译期间展开成如下形式的代码
ha := s
for hv1 := 0; hv1 < len(ha); {
hv1t := hv1
hv2 := rune(ha[hv1])
// 判断是否是单字节字符
if hv2 < utf8.RuneSelf {
hv1++
} else {
hv2, hv1 = decoderune(ha, hv1)
}
i, r = hv1t, hv2
// 循环体
}
在展开的代码中,for range
循环会替换成经典的for
循环,在循环中,会判断当前字节是否是单字节字符,如果是多字节字符的话会调用运行时函数runtime.decoderune
来获取其完整编码,然后再赋值给i
,r
,处理完过后就到了源代码中定义的循环体执行。
负责构造中间代码的工作由cmd/compile/internal/walk/range.go
中的walkRange
函数来完成,同时它也负责处理所有能被for range
遍历的类型,这里就不展开了,感兴趣的可以自己去了解。