【Go】string 優(yōu)化誤區(qū)及建議

原文鏈接: https://blog.thinkeridea.com/201902/go/string_ye_shi_yin_yong_lei_xing.html

本文原標(biāo)題為 《string 也是引用類(lèi)型》,經(jīng)過(guò) 郝林 大佬指點(diǎn)原標(biāo)題存在誘導(dǎo)性,這里解釋一下 "引用類(lèi)型" 有兩個(gè)特征:1、多個(gè)變量引用一塊內(nèi)存數(shù)據(jù),不創(chuàng)建變量的副本,2、修改任意變量的數(shù)據(jù),其它變量可見(jiàn)。顯然字符串只滿(mǎn)足了 "引用類(lèi)型" 的第一個(gè)特點(diǎn),不能滿(mǎn)足第二個(gè)特點(diǎn),顧不能說(shuō)字符串是引用類(lèi)型,感謝大佬指正。

初學(xué) Go 語(yǔ)言的朋友總會(huì)在傳 []bytestring 之間有著很多糾結(jié),實(shí)際上是沒(méi)有了解 stringslice 的本質(zhì),而且讀了一些程序源碼,也發(fā)現(xiàn)很多與之相關(guān)的問(wèn)題,下面類(lèi)似的代碼估計(jì)很多初學(xué)者都寫(xiě)過(guò),也充分說(shuō)明了作者當(dāng)時(shí)內(nèi)心的糾結(jié):

package main

import "bytes"

func xx(s []byte) []byte{
    ....
    
    return s
}

func main(){
    s := "xxx"
    
    s = string(xx([]byte(s)))
    
    s = string(bytes.Replace([]byte(s), []byte("x"), []byte(""), -1))
}

雖然這樣的代碼并不是來(lái)自真實(shí)的項(xiàng)目,但是確實(shí)有人這樣設(shè)計(jì),單從設(shè)計(jì)上看就很糟糕了,這樣設(shè)計(jì)的原因很多人說(shuō):“slice 是引用類(lèi)型,傳遞引用類(lèi)型效率高呀”,主要原因不了解兩者的本質(zhì)。

上面這個(gè)例子如果覺(jué)得有點(diǎn)基礎(chǔ)和可愛(ài),下面這個(gè)例子貌似并不那么容易說(shuō)明其存在的問(wèn)題了吧。

package main

func xx(s *string) *string{
    ....
    return s
}

func main(){
    s := "xx"
    
    s = *xx(&s)
    
    ss :=[]*string{}
    
    ss = append(ss, &s)
}

指針效率高,我就用指針多好,可以減少內(nèi)存分配呀,設(shè)計(jì)函數(shù)都接收指針變量,程序性能會(huì)有很大提升,在實(shí)際的項(xiàng)目中這種例子也不少見(jiàn),我想通過(guò)這篇文檔來(lái)幫助初學(xué)者走出誤區(qū),減少適得其反的優(yōu)化技巧。

slice 的定義

在之前 “【Go】深入剖析slice和array” 一文中說(shuō)了 slice 在內(nèi)存中的存儲(chǔ)模式,slice 本身包含一個(gè)指向底層數(shù)組的指針,一個(gè) int 類(lèi)型的長(zhǎng)度和一個(gè) int 類(lèi)型的容量, 這就是 slice 的本質(zhì), []byte 本身也是一個(gè) slice,只是底層數(shù)組存儲(chǔ)的元素是 byte。下面這個(gè)圖就是 slice 的在內(nèi)存中的狀態(tài):

slice_1.jpg

看一下 reflect.SliceHeader 如何定義 slice 在內(nèi)存中的結(jié)構(gòu)吧:

type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

slice 是引用類(lèi)型是 slice 本身會(huì)包含一個(gè)地址,在傳遞 slice 時(shí)只需要分配 SliceHeader 就好了, 而 SliceHeader 只包含了三個(gè) int 類(lèi)型,相當(dāng)于傳遞一個(gè) slice 就只需要拷貝 SliceHeader,而不用拷貝整個(gè)底層數(shù)組,所以才說(shuō) slice 是引用類(lèi)型的。

