Go进阶string原理和高效实用

0 阅读10分钟

Go并发编程与诗词内容整合图.png

1.字符串类型:

在Go语言中.无论是字符串常量 字符串变量还是代码中出现的字符串字面量.它们的类型都被统一设置为string.

const (
    s = "string constant"
)

func main() {
    var s1 string = "hello world"
    fmt.Printf("%T\n", s)
    fmt.Printf("%T\n", s1)
    fmt.Printf("%T\n", "temporary string literal")
}

Go的string类型设计充分吸收了C语言字符串设计的经验教训.并结合了其他主流语言在字符串类型设计的实践.最终有如下功能特点.

1).string类型的数据是不可变的.


一旦声明了一个string类型的标识符.无论是常量还是变量.该标识符所指代的数据在整个程序的生命周期内便无法更改.

func main() {
    //原始字符串.
    var s string = "hello"
    fmt.Println("original string:", s)

    //切片化后试图改变原字符串.
    s1 := []byte(s)
    s1[0] = 't'
    fmt.Println("slice:", string(s1))
    fmt.Println("after reslice,thr original string is:", string(s))
}

上面的例子试图将string转换为一个切片并通过该切片对其内容进行改变.结果是失败的.Go编译器会为切片变量重新分配底层存储而不是共用string的底层存储.因此不会有任何影响.

func main() {
    //原始字符串.
    var s string = "hello"
    fmt.Println("original string:", s)

    //试图通过unsafe指针修改原始string.
    modifyString(&s)
    fmt.Println(s)
}

func modifyString(s *string) {
    //取出第一个8字节的值.
    p := (*uintptr)(unsafe.Pointer(s))
    //获取底层数组的地址.
    var array *[5]byte = (*[5]byte)(unsafe.Pointer(*p))

    var len *int = (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(s)) + unsafe.Sizeof((*uintptr)(nil))))
    for i := 0; i < (*len); i++ {
       fmt.Printf("%p>=%c\n", &((*array)[i]), (*array)[i])
       p1 := &((*array)[i])
       v := (*p1)
       (*p1) = v + 1
    }
}

对string的底层的数据存储区仅能进行只读操作.一旦试图修改那块区域的数据.便会得到SIGBUS的运行时错误.

2).零值可用.

Go string类型支持零值可用的理念.Go字符串无须像C语言中那样考虑结尾的'\0'字符.因此零值为"",长度为0.

3).获取长度的时间复杂度是O(1)级别.

Gostring类型数据是不可变的.因此一旦有了初值.那块数据就不会在改变.其长度也不会改变.Go将这个长度作为一个字段存储在运行时的string类型的内部表示结构中.这样获取string长度操作.即len(s)实际上就是读取存储在运行时中的那个长度值.这是一个代价极低的O(1)操作.

4).支持通过+/+=操作符进行字符串连接.

+/+=操作符进行的字符串连接是体验最好的字符连接操作.Go语言支持这种操作.

s:="hello world, "

s = s + "hello world, "

s += " hello world"

5).支持各种比较关系操作符: == != >= <= >和<.

Go支持各种关系比较符.

func main() {
    //==
    s1 := "世界和平"
    s2 := "世界" + "和平"
    fmt.Println(s1 == s2)

    //!=
    s1 = "Go"
    s2 = "C"
    fmt.Println(s1 != s2)

    //<和<=
    s1 = "12345"
    s2 = "23456"
    fmt.Println(s1 < s2)
    fmt.Println(s1 <= s2)

    //>和>=
    s1 = "12345"
    s2 = "123"
    fmt.Println(s1 > s2)
    fmt.Println(s1 >= s2)
}

6).对非ASCII字符提供原生支持.

Go语言源文件默认采用的Unicode字符集.Unicode字符集是目前市面上最流行的字符集.几乎囊括了所有主流非ASII字符(包括中文字符).Go字符串的每个字符都是一个Unicode字符.并且这些Unicode字符是以Utf-8编码格式存储在内存中.

func main() {
    s := "中国欢迎您"
    rs := []rune(s)
    s1 := []byte(s)
    for i, v := range rs {
       var utf8Bytes []byte
       for j := i * 3; j < (i+1)*3; j++ {
          utf8Bytes = append(utf8Bytes, s1[j])
       }
       fmt.Printf("%s => %X => %X\n", string(v), v, utf8Bytes)
    }
}

