Golang 語言高效使用字符串的方法
01介紹
在 Golang 語言中,string 類型的值是只讀的,不可以被修改。如果需要修改,通常的做法是對(duì)原字符串進(jìn)行截取和拼接操作,從而生成一個(gè)新字符串,但是會(huì)涉及內(nèi)存分配和數(shù)據(jù)拷貝,從而有性能開銷。本文我們介紹在 Golang 語言中怎么高效使用字符串。
02字符串的數(shù)據(jù)結(jié)構(gòu)
在 Golang 語言中,字符串的值存儲(chǔ)在一塊連續(xù)的內(nèi)存空間,我們可以把存儲(chǔ)數(shù)據(jù)的內(nèi)存空間看作一個(gè)字節(jié)數(shù)組,字符串在 runtime 中的數(shù)據(jù)結(jié)構(gòu)是一個(gè)結(jié)構(gòu)體 stringStruct,該結(jié)構(gòu)體包含兩個(gè)字段,分別是指針類型的 str 和整型的 len。字段 str 是指向字節(jié)數(shù)組頭部的指針值,字段 len 的值是字符串的長度(字節(jié)個(gè)數(shù))。
type stringStruct struct { str unsafe.Pointer len int }
我們通過示例代碼,比較一下字符串和字符串指針的性能差距。我們定義兩個(gè)函數(shù),分別用 string 和 *string 作為函數(shù)的參數(shù)。
var strs string = `Go is an open source programming language that makes it easy to build simple, reliable, and efficient software.` func str (str string) { _ = str + "golang" } func ptr (str *string) { _ = *str + "golang" } func BenchmarkString (b *testing.B) { for i := 0; i < b.N; i++ { str(strs) } } func BenchmarkStringPtr (b *testing.B) { for i := 0; i < b.N; i++ { ptr(&strs) } }
output:
go test -bench . -benchmem string_test.go goos: darwin goarch: amd64 cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz BenchmarkString-16 21987604 46.05 ns/op 128 B/op 1 allocs/op BenchmarkStringPtr-16 24459241 46.23 ns/op 128 B/op 1 allocs/op PASS ok command-line-arguments 2.590s
閱讀上面這段代碼,我們可以發(fā)現(xiàn)使用字符串作為參數(shù),和使用字符串指針作為參數(shù),它們的性能基本相同。
雖然字符串的值并不是具體的數(shù)據(jù),而是一個(gè)指向存儲(chǔ)字符串?dāng)?shù)據(jù)的內(nèi)存地址的指針和一個(gè)字符串的長度,但是字符串仍然是值類型。
03字符串是只讀的,不可修改
在 Golang 語言中,字符串是只讀的,它不可以被修改。
func main () { str := "golang" fmt.Println(str) // golang byteSlice := []byte(str) byteSlice[0] = 'a' fmt.Println(string(byteSlice)) // alang fmt.Println(str) // golang }
閱讀上面這段代碼,我們將字符串類型的變量 str 轉(zhuǎn)換為字節(jié)切片類型,并賦值給變量 byteSlice,使用索引下標(biāo)修改 byteSlice 的值,打印結(jié)果仍未發(fā)生改變。
因?yàn)樽址D(zhuǎn)換為字節(jié)切片,Golang 編譯器會(huì)為字節(jié)切片類型的變量重新分配內(nèi)存來存儲(chǔ)數(shù)據(jù),而不是和字符串類型的變量共用同一塊內(nèi)存空間。
可能會(huì)有讀者想到用指針修改字符串類型的變量存儲(chǔ)在內(nèi)存中的數(shù)據(jù)。
func main () { var str string = "golang" fmt.Println(str) ptr := (*uintptr)(unsafe.Pointer(&str)) var arr *[6]byte = (*[6]byte)(unsafe.Pointer(*ptr)) var len *int = (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&str)) + unsafe.Sizeof((*uintptr)(nil)))) for i := 0; i < (*len); i++ { fmt.Printf("%p => %c\n", &((*arr)[i]), (*arr)[i]) ptr2 := &((*arr)[i]) val := (*ptr2) (*ptr2) = val + 1 } fmt.Println(str) }
output:
go run main.go golang 0x10c96d2 => g unexpected fault address 0x10c96d2 fatal error: fault [signal SIGBUS: bus error code=0x2 addr=0x10c96d2 pc=0x10a4c56]
閱讀上面這段代碼,我們可以發(fā)現(xiàn)在代碼中嘗試通過指針修改 string 類型的 str 變量的存儲(chǔ)在內(nèi)存中的數(shù)據(jù),結(jié)果引發(fā)了 signal SIGBUS 運(yùn)行時(shí)錯(cuò)誤,從而證明 string 類型的變量是只讀的。
我們已經(jīng)知道字符串在 runtime 中的結(jié)構(gòu)體包含兩個(gè)字段,指向存儲(chǔ)數(shù)據(jù)的內(nèi)存地址的指針和字符串的長度,因?yàn)樽址侵蛔x的,字符串被賦值后,它的數(shù)據(jù)和長度都不會(huì)被修改,所以讀取字符串的長度,實(shí)際上就是讀取字段 len 的值,復(fù)雜度是 O(1)。
在字符串比較時(shí),因?yàn)樽址侵蛔x的,不可修改的,所以只要兩個(gè)比較的字符串的長度 len 的值不同,就可以判斷這兩個(gè)字符串不相同,不用再去比較兩個(gè)字符串存儲(chǔ)的具體數(shù)據(jù)。
如果 len 的值相同,再去判斷兩個(gè)字符串的指針是否指向同一塊內(nèi)存,如果 len 的值相同,并且指針指向同一塊內(nèi)存,則可以判斷兩個(gè)字符串相同。但是如果 len 的值相同,而指針不是指向同一塊內(nèi)存,那么還需要繼續(xù)去比較兩個(gè)字符串的指針指向的字符串?dāng)?shù)據(jù)是否相同。
04字符串拼接
在 Golang 語言中,關(guān)于字符串拼接有多種方式,分別是:
- 使用操作符 +/+=
- 使用 fmt.Sprintf
- 使用 bytes.Buffer
- 使用 strings.Join
- 使用 strings.Builder
其中使用操作符是最易用的,但是它不是最高效的,一般使用場景是用于已知需要拼接的字符串的長度。
使用 fmt.Sprintf 拼接字符串,性能是最差的,但是它可以格式化,所以一般使用場景是需要格式化拼接字符串。
使用 bytes.Buffer 和使用 strings.Join 的性能比較接近,性能最高的字符串拼接方式是使用 strings.Builder 。
我準(zhǔn)備對(duì) strings.Builder 的字符串拼接方式多費(fèi)些筆墨。
Golang 語言標(biāo)準(zhǔn)庫 strings 中的 Builder 類型,用于在 Write 方法中有效拼接字符串,它減少了數(shù)據(jù)拷貝和內(nèi)存分配。
type Builder struct { addr *Builder // of receiver, to detect copies by value buf []byte }
Builder 結(jié)構(gòu)體中包含兩個(gè)字段,分別是 addr 和 buf,字段 addr 是指針類型,字段 buf 是字節(jié)切片類型,但是它的值仍然不允許被修改,但是字節(jié)切片中的值可以被拼接或者被重置。
Builder 提供了一系列 Write* 拼接方法,這些方法可以用于把新數(shù)據(jù)拼接到已存在的數(shù)據(jù)的末尾,同時(shí)如果字節(jié)切片的容量不夠用,可以自動(dòng)擴(kuò)容。需要注意的是,只要觸發(fā)擴(kuò)容,就會(huì)涉及內(nèi)存分配和數(shù)據(jù)拷貝。自動(dòng)擴(kuò)容規(guī)則和切片的擴(kuò)容規(guī)則相同。
除了自動(dòng)擴(kuò)容,還可以手動(dòng)擴(kuò)容,Builder 提供的 Grow 方法,可以根據(jù) int 類型的傳參,擴(kuò)充字節(jié)數(shù)量。因?yàn)閿U(kuò)容操作,會(huì)涉及內(nèi)存分配和數(shù)據(jù)拷貝,所以調(diào)用 Grow 方法手動(dòng)擴(kuò)容時(shí),Golang 也做了優(yōu)化,如果當(dāng)前字節(jié)切片的容量剩余字節(jié)數(shù)小于或等于傳參的值, Grow 方法將不會(huì)執(zhí)行擴(kuò)容操作。手動(dòng)擴(kuò)容規(guī)則是原字節(jié)切片容量的 2 倍加上傳參的值。
Builder 類型還提供了一個(gè)重置方法 Reset,它可以將 Builder 類型的變量重置為零值。被重置后,原字節(jié)切片將會(huì)被垃圾回收。
在了解完上述 Builder 的介紹后,相信讀者已對(duì) Builder 有了初步認(rèn)識(shí)。下面我們通過代碼看一下預(yù)分配字節(jié)數(shù)量和未分配字節(jié)數(shù)量的區(qū)別:
var lan []string = []string{ "golang", "php", "javascript", } func stringBuilder (lan []string) string { var str strings.Builder for _, val := range lan { str.WriteString(val) } return str.String() } func stringBuilderGrow (lan []string) string { var str strings.Builder str.Grow(16) for _, val := range lan { str.WriteString(val) } return str.String() } func BenchmarkBuilder (b *testing.B) { for i := 0; i < b.N; i++ { stringBuilder(lan) } } func BenchmarkBuilderGrow (b *testing.B) { for i := 0; i < b.N; i++ { stringBuilderGrow(lan) } }
output:
go test -bench . -benchmem builder_test.go goos: darwin goarch: amd64 cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz BenchmarkBuilder-16 13761441 81.85 ns/op 56 B/op 3 allocs/op BenchmarkBuilderGrow-16 20487056 56.20 ns/op 48 B/op 2 allocs/op PASS ok command-line-arguments 2.888s
閱讀上面這段代碼,可以發(fā)現(xiàn)調(diào)用 Grow 方法,預(yù)分配字節(jié)數(shù)量比未預(yù)分配字節(jié)數(shù)量的字符串拼接效率高。我們?cè)诳梢灶A(yù)估字節(jié)數(shù)量的前提下,盡量使用 Grow 方法預(yù)先分配字節(jié)數(shù)量。
注意:第一,Builder 類型的變量在被調(diào)用之后,不可以再被復(fù)制,否則會(huì)引發(fā) panic。第二,因?yàn)?Builder 類型的值不是完全不可修改的,所以使用者需要注意并發(fā)安全的問題。
05字符串和字節(jié)切片互相轉(zhuǎn)換
因?yàn)榍衅愋统酥荒芎?nil 做比較之外,切片類型之間是無法做比較操作的。如果我們需要對(duì)切片類型做比較操作,通常的做法是先將切片類型轉(zhuǎn)換為字符串類型。但是因?yàn)?string 類型是只讀的,不可修改的,所以轉(zhuǎn)換操作會(huì)涉及內(nèi)存分配和數(shù)據(jù)拷貝。
為了提升轉(zhuǎn)換的性能,唯一的方法就是減少或者避免內(nèi)存分配的開銷。在 Golang 語言中,運(yùn)行時(shí)對(duì)二者的互相轉(zhuǎn)換也做了優(yōu)化,感興趣的讀者可以閱讀 runtime 中的相關(guān)源碼:
/usr/local/go/src/runtime/string.go
但是,我們還可以繼續(xù)優(yōu)化,實(shí)現(xiàn)零拷貝的轉(zhuǎn)換操作,從而避免內(nèi)存分配的開銷,提升轉(zhuǎn)換效率。
先閱讀 reflect 中 StringHeader 和 SliceHeader 的數(shù)據(jù)結(jié)構(gòu):
// /usr/local/go/src/reflect/value.go type StringHeader struct { Data uintptr // 指向存儲(chǔ)數(shù)據(jù)的字節(jié)數(shù)組 Len int // 長度 } type SliceHeader struct { Data uintptr // 指向存儲(chǔ)數(shù)據(jù)的字節(jié)數(shù)組 Len int // 長度 Cap int // 容量 }
閱讀上面這段代碼,我們可以發(fā)現(xiàn) StringHeader 和 SliceHeader 的字段只缺少一個(gè)表示容量的字段 Cap,二者都有指向存儲(chǔ)數(shù)據(jù)的字節(jié)數(shù)組的指針和長度。我們只需要通過使用 unsafe.Pointer 獲取內(nèi)存地址,就可以實(shí)現(xiàn)在原內(nèi)存空間修改數(shù)據(jù),避免了內(nèi)存分配和數(shù)據(jù)拷貝的開銷。
因?yàn)?StringHeader 比 SliceHeader 缺少一個(gè)表示容量的字段 Cap,所以通過 unsafe.Pointer 將 *SliceHeader 轉(zhuǎn)換為 *StringHeader 沒有問題,但是反之就不行了。我們需要補(bǔ)上一個(gè) Cap 字段,并且將字段 Len 的值作為字段 Cap 的默認(rèn)值。
func main () { str := "golang" fmt.Printf("str val:%s type:%T\n", str, str) strPtr := (*reflect.SliceHeader)(unsafe.Pointer(&str)) // strPtr[0] = 'a' strPtr.Cap = strPtr.Len fmt.Println(strPtr.Data) str2 := *(*[]byte)(unsafe.Pointer(strPtr)) fmt.Printf("str2 val:%s type:%T\n", str2, str2) fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&str2)).Data) }
output:
go run main.go golang str val:golang type:string 17602449 str2 val:golang type:[]uint8 17602449
閱讀上面這段代碼,我們可以發(fā)現(xiàn)通過使用 unsafe.Pointer 把字符串轉(zhuǎn)換為字節(jié)切片,可以做到零拷貝,str 和 str2 共用同一塊內(nèi)存,無需新分配一塊內(nèi)存。但是需要注意的是,轉(zhuǎn)換后的字節(jié)切片仍然不能修改,因?yàn)樵?Golang 語言中字符串是只讀的,通過索引下標(biāo)修改會(huì)引發(fā) panic。
06總結(jié)
本文我們介紹了怎么高效使用 Golang 語言中的字符串,先是介紹了字符串在 runtime 中的數(shù)據(jù)結(jié)構(gòu),然后介紹了字符串拼接的幾種方式,字符串與字節(jié)切片零拷貝互相轉(zhuǎn)換,還通過示例代碼證明了字符串在 Golang 語言中是只讀的。更多關(guān)于字符串的操作,讀者可以閱讀標(biāo)準(zhǔn)庫 strings 和 strconv 了解更多內(nèi)容。
到此這篇關(guān)于Golang 語言高效使用字符串的方法的文章就介紹到這了,更多相關(guān)Golang 語言使用字符串內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Go?gRPC服務(wù)proto數(shù)據(jù)驗(yàn)證進(jìn)階教程
這篇文章主要為大家介紹了Go?gRPC服務(wù)proto數(shù)據(jù)驗(yàn)證進(jìn)階教程示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-06-06golang如何用http.NewRequest創(chuàng)建get和post請(qǐng)求
這篇文章主要介紹了golang如何用http.NewRequest創(chuàng)建get和post請(qǐng)求問題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2024-03-03golang使用sort接口實(shí)現(xiàn)排序示例
這篇文章主要介紹了golang使用sort接口實(shí)現(xiàn)排序的方法,簡單分析了sort接口的功能并實(shí)例演示了基于sort接口的排序?qū)崿F(xiàn)方法,需要的朋友可以參考下2016-07-07