那么字符串呢,計(jì)算機(jī)中我們處理的大多數(shù)問(wèn)題都和字符串有關(guān),難道傳遞字符串真的需要那么高的成本,需要借助 slice 和指針來(lái)減少內(nèi)存開(kāi)銷(xiāo)嗎。

string 的定義

reflect 包里面也定義了一個(gè) StringHeader 看一下吧:

type StringHeader struct {
    Data uintptr
    Len  int
}

字符串只包含了兩個(gè) int 類(lèi)型的數(shù)據(jù),其中一個(gè)是指針,一個(gè)是字符串的長(zhǎng)度,從 StringHeader 定義來(lái)看 string 并不會(huì)發(fā)生拷貝的,傳遞 string 只會(huì)拷貝 StringHeader 而已。

借助 unsafe 來(lái)分析一下情況是不是這樣吧:

package main

import (
    "reflect"
    "unsafe"

    "github.com/davecgh/go-spew/spew"
)

func xx(s string) {
    sh := *(*reflect.StringHeader)(unsafe.Pointer(&s))
    spew.Dump(sh)
}

func main() {
    s := "xx"

    sh := *(*reflect.StringHeader)(unsafe.Pointer(&s))
    spew.Dump(sh)

    xx(s)
    xx(s[:1])
    xx(s[1:])
}

上面這段代碼的輸出如下:

(reflect.StringHeader) {
 Data: (uintptr) 0x10f5ee0,
 Len: (int) 2
}
(reflect.StringHeader) {
 Data: (uintptr) 0x10f5ee0,
 Len: (int) 2
}
(reflect.StringHeader) {
 Data: (uintptr) 0x10f5ee0,
 Len: (int) 1
}
(reflect.StringHeader) {
 Data: (uintptr) 0x10f5ee1,
 Len: (int) 1
}

可以發(fā)現(xiàn)前三個(gè)輸出的指針都是同一個(gè)地址,第四個(gè)的地址發(fā)生了一個(gè)字節(jié)的偏移,分析來(lái)看傳遞字符串確實(shí)沒(méi)有分配新的內(nèi)存,同時(shí)和 slice 一樣即使傳遞字符串的子串也不會(huì)分配新的內(nèi)存空間,而是指向原字符串的中的一個(gè)位置。

這樣說(shuō)來(lái)把 string 轉(zhuǎn)成 []byte 還浪費(fèi)的一個(gè) int 的空間呢,需要分配更多的內(nèi)存,真是適得其反呀,而且類(lèi)型轉(zhuǎn)換會(huì)發(fā)生內(nèi)存拷貝,從 string 轉(zhuǎn)為 []byte 才是真的把 string 底層數(shù)據(jù)全部拷貝一遍呢,真是得不償失呀。

string 的兩個(gè)小特性

字符串還有兩個(gè)小特性,針對(duì)字面量(就是直接寫(xiě)在程序中的字符串),會(huì)創(chuàng)建在只讀空間上,并且被復(fù)用,看一下下面的一個(gè)小例子:

package main

import (
    "reflect"
    "unsafe"

    "github.com/davecgh/go-spew/spew"
)

func main() {
    a := "xx"
    b := "xx"
    c := "xxx"
    spew.Dump(*(*reflect.StringHeader)(unsafe.Pointer(&a)))
    spew.Dump(*(*reflect.StringHeader)(unsafe.Pointer(&b)))
    spew.Dump(*(*reflect.StringHeader)(unsafe.Pointer(&c)))
}

從輸出可以了解到,相同的字面量會(huì)被復(fù)用,但是子串是不會(huì)復(fù)用空間的,這就是編譯器給我們帶來(lái)的福利了,可以減少字面量字符串占用的內(nèi)存空間。

