目錄
- 01介紹
- 02字符串的數(shù)據(jù)結(jié)構(gòu)
- 03字符串是只讀的,不可修改
- 04字符串拼接
- 05字符串和字節(jié)切片互相轉(zhuǎn)換
- 06總結(jié)
01介紹
在 Golang 語言中,string 類型的值是只讀的,不可以被修改。如果需要修改,通常的做法是對原字符串進行截取和拼接操作,從而生成一個新字符串,但是會涉及內(nèi)存分配和數(shù)據(jù)拷貝,從而有性能開銷。本文我們介紹在 Golang 語言中怎么高效使用字符串。
02字符串的數(shù)據(jù)結(jié)構(gòu)
在 Golang 語言中,字符串的值存儲在一塊連續(xù)的內(nèi)存空間,我們可以把存儲數(shù)據(jù)的內(nèi)存空間看作一個字節(jié)數(shù)組,字符串在 runtime 中的數(shù)據(jù)結(jié)構(gòu)是一個結(jié)構(gòu)體 stringStruct,該結(jié)構(gòu)體包含兩個字段,分別是指針類型的 str 和整型的 len。字段 str 是指向字節(jié)數(shù)組頭部的指針值,字段 len 的值是字符串的長度(字節(jié)個數(shù))。
type stringStruct struct {
str unsafe.Pointer
len int
}
我們通過示例代碼,比較一下字符串和字符串指針的性能差距。我們定義兩個函數(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ù),而是一個指向存儲字符串數(shù)據(jù)的內(nèi)存地址的指針和一個字符串的長度,但是字符串仍然是值類型。
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,使用索引下標修改 byteSlice 的值,打印結(jié)果仍未發(fā)生改變。
因為字符串轉(zhuǎn)換為字節(jié)切片,Golang 編譯器會為字節(jié)切片類型的變量重新分配內(nèi)存來存儲數(shù)據(jù),而不是和字符串類型的變量共用同一塊內(nèi)存空間。
可能會有讀者想到用指針修改字符串類型的變量存儲在內(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 變量的存儲在內(nèi)存中的數(shù)據(jù),結(jié)果引發(fā)了 signal SIGBUS 運行時錯誤,從而證明 string 類型的變量是只讀的。
我們已經(jīng)知道字符串在 runtime 中的結(jié)構(gòu)體包含兩個字段,指向存儲數(shù)據(jù)的內(nèi)存地址的指針和字符串的長度,因為字符串是只讀的,字符串被賦值后,它的數(shù)據(jù)和長度都不會被修改,所以讀取字符串的長度,實際上就是讀取字段 len 的值,復(fù)雜度是 O(1)。
在字符串比較時,因為字符串是只讀的,不可修改的,所以只要兩個比較的字符串的長度 len 的值不同,就可以判斷這兩個字符串不相同,不用再去比較兩個字符串存儲的具體數(shù)據(jù)。
如果 len 的值相同,再去判斷兩個字符串的指針是否指向同一塊內(nèi)存,如果 len 的值相同,并且指針指向同一塊內(nèi)存,則可以判斷兩個字符串相同。但是如果 len 的值相同,而指針不是指向同一塊內(nèi)存,那么還需要繼續(xù)去比較兩個字符串的指針指向的字符串數(shù)據(jù)是否相同。
04字符串拼接
在 Golang 語言中,關(guān)于字符串拼接有多種方式,分別是:
- 使用操作符 +/+=
- 使用 fmt.Sprintf
- 使用 bytes.Buffer
- 使用 strings.Join
- 使用 strings.Builder
其中使用操作符是最易用的,但是它不是最高效的,一般使用場景是用于已知需要拼接的字符串的長度。
使用 fmt.Sprintf 拼接字符串,性能是最差的,但是它可以格式化,所以一般使用場景是需要格式化拼接字符串。
使用 bytes.Buffer 和使用 strings.Join 的性能比較接近,性能最高的字符串拼接方式是使用 strings.Builder 。
我準備對 strings.Builder 的字符串拼接方式多費些筆墨。
Golang 語言標準庫 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)體中包含兩個字段,分別是 addr 和 buf,字段 addr 是指針類型,字段 buf 是字節(jié)切片類型,但是它的值仍然不允許被修改,但是字節(jié)切片中的值可以被拼接或者被重置。
Builder 提供了一系列 Write* 拼接方法,這些方法可以用于把新數(shù)據(jù)拼接到已存在的數(shù)據(jù)的末尾,同時如果字節(jié)切片的容量不夠用,可以自動擴容。需要注意的是,只要觸發(fā)擴容,就會涉及內(nèi)存分配和數(shù)據(jù)拷貝。自動擴容規(guī)則和切片的擴容規(guī)則相同。
除了自動擴容,還可以手動擴容,Builder 提供的 Grow 方法,可以根據(jù) int 類型的傳參,擴充字節(jié)數(shù)量。因為擴容操作,會涉及內(nèi)存分配和數(shù)據(jù)拷貝,所以調(diào)用 Grow 方法手動擴容時,Golang 也做了優(yōu)化,如果當前字節(jié)切片的容量剩余字節(jié)數(shù)小于或等于傳參的值, Grow 方法將不會執(zhí)行擴容操作。手動擴容規(guī)則是原字節(jié)切片容量的 2 倍加上傳參的值。
Builder 類型還提供了一個重置方法 Reset,它可以將 Builder 類型的變量重置為零值。被重置后,原字節(jié)切片將會被垃圾回收。
在了解完上述 Builder 的介紹后,相信讀者已對 Builder 有了初步認識。下面我們通過代碼看一下預(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ù)量的字符串拼接效率高。我們在可以預(yù)估字節(jié)數(shù)量的前提下,盡量使用 Grow 方法預(yù)先分配字節(jié)數(shù)量。
注意:第一,Builder 類型的變量在被調(diào)用之后,不可以再被復(fù)制,否則會引發(fā) panic。第二,因為 Builder 類型的值不是完全不可修改的,所以使用者需要注意并發(fā)安全的問題。
05字符串和字節(jié)切片互相轉(zhuǎn)換
因為切片類型除了只能和 nil 做比較之外,切片類型之間是無法做比較操作的。如果我們需要對切片類型做比較操作,通常的做法是先將切片類型轉(zhuǎn)換為字符串類型。但是因為 string 類型是只讀的,不可修改的,所以轉(zhuǎn)換操作會涉及內(nèi)存分配和數(shù)據(jù)拷貝。
為了提升轉(zhuǎn)換的性能,唯一的方法就是減少或者避免內(nèi)存分配的開銷。在 Golang 語言中,運行時對二者的互相轉(zhuǎn)換也做了優(yōu)化,感興趣的讀者可以閱讀 runtime 中的相關(guān)源碼:
/usr/local/go/src/runtime/string.go
但是,我們還可以繼續(xù)優(yōu)化,實現(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 // 指向存儲數(shù)據(jù)的字節(jié)數(shù)組
Len int // 長度
}
type SliceHeader struct {
Data uintptr // 指向存儲數(shù)據(jù)的字節(jié)數(shù)組
Len int // 長度
Cap int // 容量
}
閱讀上面這段代碼,我們可以發(fā)現(xiàn) StringHeader 和 SliceHeader 的字段只缺少一個表示容量的字段 Cap,二者都有指向存儲數(shù)據(jù)的字節(jié)數(shù)組的指針和長度。我們只需要通過使用 unsafe.Pointer 獲取內(nèi)存地址,就可以實現(xiàn)在原內(nèi)存空間修改數(shù)據(jù),避免了內(nèi)存分配和數(shù)據(jù)拷貝的開銷。
因為 StringHeader 比 SliceHeader 缺少一個表示容量的字段 Cap,所以通過 unsafe.Pointer 將 *SliceHeader 轉(zhuǎn)換為 *StringHeader 沒有問題,但是反之就不行了。我們需要補上一個 Cap 字段,并且將字段 Len 的值作為字段 Cap 的默認值。
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é)切片仍然不能修改,因為在 Golang 語言中字符串是只讀的,通過索引下標修改會引發(fā) panic。
06總結(jié)
本文我們介紹了怎么高效使用 Golang 語言中的字符串,先是介紹了字符串在 runtime 中的數(shù)據(jù)結(jié)構(gòu),然后介紹了字符串拼接的幾種方式,字符串與字節(jié)切片零拷貝互相轉(zhuǎn)換,還通過示例代碼證明了字符串在 Golang 語言中是只讀的。更多關(guān)于字符串的操作,讀者可以閱讀標準庫 strings 和 strconv 了解更多內(nèi)容。
到此這篇關(guān)于Golang 語言高效使用字符串的方法的文章就介紹到這了,更多相關(guān)Golang 語言使用字符串內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
您可能感興趣的文章:- 用golang如何替換某個文件中的字符串
- 解決golang時間字符串轉(zhuǎn)time.Time的坑
- golang中json小談之字符串轉(zhuǎn)浮點數(shù)的操作
- golang 如何替換掉字符串里面的換行符\n
- golang 字符串比較是否相等的方法示例
- 解決Golang json序列化字符串時多了\的情況
- golang 獲取字符串長度的案例
- golang如何去除多余空白字符(含制表符)