深度解密 Go 语言的字符串

Go 字符串实现原理

Go 的字符串有个特性,不管长度是多少,大小都是固定的 16 字节。

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    fmt.Println(unsafe.Sizeof("komeiji satori"))  // 16
    fmt.Println(unsafe.Sizeof("satori"))  // 16
}

显然用鼻子也能猜到原因,Go 的字符串底层并没有实际保存这些字符,而是保存了一个指针,该指针指向的内存区域负责存储具体的字符。由于指针的大小是固定的,所以不管字符串多长,大小都是相等的。

另外字符串大小是 16 字节,指针是 8 字节,那么剩下的 8 字节是什么呢?不用想,显然是长度。下面来验证一下我们结论:

以上是 Go 字符串的底层结构,位于 runtime/string.go 中。字符串在底层是一个结构体,包含两个字段,其中 str 是一个 8 字节的万能指针,指向一个数组,数组里面存储的就是实际的字符;而 len 则表示长度,也是 8 字节。

因此结构很清晰了:

str 指向的数组里面存储的就是所有的字符,并且类型是 uint8,因为 Go 的字符串默认采用 utf-8 编码。所以一个汉字在 Go 里面占 3 字节,我们先用 Python 举个例子:

>>> name = "琪露诺"
>>> [c for c in name.encode("utf-8")]
[231, 144, 170, 233, 156, 178, 232, 175, 186]
>>>

那么对于 Go 而言,底层就是这么存储的:

我们验证一下:

package main

import "fmt"

func main() {
    name := "琪露诺"
    // 长度是 9,不是 3
    fmt.Println(len(name))  // 9
    // 查看底层数组存储的值
    // 可以转成切片查看
    fmt.Println([]byte(name))  // [231 144 170 233 156 178 232 175 186]
}

结果和我们想的一样,并且内置函数 len 在统计字符串长度时,返回的是底层数组的长度。

字符串的截取

如果要截取字符串的某个子串,要怎么做呢?如果是 Python 的话很简单:

因为 Python 字符串里面的每个字符的大小都是相同的,可能是 1 字节、2字节、4字节。但不管是哪种,一个字符串里面的所有字符都具有相同的大小,因此才能通过索引准确定位。

但在 Go 里面这种做法行不通,Go 的字符串采用 utf-8 编码,不同字符占用的大小不同,ASCII 字符占 1 字节,汉字占 3 字节,所以无法通过索引准确定位。

package main

import "fmt"

func main() {
    name := "琪露诺"
    fmt.Println(name[0], name[1], name[2])  // 231 144 170
    fmt.Println(name[: 3])  // 琪
}

如果一个字符串里面既有英文又有中文,那么想通过索引准确定位是不可能的。因此这个时候我们需要进行转换,让它像 Python 一样,每个字符都具有相同的大小。

package main

import "fmt"

func main() {
    name := "琪露诺"
    // rune 等价于 int32
    // 此时每个元素统一占 4 字节
    // 并且 []rune(name) 的长度才是字符串的字符个数
    fmt.Println(
        []rune(name),
    ) // [29738 38706 35834]
    
    // 然后再进行截取
    fmt.Println(
        string([]rune(name)[0]),
        string([]rune(name)[: 2]),
    )  // 琪 琪露
}

所以对于字符串 "憨pi" 而言,如果是 utf-8 存储,那么只需要 5 个字节。但很明显,基于索引查找指定的字符是不可能的,除非事先知道字符串长什么样子。如果是转成 []rune 的话,那么需要 12 字节存储,内存占用变大了,但可以很方便地查找某个字符或者某个子串。

字符串和切片的转换

字符串和切片之间是可以互转的,但切片只能是 uint8 或者 int32 类型,另外 uint8 也可以写成 byte,int32 可以写成 rune。由于 byte 是 1 字节,那么当字符串包含汉字,转成 []byte 切片时,一个汉字需要 3 个 byte 表示。因此字符串 "憨pi" 转成 []byte 之后,长度为 5。

而 rune 是 4 字节,可以容纳所有的字符,那么转成 []rune 切片时,不管什么字符,都只需要一个 rune 表示即可。所以字符串 "憨pi" 转成 []rune 之后,长度为 3。因此当你想统计字符串的字符个数时,最好转成 []rune 数组之后再统计。如果是字节个数,那么直接使用内置函数 len 即可。

我们举例说明,先来看一段 Python 代码:

>>> s = "憨pi"
# 采用 utf-8 编码(等价于 Go 的 []byte 数组)
# "憨" 需要 230 134 168 三个整数来表示
# 而 "p" 和 "i" 均只需 1 个字节,分别为 112 和 105
>>> [c for c in s.encode("utf-8")]
[230, 134, 168, 112, 105]

# 采用 unicode 编码(类似于 Go 的 [] rune数组)
# 所有字符都只需要1个整数表示
# 但对于 ASCII 字符而言,不管什么编码,对应的数值不变
>>> [ord(c) for c in s]
[25000, 112, 105]

我们用 Go 再演示一下:

package main

import "fmt"

func main() {
    s := "憨pi"
    fmt.Println([]byte(s)) // [230 134 168 112 105]

    fmt.Println([]rune(s)) // [25000 112 105]
}

结果是一样的,当然这个过程我们也可以反向进行:

package main

import "fmt"

func main() {
    s1 := []byte{230, 134, 168, 112, 105}
    fmt.Println(string(s1)) // 憨pi

    s2 := []rune{25000, 112, 105}
    fmt.Println(string(s2)) // 憨pi
}

结果没有任何问题。

字符串和切片共享底层数组