(reflect.StringHeader) {
 Data: (uintptr) 0x10f5ea0,
 Len: (int) 2
}
(reflect.StringHeader) {
 Data: (uintptr) 0x10f5ea0,
 Len: (int) 2
}
(reflect.StringHeader) {
 Data: (uintptr) 0x10f5f2e,
 Len: (int) 3
}

另一個(gè)小特性大家都知道,就是字符串是不能修改的,如果我們不希望調(diào)用函數(shù)修改我們的數(shù)據(jù),最好傳遞字符串,高效有安全。

不過(guò)有了 unsafe 這個(gè)黑魔法,字符串的這一個(gè)特性也就不那么可靠了。

package main

import (
    "fmt"
    "reflect"
    "strings"
    "unsafe"
)

func main() {
    a := strings.Repeat("x", 10)

    fmt.Println(a)
    strHeader := *(*reflect.StringHeader)(unsafe.Pointer(&a))

    sliceHeader := reflect.SliceHeader{
        Data: strHeader.Data,
        Len:  strHeader.Len,
        Cap:  strHeader.Len,
    }

    b := *(*[]byte)(unsafe.Pointer(&sliceHeader))

    b[1] = 'a'

    fmt.Println(a)
}

從輸出里面居然發(fā)現(xiàn)字符串被修改了, 我們沒(méi)有辦法直接修改字符串,但是可以利用 slicestring 本身結(jié)構(gòu)的特性,創(chuàng)建一個(gè) slice 讓它的指針指向 string 的指針位置,然后借助 unsafe 把這個(gè) SliceHeader 轉(zhuǎn)成 []byte 來(lái)修改字符串,字符串確實(shí)被修改了。

xxxxxxxxxx
xaxxxxxxxx

看了上面的例子是不是開(kāi)始擔(dān)心把字符串傳給其它函數(shù)真的不會(huì)更改嗎?感覺(jué)很不放心的樣子,難道使用任何函數(shù)都要了解它的內(nèi)部實(shí)現(xiàn)嗎,其實(shí)這種情況極少發(fā)生,還記得之前說(shuō)的那個(gè)字符串特性嗎,字面量字符串會(huì)放到只讀空間中,這個(gè)很重要,可以保證不是任何函數(shù)想修改我們的字符串就可以修改的。

package main

import (
    "reflect"
    "unsafe"
)

func main() {
    defer func() {
        recover()
    }()

    a := "xx"

    strHeader := *(*reflect.StringHeader)(unsafe.Pointer(&a))
    sliceHeader := reflect.SliceHeader{
        Data: strHeader.Data,
        Len:  strHeader.Len,
        Cap:  strHeader.Len,
    }
    b := *(*[]byte)(unsafe.Pointer(&sliceHeader))
    b[1] = 'a'
}

運(yùn)行上面的代碼發(fā)生了一個(gè)運(yùn)行時(shí)不可修復(fù)的錯(cuò)誤,就是這個(gè)特性其它函數(shù)不能確保輸入字符串是否是字面量,也是不會(huì)惡意修改我們字符串的了。

unexpected fault address 0x1095dd5
fatal error: fault
[signal SIGBUS: bus error code=0x2 addr=0x1095dd5 pc=0x106c804]

goroutine 1 [running]:
runtime.throw(0x1095fde, 0x5)
    /usr/local/go/src/runtime/panic.go:608 +0x72 fp=0xc000040700 sp=0xc0000406d0 pc=0x10248d2
runtime.sigpanic()
    /usr/local/go/src/runtime/signal_unix.go:387 +0x2d7 fp=0xc000040750 sp=0xc000040700 pc=0x1037677
main.main()
    /Users/qiyin/project/go/src/github.com/yumimobi/test/a.go:22 +0x84 fp=0xc000040798 sp=0xc000040750 pc=0x106c804
runtime.main()
    /usr/local/go/src/runtime/proc.go:201 +0x207 fp=0xc0000407e0 sp=0xc000040798 pc=0x1026247
