简单说说golang中的string

现象

最近使用bytes.Buffer的过程中发现了一个有意思的问题

1
2
3
4
5
6
7
8
var b bytes.Buffer
b.WriteString("hello")
var val = b.Bytes()
t.Log(string(val)) // hello
b.Reset()
b.WriteString("bye")
t.Log(string(val)) //byelo

在对bytes.Buffer进行Reset操作后,依然还可以影响到之前的数据。如果改为用String方法:

1
2
3
4
5
6
7
var b bytes.Buffer
b.WriteString("hello")
var val = b.String()
t.Log(val) // hello
b.Reset()
b.WriteString("bye")
t.Log(val) // hello

原理

首先看看String和Bytes方法的定义

1
func (b *Buffer) Bytes() []byte { return b.buf[b.off:] }
1
2
3
4
5
6
func (b *Buffer) String() string {
if b == nil {
return "<nil>"
}
return string(b.buf[b.off:])
}

抛开处理nil情况的分支,其实只是进行了一次将[]byte转换为string的操作,仅仅多了这一次转换,就能造成结果的不同吗?

要弄懂原因,首先要看Reset方法到底reset了什么

1
2
3
4
5
6
7
8
// Reset resets the buffer to be empty,
// but it retains the underlying storage for use by future writes.
// Reset is the same as Truncate(0).
func (b *Buffer) Reset() {
b.buf = b.buf[:0]
b.off = 0
b.lastRead = opInvalid
}

从代码和注释可以了解到,Reset方法只会将内部b.buf进行re-slice,reset后依旧是复用同一个底层数组

string 和 slice 在运行时的数据结构表示如下

1
2
3
4
5
6
7
8
9
10
type StringHeader struct {
Data uintptr
Len int
}

type SliceHeader struct {
Data uintptr
Len int
Cap int
}

与slice相比,string结构中没有表示容量的Cap字段,这是由于golang中的string是不可变的,golang不支持直接修改string类型变量的内存空间,只能通过改变string类型变量指向的内存空间来达成“修改”内容的目的。即,runtime.stringStruct中的str指针针指向的内容是不可改变的,但指针本身可以改变

当我们通过string([]byte)将[]byte转换为string时, 背后将调用runtime.slicebytetostring 函数,该函数会根据传入缓冲区的大小决定是否要分配一个新的内存空间,runtime.stringStructOf 会将字符串指针转化为runtime.stringStruct结构体指针,再设置了字符串指针p和长度n之后调用memmove将原[]byte中的字节copy到新的内存中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
func slicebytetostring(buf *tmpBuf, ptr *byte, n int) (str string) {
if n == 0 {
return ""
}
if raceenabled {
racereadrangepc(unsafe.Pointer(ptr),
uintptr(n),
getcallerpc(),
abi.FuncPCABIInternal(slicebytetostring))
}
if msanenabled {
msanread(unsafe.Pointer(ptr), uintptr(n))
}
if asanenabled {
asanread(unsafe.Pointer(ptr), uintptr(n))
}
if n == 1 {
p := unsafe.Pointer(&staticuint64s[*ptr])
if goarch.BigEndian {
p = add(p, 7)
}
stringStructOf(&str).str = p
stringStructOf(&str).len = 1
return
}

var p unsafe.Pointer
if buf != nil && n <= len(buf) {
p = unsafe.Pointer(buf)
} else {
p = mallocgc(uintptr(n), nil, false)
}
stringStructOf(&str).str = p
stringStructOf(&str).len = n
memmove(p, unsafe.Pointer(ptr), uintptr(n))
return
}

所以Buffer.String方法实际上进行了一次Copy操作,因此之后对底层数组的修改并不会反映在其之上

strings.Builder

如果不想在[]byte转换为string时进行额外的内存分配,可以使用strings.Builderstrings.Builderbytes.Buffer的底层实现基本类似,两者差别主要是String / Reset方法:

1
2
3
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}

String方法使用指针操作,丢弃SliceHeader结构的Cap字段,将这一块内存布局解释为StringHeader,因此不会有额外内存分配

1
2
3
4
func (b *Builder) Reset() {
b.addr = nil
b.buf = nil
}

Reset方法将b.buf置为空,因此reset之后新的写入操作并不是对同一片地址。这也是和bytes.Buffer的不同之处

bytes.Buffer相比,string.Builer还有一个限制:为了保证共用buf导致的底层数组污染,strings.BuilderGrow/ Write / WriteRune / WriteString 这几个方法中添加了禁止copy的限制

1
2
3
4
5
6
7
func (b *Builder) copyCheck() {
if b.addr == nil {
b.addr = (*Builder)(noescape(unsafe.Pointer(b)))
} else if b.addr != b {
panic("strings: illegal use of non-zero Builder copied by value")
}
}
1
2
3
4
var b1 strings.Builder
b1.WriteString("hello")
b2 := b1
b2.WriteString("bye") // panic: strings: illegal use of non-zero Builder copied by value

但如果复制后的结构在写入前先进行了Reset操作,则调用Write相关方法也不会panic。因为此时buf已被设置为nil,相关的写入操作已无法对其他结构体中的buf造成影响

1
2
3
4
5
6
7
8
var b1 strings.Builder
b1.WriteString("hello")
b2 := b1
fmt.Println(b2.Len()) // 5
fmt.Println(b2.String()) // hello
b2.Reset()
b2.WriteString("bye")
fmt.Println(b2.String()) // bye