字符串变量s中存储的文本是"中国欢迎你"五个汉字字符(非ASII字符范畴).这里输出了每个中文字符对应的Unicode码点(Code Point结果第二列),一个rune对应一个码点.UTF-8编码是Unicode码点的一种字符编码形式.是最常用的一种编码格式.也是Go默认的字符编码格式.

在UTF-8中.大多数中文字符都使用三字节表示.[]byte(s)的转型获得了s底层存储的复制品.从而得到每个汉字字符对应的UTF-8的编码字节.(输出结果第三列).

7).原生支持多行字符串.

Go语言提供了通过反引号构造多行字符串的方法.

func main() {

    s2 := `无言独上西楼,
         月如钩.
        寂寞梧桐深院锁清秋,
      剪不断.理还乱.
       别是一番滋味在心头.`
    fmt.Println(s2)
}

2.字符串内部表示:

Go string类型也是一个描述符.它本身并不真正存储数据.而仅是由一个指向底层存储的指针和字符串长度的数字字段组成.

实例化字符串的函数rawstring:

源码位置:src/runtime/string.go

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)
}

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

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

字符串高效构造:

构造字符串的方法:

fmt.Sprintf:

strings.Join:

strings.Builder:

bytes.Buffer:

var s1 []string = []string{
    "Rob Pike",
    "Robert Griesemer ",
    "Ken Thompson",
}

func concatStringByOperators(s1 []string) string {
    var s string
    for _, v := range s1 {
       s += v
    }
    return s
}

func concatStringBySprintf(s1 []string) string {
    var s string
    for _, v := range s1 {
       s = fmt.Sprintf("%s%s", s, v)
    }
    return s
}

func concatStringByJoin(s1 []string) string {
    return strings.Join(s1, "")
}

func concatStringByStringBuilder(s1 []string) string {
    var b strings.Builder
    for _, v := range s1 {
       b.WriteString(v)
    }
    return b.String()
}

func concatStringByStringBuilderWithInitSize(s1 []string) string {
    var b strings.Builder
    b.Grow(64)
    for _, v := range s1 {
       b.WriteString(v)
    }
    return b.String()
}

func concatStringByBytesBuffer(s1 []string) string {
    var b bytes.Buffer
    for _, v := range s1 {
       b.WriteString(v)
    }
    return b.String()
}

func concatStringByBytesBufferWithInitSize(s1 []string) string {
    buf := make([]byte, 0, 64)
    b := bytes.NewBuffer(buf)
    for _, v := range s1 {
       b.WriteString(v)
    }
    return b.String()
}

func BenchmarkConcatStringByOperators(b *testing.B) {
    for i := 0; i < b.N; i++ {
       concatStringByOperators(s1)
    }
}

func BenchmarkConcatStringBySprintf(b *testing.B) {
    for i := 0; i < b.N; i++ {
       concatStringBySprintf(s1)
    }
}

func BenchmarkConcatStringByJoin(b *testing.B) {
    for i := 0; i < b.N; i++ {
       concatStringByJoin(s1)
    }
}

func BenchmarkConcatStringByStringsBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
       concatStringByStringBuilder(s1)
    }
}

func BenchmarkConcatStringByStringsBuilderWithInitSize(b *testing.B) {
    for i := 0; i < b.N; i++ {
       concatStringByStringBuilderWithInitSize(s1)
    }
}

func BenchmarkConcatStringByBytesBuffer(b *testing.B) {
    for i := 0; i < b.N; i++ {
       concatStringByBytesBuffer(s1)
    }
}

func BenchmarkConcatStringByBytesBufferWithInitSize(b *testing.B) {
    for i := 0; i < b.N; i++ {
       concatStringByBytesBufferWithInitSize(s1)
    }
}

结论:

1).在能预估出最终字符串长度的情况下.使用预初始化的string.Builder连接构建字符串效率最高.

2).strings.Join连接构建字符串的平均性能最稳定.如果输入的多个字符串是以[]string承载的.那么strings.Join也是不错的选择.

3).使用操作符连接的方式最直观 最自然.在编译器知晓预连接的字符串个数的情况下.这种方式可以得到编译器优化.

4).fmt.Sprintf虽然效率不高.如果是由多种不同类型变量构建特定格式的字符串.这种方式还是很适合.

字符串高效转换:

反向转换:

func main() {

    rs := []rune{
       0x4E2D,
       0x56FD,
       0x6B22,
       0x8FCE,
       0x60A8,
    }

    s := string(rs)
    fmt.Println(s)

    s1 := []byte{
       0xE4, 0xB8, 0xAD,
       0xE5, 0x9B, 0xBD,
       0xE6, 0xAC, 0xA2,
       0xE8, 0xBF, 0x8E,
       0xE6, 0x82, 0xA8,
    }
    s = string(s1)
    fmt.Println(s)
}