我们知道字符串和切片内部都有一个指针,指针指向一个数组,该数组存放具体的元素。

// runtime/string.go
type stringStruct struct {
    str unsafe.Pointer
    len int
}

// runtime/slice.go
type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

假设有一个字符串 "abc",然后基于该字符串创建一个切片,那么两者的结构如下:

字符串在转成切片的时候,会将底层数组也拷贝一份。那么问题来了,在基于字符串创建切片的时候,能不能不拷贝数组呢?也就是下面这个样子:

如果字符串比较大,或者说需要和切片之间来回转换的话,这种方式无疑会减少大量开销。Go 提供了万能指针帮我们实现这一点,也就是上一篇文章中的 unsafe.Pointer。

字符串和切片共享底层数组

我们知道 C 的指针不仅可以相互转换,而且还可以参与运算,但 Go 不行,因为 Go 的指针是类型安全的。Go 编译器对类型的检测非常严格,让你在享受指针带来的便利时,又给指针施加了很多制约来保证安全。因此 Go 的指针不可以相互转换,也不可以参与运算。

但保证安全是需要以牺牲效率为代价的,如果你能保证写出的程序就是安全的,那么可以使用 Go 中的万能指针,从而绕过类型系统的检测,让程序运行的更快。

万能指针在 Go 里面叫做 unsafe.Pointer,它位于 unsafe 包下面。当然这个包名看起来有点怪怪的,因为这个包可以让我们绕过 Go 类型系统的检测,直接访问内存,从而提升效率。所以它有点危险,而 Go 官方也不推荐开发者使用,于是起了这个名字。但实际上 unsafe 包在底层被大量使用,所以不要被名字误导了,这个包是一定要掌握的。

回到万能指针上面来,Go 的指针不可以相互转换,但是它们都可以和万能指针转换。举个例子:

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    // 一个 []int8 类型的切片
    s1 := []int8{1, 2, 3, 4}
    // 如果直接转成 []int16 是会报错的,因为 Go 的类型系统不允许这么做
    // 但是有万能指针,任何指针都可以和它转换
    // 我们可以先将 s1 的指针转成万能指针,然后再将万能指针转成 *[]int16,最后再解引用
    s2 := *(*[]int16)(unsafe.Pointer(&s1))
    // 那么问题来了,指针虽然转换了,但是内存地址没变,内存里的值也没变
    // 由于 s2 是 []int16 类型,s1 是 []int8 类型
    // 所以它会把 s1[0] 和 s1[1] 整体作为 s2[0],会把 s1[2] 和 s1[3] 整体作为 s2[1]
    fmt.Println(s2)  // [513 1027 0 0]
    
    // int8 类型的 1 和 2 组合成 int16 
    // int8 类型的 3 和 4 组合成 int16 
    fmt.Println(2 << 8 + 1)  // 513
    fmt.Println(4 << 8 + 3)  // 1027
}

因此把 Go 的万能指针想象成 C 的空指针 void * 即可。

那么让字符串和切片共享数组,我们就可以这么做:

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    str := "abc"
    slice := *(*[]byte)(unsafe.Pointer(&str))
    fmt.Println(slice)  // [97 98 99]
    fmt.Println(cap(slice))  // 10036576
}

虽然转换成功了,但是还有点问题,容量不太对劲。至于原因也很简单,字符串和切片在底层都是结构体,并且它们的前两个字段相同,所以转换之后打印没有问题。但字符串没有容量的概念,它是定长的,所以转成切片的时候 cap 就丢失了,打印的就是乱七八糟的值。

所以我们需要再完善一下:

package main

import (
    "fmt"
    "unsafe"
)

func StringToBytes(s string) []byte {
    // 既然字符串转切片,会丢失容量
    // 那么加上去就好了,做法也很简单
    // 新建一个结构体,将容量(等于长度)加进去
    return *(*[]byte)(unsafe.Pointer(
        &struct {
            string
            Cap int
        }{s, len(s)},
    ))
}

func BytesToString(b []byte) string {
    // 切片转字符串就简单了,直接转即可
    // 转的过程中,切片的 Cap 字段会丢弃
    return *(*string)(unsafe.Pointer(&b))
}
func main() {
    fmt.Println(StringToBytes("abc")) // [97 98 99]

    fmt.Println(BytesToString([]byte{97, 98, 99})) // abc
}

结果没有问题,但我们怎么证明它们是共享数组的呢?很简单:

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    slice := []byte{97, 98, 99}
    str := *(*string)(unsafe.Pointer(&slice))
    fmt.Println(str)  // abc
    slice[0] = 'A'
    fmt.Println(str)  // Abc
}

操作切片等于操作底层数组,而 str 前后的打印结果不一致,所以确实是共享同一个数组。但需要注意的是,这里是先创建的切片,因此底层数组是可以修改的,没有问题。

但如果创建的是字符串,然后基于字符串得到切片,那么切片就不可以修改了。因为字符串是不可修改的,所以底层数组也不可修改,也意味着切片不可以修改。

小结

  • 字符串底层是一个结构体,内部不存储实际数据,而是只保存一个指针和一个长度;
  • 字符串采用 utf-8 编码,这种编码的特点是省内存,但是无法通过索引准确定位字符和截取子串;
  • 字符串可以和 []byte、[]rune 类型的切片互相转换,特别是 []rune,如果想计算字符长度或者截取子串,需要转成 []rune;
  • 字符串和切片之间可以共享底层数组,其实现的核心就在于万能指针;
posted @ 2019-11-19 17:01  古明地盆  阅读(3221)  评论(0编辑  收藏  举报