runtime.goexit()
    /usr/local/go/src/runtime/asm_amd64.s:1333 +0x1 fp=0xc0000407e8 sp=0xc0000407e0 pc=0x104da51

關(guān)于字符串轉(zhuǎn) []bytego-extend 擴(kuò)展包中有直接的實(shí)現(xiàn),這種用法在 go-extend 內(nèi)部方法實(shí)現(xiàn)中也有大量使用, 實(shí)際上因?yàn)樵瓟?shù)據(jù)類(lèi)型和處理數(shù)據(jù)的函數(shù)類(lèi)型不一致,使用這種方法轉(zhuǎn)換字符串和 []byte 可以極大的提升程序性能

上面這兩個(gè)函數(shù)用的好,可以極大的提升我們程序的性能,關(guān)于 exstrings.UnsafeToBytes 我們轉(zhuǎn)換不確定是否是字面量的字符串時(shí)就需要確保調(diào)用的函數(shù)不會(huì)修改我們的數(shù)據(jù),這往常在調(diào)用 bytes 里面的方法十分有效。

傳字符串和字符串指針的區(qū)別

之前分析了傳遞 slice 并沒(méi)有 string 高效,何況轉(zhuǎn)換數(shù)據(jù)類(lèi)型本身就會(huì)發(fā)生數(shù)據(jù)拷貝。

那么在這篇文章的第二個(gè)例子,為什么說(shuō)傳遞字符串指針也不好呢,要了解指針在底層就是一個(gè) int 類(lèi)型的數(shù)據(jù),而我們字符串只是兩個(gè) int 而已,另外如果了解 GC 的話(huà),GC 只處理堆上的數(shù)據(jù),傳遞指針字符串會(huì)導(dǎo)致數(shù)據(jù)逃逸到堆上,閱讀標(biāo)準(zhǔn)庫(kù)的代碼會(huì)有很多注釋說(shuō)明避免逃逸到堆上,這樣會(huì)極大的增加 GC 的開(kāi)銷(xiāo),GC 的成本可謂是很高的呀。

疑惑

這篇文章說(shuō) “傳遞 slice 并沒(méi)有 string 高效”,為什么還會(huì)有 bytes 包的存在呢,其中很多函數(shù)的功能和 strings 包的功能一致,只是把 string 換成了 []byte, 既然傳遞 []byte 沒(méi)有 string 效率好,這個(gè)包存在的意義是什么呢。

我們想一下轉(zhuǎn)換數(shù)據(jù)類(lèi)型是會(huì)發(fā)生數(shù)據(jù)拷貝,這個(gè)成本可是大的多呀,如果我們數(shù)據(jù)本身就是 []byte 類(lèi)型,使用 strings 包就需要轉(zhuǎn)換數(shù)據(jù)類(lèi)型了。

另外我們對(duì)比兩個(gè)函數(shù)來(lái)看下一下即使傳遞 []byte 沒(méi)有 string 效率好,但是標(biāo)準(zhǔn)庫(kù)實(shí)現(xiàn)上卻會(huì)導(dǎo)致兩個(gè)函數(shù)有很大的性能差異的。

strings.Repeat 函數(shù):

func Repeat(s string, count int) string {
    // Since we cannot return an error on overflow,
    // we should panic if the repeat will generate
    // an overflow.
    // See Issue golang.org/issue/16237
    if count < 0 {
        panic("strings: negative Repeat count")
    } else if count > 0 && len(s)*count/count != len(s) {
        panic("strings: Repeat count causes overflow")
    }

    b := make([]byte, len(s)*count)
    bp := copy(b, s)
    for bp < len(b) {
        copy(b[bp:], b[:bp])
        bp *= 2
    }
    return string(b)
}

bytes.Repeat 函數(shù):

