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