原文鏈接: 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ì)在傳 []byte 和 string 之間有著很多糾結(jié),實(shí)際上是沒(méi)有了解 string 與 slice 的本質(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):

看一下 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)有辦法直接修改字符串,但是可以利用 slice 和 string 本身結(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) []byte 在 go-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 可以極大的提升程序性能
-
exbytes.ToString 零成本的把
[]byte轉(zhuǎn)為string。 -
exstrings.UnsafeToBytes 零成本的把
[]byte轉(zhuǎn)為string。
上面這兩個(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)注明出處!