天天看點

Golang 語言怎麼高效使用字元串?

01

介紹

在 Golang 語言中,string 類型的值是隻讀的,不可以被修改。如果需要修改,通常的做法是對原字元串進行截取和拼接操作,進而生成一個新字元串,但是會涉及記憶體配置設定和資料拷貝,進而有性能開銷。本文我們介紹在 Golang 語言中怎麼高效使用字元串。

02

字元串的資料結構

在 Golang 語言中,字元串的值存儲在一塊連續的記憶體空間,我們可以把存儲資料的記憶體空間看作一個位元組數組,字元串在 runtime 中的資料結構是一個結構體 stringStruct,該結構體包含兩個字段,分别是指針類型的 str 和整型的 len。字段 str 是指向位元組數組頭部的指針值,字段 len 的值是字元串的長度(位元組個數)。

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
           

複制

閱讀上面這段代碼,我們可以發現使用字元串作為參數,和使用字元串指針作為參數,它們的性能基本相同。

雖然字元串的值并不是具體的資料,而是一個指向存儲字元串資料的記憶體位址的指針和一個字元串的長度,但是字元串仍然是值類型。

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 轉換為位元組切片類型,并指派給變量 byteSlice,使用索引下标修改 byteSlice 的值,列印結果仍未發生改變。

因為字元串轉換為位元組切片,Golang 編譯器會為位元組切片類型的變量重新配置設定記憶體來存儲資料,而不是和字元串類型的變量共用同一塊記憶體空間。

可能會有讀者想到用指針修改字元串類型的變量存儲在記憶體中的資料。

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]
           

複制

閱讀上面這段代碼,我們可以發現在代碼中嘗試通過指針修改 string 類型的 str 變量的存儲在記憶體中的資料,結果引發了 signal SIGBUS 運作時錯誤,進而證明 string 類型的變量是隻讀的。

我們已經知道字元串在 runtime 中的結構體包含兩個字段,指向存儲資料的記憶體位址的指針和字元串的長度,因為字元串是隻讀的,字元串被指派後,它的資料和長度都不會被修改,是以讀取字元串的長度,實際上就是讀取字段 len 的值,複雜度是 O(1)。

在字元串比較時,因為字元串是隻讀的,不可修改的,是以隻要兩個比較的字元串的長度 len 的值不同,就可以判斷這兩個字元串不相同,不用再去比較兩個字元串存儲的具體資料。

如果 len 的值相同,再去判斷兩個字元串的指針是否指向同一塊記憶體,如果 len 的值相同,并且指針指向同一塊記憶體,則可以判斷兩個字元串相同。但是如果 len 的值相同,而指針不是指向同一塊記憶體,那麼還需要繼續去比較兩個字元串的指針指向的字元串資料是否相同。

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 是位元組切片類型,但是它的值仍然不允許被修改,但是位元組切片中的值可以被拼接或者被重置。

Builder 提供了一系列 Write* 拼接方法,這些方法可以用于把新資料拼接到已存在的資料的末尾,同時如果位元組切片的容量不夠用,可以自動擴容。需要注意的是,隻要觸發擴容,就會涉及記憶體配置設定和資料拷貝。自動擴容規則和切片的擴容規則相同。

除了自動擴容,還可以手動擴容,Builder 提供的 Grow 方法,可以根據 int 類型的傳參,擴充位元組數量。因為擴容操作,會涉及記憶體配置設定和資料拷貝,是以調用 Grow 方法手動擴容時,Golang 也做了優化,如果目前位元組切片的容量剩餘位元組數小于或等于傳參的值, Grow 方法将不會執行擴容操作。手動擴容規則是原位元組切片容量的 2 倍加上傳參的值。

Builder 類型還提供了一個重置方法 Reset,它可以将 Builder 類型的變量重置為零值。被重置後,原位元組切片将會被垃圾回收。

在了解完上述 Builder 的介紹後,相信讀者已對 Builder 有了初步認識。下面我們通過代碼看一下預配置設定位元組數量和未配置設定位元組數量的差別:

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
           

複制

閱讀上面這段代碼,可以發現調用 Grow 方法,預配置設定位元組數量比未預配置設定位元組數量的字元串拼接效率高。我們在可以預估位元組數量的前提下,盡量使用 Grow 方法預先配置設定位元組數量。

注意:第一,Builder 類型的變量在被調用之後,不可以再被複制,否則會引發 panic。第二,因為 Builder 類型的值不是完全不可修改的,是以使用者需要注意并發安全的問題。

05

字元串和位元組切片互相轉換

因為切片類型除了隻能和 nil 做比較之外,切片類型之間是無法做比較操作的。如果我們需要對切片類型做比較操作,通常的做法是先将切片類型轉換為字元串類型。但是因為 string 類型是隻讀的,不可修改的,是以轉換操作會涉及記憶體配置設定和資料拷貝。

為了提升轉換的性能,唯一的方法就是減少或者避免記憶體配置設定的開銷。在 Golang 語言中,運作時對二者的互相轉換也做了優化,感興趣的讀者可以閱讀 runtime 中的相關源碼:

/usr/local/go/src/runtime/string.go
           

複制

但是,我們還可以繼續優化,實作零拷貝的轉換操作,進而避免記憶體配置設定的開銷,提升轉換效率。

先閱讀 reflect 中 StringHeader 和 SliceHeader 的資料結構:

// /usr/local/go/src/reflect/value.go

type StringHeader struct {
 Data uintptr // 指向存儲資料的位元組數組
 Len  int // 長度
}

type SliceHeader struct {
 Data uintptr // 指向存儲資料的位元組數組
 Len  int // 長度
 Cap  int // 容量
}
           

複制

閱讀上面這段代碼,我們可以發現 StringHeader 和 SliceHeader 的字段隻缺少一個表示容量的字段 Cap,二者都有指向存儲資料的位元組數組的指針和長度。我們隻需要通過使用

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
           

複制

閱讀上面這段代碼,我們可以發現通過使用

unsafe.Pointer

把字元串轉換為位元組切片,可以做到零拷貝,str 和 str2 共用同一塊記憶體,無需新配置設定一塊記憶體。但是需要注意的是,轉換後的位元組切片仍然不能修改,因為在 Golang 語言中字元串是隻讀的,通過索引下标修改會引發 panic。

06

總結

本文我們介紹了怎麼高效使用 Golang 語言中的字元串,先是介紹了字元串在 runtime 中的資料結構,然後介紹了字元串拼接的幾種方式,字元串與位元組切片零拷貝互相轉換,還通過示例代碼證明了字元串在 Golang 語言中是隻讀的。更多關于字元串的操作,讀者可以閱讀标準庫 strings 和 strconv 了解更多内容。