func modifyString(s *string) {
    //取出第一个8字节的值.
    p := (*uintptr)(unsafe.Pointer(s))
    //获取底层数组的地址.
    var array *[5]byte = (*[5]byte)(unsafe.Pointer(*p))

    var len *int = (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(s)) + unsafe.Sizeof((*uintptr)(nil))))
    for i := 0; i < (*len); i++ {
       fmt.Printf("%p>=%c\n", &((*array)[i]), (*array)[i])
       p1 := &((*array)[i])
       v := (*p1)
       (*p1) = v + 1
    }
}

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

反向转换分配:

func main() {
    fmt.Println(testing.AllocsPerRun(1, byteSliceToString))
    fmt.Println(testing.AllocsPerRun(1, stringToByteSlice))
}

func byteSliceToString() {
    s1 := []byte{
       0xE4, 0xB8, 0xAD,
       0xE5, 0x9B, 0xBD,
       0xE6, 0xAC, 0xA2,
       0xE8, 0xBF, 0x8E,
       0xE6, 0x82, 0xA8,
       0xEF, 0xBC, 0x8C,
       0xE5, 0x8C, 0x97,
       0xE4, 0xBA, 0xAC,
       0xE6, 0xAC, 0xA2,
       0xE8, 0xBF, 0x8E,
       0xE6, 0x82, 0xA8,
    }
    _ = string(s1)
}

func stringToByteSlice() {
    s := "中国欢迎您,北京欢迎您,"
    _ = []byte(s)
}

(字符串转字节为什么没有分配还没有找到原因)

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
}

slicebytetostring函数:

func slicebytetostring(buf *tmpBuf, ptr *byte, n int) string {
    if n == 0 {
       // Turns out to be a relatively common case.
       // Consider that you want to parse out data between parens in "foo()bar",
       // you find the indices and convert the subslice to string.
       return ""
    }
    if raceenabled {
       racereadrangepc(unsafe.Pointer(ptr),
          uintptr(n),
          sys.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)
       }
       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)
}

想要高效的转换.唯一的方法就是减少甚至避免额外内存分配操作.可以看到运行时实现转换的函数已经加入了一些避免每种情况都要分配新内存的操作的优化.(tmpBuf的复用).

slice类型是不可比较的.而string类型是可比较的.会经常遇到将slice临时转换为string的情况.Go编译器为这样的场景提供了优化.运行是有一个名为slicebytetostringtmp函数协助优化.

func slicebytetostringtmp(ptr *byte, n int) string {
    if raceenabled && n > 0 {
       racereadrangepc(unsafe.Pointer(ptr),
          uintptr(n),
          sys.GetCallerPC(),
          abi.FuncPCABIInternal(slicebytetostringtmp))
    }
    if msanenabled && n > 0 {
       msanread(unsafe.Pointer(ptr), uintptr(n))
    }
    if asanenabled && n > 0 {
       asanread(unsafe.Pointer(ptr), uintptr(n))
    }
    return unsafe.String(ptr, n)
}

不在为string新开辟一块内存.直接使用slice的底层存储.这个函数的前提是.原slice被修改后.这个string不能再被使用了.

特定场景:

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

2).string(b)用在字符串连接语句.

3).string(b)用在字符串比较.

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

func main() {
    fmt.Println(testing.AllocsPerRun(1000, convert))
    fmt.Println(testing.AllocsPerRun(1000, convertWithOptimize))
}

func convert() {
    s := "中国欢迎您,北京欢迎您"
    s1 := []byte(s)
    for _, b := range s1 {
       _ = b
    }
}

func convertWithOptimize() {
    s := "中国欢迎您,北京欢迎您"
    for _, b := range []byte(s) {
       _ = b
    }
}

六宫佳丽谁曾见,层台尚临芳渚。露脚斜飞,虹腰欲断,荷叶未收残雨。添妆何处。试问取雕笼,雪衣分付。一镜空蒙,鸳鸯拂破白萍去。
相传内家结束,有帕装孤稳,靴缝女古。冷艳全消,苍苔玉匣,翻出十眉遗谱。人间朝暮。看胭粉亭西、几堆尘土。只有花铃,绾风深夜语。 纳兰

语雀地址www.yuque.com/itbosunmian…?

《Go.》 密码:xbkk 欢迎大家访问.提意见.

如果大家喜欢我的分享的话.可以关注我的微信公众号

念何架构之路