string

寒江蓑笠翁大约 19 分钟

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)open in new window

// 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

尤其是StringStringData 函数,它们并不涉及内存复制,也可以完成转换,不过需要注意的是,使用它们的前提是,得确保数据是只读的,后续不会有任何修改,否则的话字符串就会发生变化,看下面的例子。

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遍历的类型,这里就不展开了,感兴趣的可以自己去了解。