func Repeat(b []byte, count int) []byte {
    // Since we cannot return an error on overflow,
    // we should panic if the repeat will generate
    // an overflow.
    // See Issue golang.org/issue/16237.
    if count < 0 {
        panic("bytes: negative Repeat count")
    } else if count > 0 && len(b)*count/count != len(b) {
        panic("bytes: Repeat count causes overflow")
    }

    nb := make([]byte, len(b)*count)
    bp := copy(nb, b)
    for bp < len(nb) {
        copy(nb[bp:], nb[:bp])
        bp *= 2
    }
    return nb
}

上面兩個(gè)函數(shù)的實(shí)現(xiàn)非常相似,除了類(lèi)型不同 strings 包在處理完數(shù)據(jù)發(fā)生了一次類(lèi)型轉(zhuǎn)換,使用 bytes 只有一次內(nèi)存分配,而 strings 是兩次。

我們可以借助 exbytes.ToString 函數(shù)把 bytes.Repeat 的返回沒(méi)有任何成本的轉(zhuǎn)換會(huì)我們需要的字符串,如果我們輸入也是一個(gè)字符串的話(huà),還可以借助 exstrings.UnsafeToBytes 來(lái)轉(zhuǎn)換輸入的數(shù)據(jù)類(lèi)型。

例如:

s := exbytes.ToString(bytes.Repeat(exstrings.UnsafeToBytes("x"), 10))

不過(guò)這樣寫(xiě)有點(diǎn)太麻煩了,實(shí)際上 exstrings 包里面正在修改 strings 里面一些類(lèi)似函數(shù)的問(wèn)題,所有的實(shí)現(xiàn)基本和標(biāo)準(zhǔn)庫(kù)一致,只是把其中類(lèi)型轉(zhuǎn)換的部分用 exbytes.ToString 優(yōu)化了一下,可以提升性能,也能提升開(kāi)發(fā)效率。

exstrings.UnsafeRepeat 函數(shù):

func UnsafeRepeat(s string, count int) string {
    // Since we cannot return an error on overflow,
    // we should panic if the repeat will generate
    // an overflow.
    // See Issue golang.org/issue/16237
    if count < 0 {
        panic("strings: negative Repeat count")
    } else if count > 0 && len(s)*count/count != len(s) {
        panic("strings: Repeat count causes overflow")
    }

    b := make([]byte, len(s)*count)
    bp := copy(b, s)
    for bp < len(b) {
        copy(b[bp:], b[:bp])
        bp *= 2
    }
    return exbytes.ToString(b)
}

如果用上面的函數(shù)只需要下面這樣寫(xiě)就可以了:

s:=exstrings.UnsafeRepeat("x", 10)

go-extend 里面還收錄了很多實(shí)用的方法,大家也可以多關(guān)注。

總結(jié)

  • 千萬(wàn)不要為了使用 []byte 來(lái)優(yōu)化 string 傳遞,類(lèi)型轉(zhuǎn)換成本很高,且 slice 本身也比 string 更大一些。
  • 程序中是使用 string 還是 []byte 需要根據(jù)數(shù)據(jù)來(lái)源和處理數(shù)據(jù)的函數(shù)來(lái)決定,一定要減少類(lèi)型轉(zhuǎn)換。
  • 關(guān)于使用 strings 還是 bytes 包的問(wèn)題,主要關(guān)注點(diǎn)是數(shù)據(jù)原始類(lèi)型以及想獲得的數(shù)據(jù)類(lèi)型來(lái)選擇。
  • 減少使用字符串指針來(lái)優(yōu)化字符串,這會(huì)增加 GC 的開(kāi)銷(xiāo),具體可以參考 大堆中避免大量的GC開(kāi)銷(xiāo) 一文。

轉(zhuǎn)載:

本文作者: 戚銀(thinkeridea

本文鏈接: https://blog.thinkeridea.com/201902/go/string_ye_shi_yin_yong_lei_xing.html

版權(quán)聲明: 本博客所有文章除特別聲明外,均采用 CC BY 4.0 CN協(xié)議 許可協(xié)議。轉(zhuǎn)載請(qǐng)注明出處!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容