「读书笔记」了解 string 实现原理并高效使用

260 阅读2分钟

声明、类型、语句与控制结构

15 了解 string 实现原理并高效使用

string 功能特点

Go 的 string 类型设计充分吸取了 C 语言字符串设计的经验教训,并结合了其他主流语言字符串类型设计上的最佳实践,具有如下功能特点:

  • string 类型的数据是不可变的。如果将 string 转为 byte 切片修改,Go 编译器会为切片变量重新分配底层存储而不是共用 string 的底层存储;如果直接通过指针修改内存中存储的数据,会得到 SIGBUS 的运行时错误,因为只能对 string 的底层数据存储区进行只读操作。
  • 零值可用。零值为"",长度为 0。
  • 获取长度的时间复杂度是 O(1) 级别。
  • 支持通过 +/+= 操作符进行字符串连接。
  • 支持各种比较关系操作符:==、!=、>=、<=、>、<。如果两字符串长度不相同,则可断定字符串不同;如果长度相同,进一步判断数据指针是否指向同一块底层存储数据;如果相同则两字符串是等价的;如果不同,则需进一步对比实际的数据内容。
  • 对非 ASCII 字符提供原生支持。Go 语言源文件默认采用 Unicode 字符集。
  • 原生支持多行字符串。直接提供了通过反引号构造“所见即所得”的多行字符串。

string 底层实现

Go string 在运行时表示为下面的结构:

type stringStruct struct {
   str unsafe.Pointer
   len int
}

可以看到 string 类型也是一个描述符,它本身并不真正存储数据,而仅是一个指向底层存储的指针和字符串的长度字段组成。实例化一个字符串对应的函数:

func rawstring(size int) (s string, b []byte) {
   p := mallocgc(uintptr(size), nil, false)
​
   stringStructOf(&s).str = p
   stringStructOf(&s).len = size
​
   *(*slice)(unsafe.Pointer(&b)) = slice{p, size, size}
​
   return
}

每个字符串类型变量/常量对应一个 stringStruct 实例,经过 rawstring 实例化后,stringStruct 中的 str 指针指向真正存储字符串数据的底层内存区域,len 字段存储的是字符串的长度;rawstring 同时还创建了一个临时 slice,该 slice 的 array 指针也指向存储字符串数据的底层内存区域。注意,rawstring 调用后,新申请的内存区域还未被写入数据,该 slice 就是供后续运行时层向其中写入数据用的,写完数据后,该 slice 就可以被回收掉了。

根据 string 在运行时的表示可以得到结论:直接将 string 类型通过函数/方法参数传入也不会有太多的损耗,因为传入的仅仅是一个“描述符”,而不是真正的字符串数据。

字符串的高效构造

Go 原生支持通过 +/+= 操作符来连接多个字符串以构造一个更长的字符串,但 Go 还提供了其他一些构造字符串的方法:

  • fmt.Sprintf
  • strings.Join
  • Strings.Builder
  • Bytes.Buffer

通过一个字符串连接基础测试可以得出一些结论:

  • 在能预估出最终字符串长度的情况下,使用预初始化的 strings.Builder 连接构建字符串效率最高;
  • strings.Join 连接构建字符串的平均性能最稳定,如果输入的多个字符串是以 []string 承载的,那么 strings.Join 也是不错的选择;
  • 使用操作符连接的方式最直观、最自然,在编译器知晓欲连接的字符串个数的情况下,使用此种方式可以得到编译器的优化处理;
  • fmt.Sprintf 虽然效率不高,但也不是一无是处,如果是由多种不同类型变量来构建特定格式的字符串,那么这种方式还是最适合的。

字符串相关的高效转换

string 和 []rune、[]byte 可以双向转换。

无论是 string 转 slice 还是 slice 转 string,转换都是要付出代价的,这些代价的根源在于 string 是不可变的,运行时要为转换后的类型分配新内存。

想要更高效地进行转换,唯一的方法就是减少甚至避免额外的内存分配操作。运行时实现转换的函数中已经加入了一些避免每种情况都要分配新内存操作的优化。

slice 类型是不可比较的,而 string 类型是可比较的,因此在日常 Go 编码中,我们会经常遇到将 slice 临时转换为 string 的情况。Go 编译器为这样的场景提供了优化:

func slicebytetostringtmp(ptr *byte, n int) (str string) {
   if raceenabled && n > 0 {
      racereadrangepc(unsafe.Pointer(ptr),
         uintptr(n),
         getcallerpc(),
         abi.FuncPCABIInternal(slicebytetostringtmp))
   }
   if msanenabled && n > 0 {
      msanread(unsafe.Pointer(ptr), uintptr(n))
   }
   if asanenabled && n > 0 {
      asanread(unsafe.Pointer(ptr), uintptr(n))
   }
   stringStructOf(&str).str = unsafe.Pointer(ptr)
   stringStructOf(&str).len = n
   return
}

该函数的秘诀就在于不为 string 新开辟一块内存,而是直接使用 slice 的底层存储。当然使用这个函数的前提是:在原 slice 被修改后,这个 string 不能再被使用了。因此这样的优化是针对以下几个特定场景的:

  • string(b) 用在 map 类型的 key 中

    b := []byte{'k', 'e', 'y'}
    m := make(map[string]string)
    m[string(b)] = "value"
    
  • string(b) 用在字符串连接语句中

    b := []byte{'w', 'o', 'r', 'l', 'd'}
    s := "hello " + string(b) + "!"
    
  • string(b) 用在字符串比较中

    b := []byte{'w', 'o', 'r', 'l', 'd'}
    s := "world"
    if s < string(b) {
       ...
    }
    

Go 编译器对用在 for-range 循环中的 string 到 []byte 的转换也有优化处理,它不会为 []byte 进行额外的内存分配,而是直接使用 string 的底层数据:

// Go 编译器优化,可以节省一次内存分配操作
s := "world"
for _, v := range []byte(s) {
   _ = v
}

此外,Go 语言还在标准库中提供了 strings 和 strconv 包,可以辅助 Gopher 对 string 类型数据进行更多高级操作。

往期回顾

关注我

掘金:XQGang

Github: XQ-Gang

参考

《Go 语言精进之路:从新手到高手的编程思想、方法和技巧》——白明