FanXing Blog

FanXing Blog

一个热爱编程的高中生,正在努力成为一位优秀的后端工程师~

Day2. Go語言精進之路:string實現原理並高效使用

前幾天忙著複習 GORM,那篇文章過一段時間發,今天先來學 Golang 的 string 類型。


關於 String#

字符串類型 (string) 是現代編程語言中最常用的數據類型。在 Go 的先祖之一的 C 語言中,字符串沒有被顯式定義,是以字符串字面值常量或是以 \0 結尾的字符類型 (char) 陣列組成。

#define GOAUTHERS "Robert Griesemer, Rob Pike, and Ken Thompson"
const char * s = "hello world"
char s[] = "hello gopher"

但是這樣,就給 C 程序員使用字符串時造成了一些困擾,諸如:

  • 類型安全差
  • 字符串操作時要時時考慮結尾的 \0
  • 字符串數據可變
  • 獲取字符串長度代價大 (O(n)的時間複雜度)
  • 未內置對非 ASCII 字符的處理

但是在 Go 語言中修復了 C 語言的這一 "缺陷",內置了 string 類型,並且進行了統一的抽象。

Go 語言中的字符串#

在 Go 語言中,無論是代碼中的常量、還是變量、還是出現的字符串字面量,他們都被統一的設置為 string

const S = "hello world"

func main() {
    var s1 string = "hello string"
    fmt.Printf("%T\n", S) // string
    fmt.Printf("%T\n", s1) // string
    fmt.Printf("%T\n", "hello") // string
}

Go 的 string 類型吸取了 C 語言字符串設計的經驗,並且結合了其他語言的字符串類型上的最佳實踐,最終 Go 語言的字符串呈現出以下功能特點:

1. 數據不可變#

一旦聲明了一個 string 類型的字符串,無論是 常量 還是 變量 那麼該標識符所代指的數據在整個程序的生命周期內無法改變。我們可以嘗試一下,首先嘗試第一種方案:

func main() {
	var s1 string = "hello string"
    fmt.Printf("原字符串: %s\n", s1)

	// 切片化後嘗試改變
	sl := []byte(s1)
	sl[0] = 'l'
	fmt.Printf("slice: %s\n", sl)
	fmt.Printf("切片後,原字符串為: %s\n", s1)
}
// output:
// 原字符串: hello string
// slice: lello string
// 切片後,原字符串為: hello string

可以看到,我們將字符串轉換為一個切片後,嘗試對其修改,結果和我們想象中的不一致。對 string 切片後,Go 編譯器會為切片重新分配新的底層內存,而不是和原 string 共用同一個底層內存,因此對切片的修改,並沒有對 s1 造成任何影響。

我們通過 unsafe 進行更暴力的手段試試:

func main() {
	var s1 string = "hello"
	fmt.Printf("原字符串: %s\n", s1)

	modifyString(&s1)
	fmt.Println(s1)
}

func modifyString(s *string) {
	// 取出第一個 8 字節的值
	p := (*uintptr)(unsafe.Pointer(s))

	// 獲取底層數組的地址
	var array *[5]byte = (*[5]byte)(unsafe.Pointer(p))
	var l *int = (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(s)) + unsafe.Sizeof((*uintptr)(nil))))

	for i := 0; i < (*l); i++ {
		fmt.Printf("%p => %c\n", &((*array)[i]), (*array)[i])
		pl := &((*array)[i])
		v := (*pl)
		(*pl) = v + 1
	}
}
// output:
// 原字符串: hello
// 0xc00016b8f0 => ô
// 0xc00016b8f1 => Í
// 0xc00016b8f2 => 
// 0xc00016b8f3 =>  
// 0xc00016b8f4 =>  
// unexpected fault address 0x10120cef5
// fatal error: fault
// [signal 0xc0000005 code=0x0 addr=0x10120cef5 pc=0x13ef7c]

我們可以發現,string 的底層數據區僅能進行只讀操作,一旦嘗試修改那部分內存,就會得到 SIGBUS 運行時錯誤,對 string 數據的 "篡改攻擊" 再次失敗。

2. 零值可用#

Go string 類型支持 "零值可用" 的理念。Go 字符串無需像 C 語言中那樣考慮結尾 \0 的字符,因此其零值為 "",長度為 0。

var s string
fmt.Printf("%s\n", s)
fmt.Printf("%d\n", len(s))
// output:
// ""
// 0

3. 獲取長度的時間複雜度是 O (1) 複雜度#

Go string 類型的數據長度是不可變的,因此一旦有了初值,那塊數據就不會變,其長度也不會改變。Go 將這個長度作為一個字段存儲在 string 類型的內部表示結構中。這樣獲取字符串長度時,即 len(s) 實際上就只是在讀取 runtime/string 中的一個字段,這是一個代價極低的操作。

4. 支持通過 +/+= 操作符進行字符串連接#

對開發者來講,通過 ++= 操作符進行的字符串連接是體驗最好的字符串連接操作,而 Go 語言支持該操作:

s := "hello"
s = s + " world"
s += ", golang"

fmt.Println(s)
// output: hello world, golang

5. 支持各種比較關係操作符:==, !=, >=, <=,> 和 <#

Go 支持各種比較關係操作符:

func main() {
	s1 := "hello world"
	s2 := "hello" + " world"
	fmt.Println(s1 == s2)

	s1 = "Go"
	s2 = "C"
	fmt.Println(s1 != s2)

	s1 = "12345"
	s2 = "23456"
	fmt.Println(s1 < s2)
	fmt.Println(s1 <= s2)

	s1 = "12345"
	s2 = "123"
	fmt.Println(s1 > s2)
	fmt.Println(s1 >= s2)
}
// output:
// true
// true
// true
// true
// true
// true

由於 Go String 是不可變的,因此如果兩個字符串長度不相同,那麼無須比較具體字符串數據即可斷定兩個字符串是不同的。如果長度相同,則要進一步判斷數據指針是否指向同一塊存儲數據。如果相同,則兩個字符串是等價的;如果不同,則還需進一步比較實際的數據內容。

6. 對非 ASCII 字符提供原生支持#

Go 語言源文件全部默認採用 unicode 字符集。Unicode 字符集是目前市面上最流行的字符集,幾乎囊括了所有非 ASCII 字符。Go 字符串的每個字符都是一個 unicode 字符,而且這些 unicode 字符是以 utf-8 編碼存儲在內存當中。

7. 原生支持多行字符串#

C 語言中如果要構建多行字符串,要麼使用多個字符串進行拼接,要麼結合續行符 \,很難控制好格式。

而 Go 語言直接提供了反引號方式構建多行字符串的方法:

func main() {

	s := `hello
world
golang`

	fmt.Println(s)
}
// output:
// hello
// world
// golang

字符串的內部表示#

Go string 類型的特性實現與 Go runtime 對 string 類型的內部表示是分不開的。Go string 在 runtime 中表現為以下結構:

// $GOROOT/src/runtime/string.go
type stringStruct struct {
    str unsafe.Pointer
    len int
}

我們看到的 string 字符串其實是一個描述符,它本身並不真正存儲數據,它指向了一個由指向底層存儲的指針和字符串的長度字段組成的對象。我們結合 string 的實例化過程來看:

// $GOROOT/src/runtime/string.go

func rawstring(size int) (s string, b []byte) {
    p := mallocgc(uintptr(size), nil, false)
    stringStructOf(&s).str = p
    stringStructOf(&s).len = size

    *(*slice)(unsafe.Pointer(&b)) = slice{p, size, size}

    return
}

結合該圖分析:

image

我們可以看到,每一個字符串都對應了一個 stringStruct 實例。我們在執行 rawstring 後,stringStruct 中的 str 指針指向了真正存儲字符串數據的底層內存區域,len 字段存儲的是字符串的長度;同時,rawstring 同時還會創建一個臨時 slice,該 slicearray 指針也指向字符串的底層存儲內存區域。注意,rawstring 執行之後,申請的內存區域還沒有被寫入數據,該 slice 就是為了在後續運行時中向內存中寫入數據 "hello"

寫完數據後,slice 就會被回收掉了。

根據 stringruntime 中的表示我們可以發現,直接將 string 類型通過 函數 / 方法 參數傳入也不会有太多損耗,因為傳入的僅僅是一個描述符,而不是真正的字符串數據。

字符串的高效構造#

前面講到,Go 原生就支持使用 +/+= 操作符來連接多個字符串以構造一個更長的字符串,並且通過 +/+= 來構造是開發體驗最好的一種方法。但是 Go 還提供了一些其他的構造方式:

  • fmt.Sprintf
  • strings.Join
  • strings.Builder
  • bytes.Buffer

這些方法中,究竟哪種方法效率最高?我們使用基準測試的數據作為參考:

var sl []string = []string{
	"Rob Pike ",
	"Robert Griesemer ",
	"Ken Thompson ",
}

func concatStringByOperator(sl []string) string {
	var s string
	for _, v := range sl {
		s += v
	}
	return s
}

func concatStringBySprintf(sl []string) string {
	var s string
	for _, v := range sl {
		s = fmt.Sprintf("%s%s", s, v)
	}
	return s
}

func concatStringByJoin(sl []string) string {
	return strings.Join(sl, "")
}

func concatStringByStringsBuilder(sl []string) string {
	var b strings.Builder
	for _, v := range sl {
		b.WriteString(v)
	}
	return b.String()
}

func concatStringByStringsBuilderWithInitSize(sl []string) string {
	var b strings.Builder
	b.Grow(64)
	for _, v := range sl {
		b.WriteString(v)
	}
	return b.String()
}

func concatStringByBytesBuffer(sl []string) string {
	var b bytes.Buffer
	for _, v := range sl {
		b.WriteString(v)
	}
	return b.String()
}

func concatStringByBytesBufferWithInitSize(sl []string) string {
	buf := make([]byte, 0, 64)
	b := bytes.NewBuffer(buf)
	for _, v := range sl {
		b.WriteString(v)
	}
	return b.String()
}

func BenchmarkConcatStringByOperator(b *testing.B) {
	for n := 0; n < b.N; n++ {
		concatStringByOperator(sl)
	}
}

func BenchmarkConcatStringBySprintf(b *testing.B) {
	for n := 0; n < b.N; n++ {
		concatStringBySprintf(sl)
	}
}

func BenchmarkConcatStringByJoin(b *testing.B) {
	for n := 0; n < b.N; n++ {
		concatStringByJoin(sl)
	}
}

func BenchmarkConcatStringByStringsBuilder(b *testing.B) {
	for n := 0; n < b.N; n++ {
		concatStringByStringsBuilder(sl)
	}
}

func BenchmarkConcatStringByStringsBuilderWithInitSize(b *testing.B) {
	for n := 0; n < b.N; n++ {
		concatStringByStringsBuilderWithInitSize(sl)
	}
}

func BenchmarkConcatStringByBytesBuffer(b *testing.B) {
	for n := 0; n < b.N; n++ {
		concatStringByBytesBuffer(sl)
	}
}

func BenchmarkConcatStringByBytesBufferWithInitSize(b *testing.B) {
	for n := 0; n < b.N; n++ {
		concatStringByBytesBufferWithInitSize(sl)
	}
}

測試結果如下:

goos: windows
goarch: amd64
pkg: prometheus_for_go
cpu: AMD Ryzen 7 5800H with Radeon Graphics         
BenchmarkConcatStringByOperator
BenchmarkConcatStringByOperator-16                      	16968997	        67.09 ns/op	      80 B/op	       2 allocs/op
BenchmarkConcatStringBySprintf
BenchmarkConcatStringBySprintf-16                       	 3519394	       337.6 ns/op	     176 B/op	       8 allocs/op
BenchmarkConcatStringByJoin
BenchmarkConcatStringByJoin-16                          	27803714	        42.37 ns/op	      48 B/op	       1 allocs/op
BenchmarkConcatStringByStringsBuilder
BenchmarkConcatStringByStringsBuilder-16                	13961523	        85.36 ns/op	     112 B/op	       3 allocs/op
BenchmarkConcatStringByStringsBuilderWithInitSize
BenchmarkConcatStringByStringsBuilderWithInitSize-16    	32188840	        38.04 ns/op	      64 B/op	       1 allocs/op
BenchmarkConcatStringByBytesBuffer
BenchmarkConcatStringByBytesBuffer-16                   	18743878	        63.34 ns/op	     112 B/op	       2 allocs/op
BenchmarkConcatStringByBytesBufferWithInitSize
BenchmarkConcatStringByBytesBufferWithInitSize-16       	33670977	        35.76 ns/op	      48 B/op	       1 allocs/op

我們可以看到,做了預初始化的 Bytes.Buffer 的效率最高,每次操作僅需要 35.76ns,並且內存操作也僅僅需要一次。經過我的多次測試,效能最佳的均是該項,也就是預初始化後的 bytes.Buffer

然後就是預初始化的 strings.Builder,僅僅比 bytes.Buffer 慢了大概 3ns,可以說是非常接近 bytes.Buffer 的水平。

所以我們可以得出結論:

  • 在能預估最終字符串長度的情況下,使用預初始化的 bytes.Bufferstrings.Builder 構建字符串的效果最佳;
  • strings.Join 的性能是最穩定的,如果輸入多個字符串,是以 []string 承載的,那麼 strings.Join 也是個不錯的選擇
  • 直接使用操作符是最直觀、最自然的,在編譯器知道連接字符串個數的情況下,編譯器會對這種方法進行優化。
  • fmt.Sprintf 雖然效率不高,但是如果我們需要穩定的使用不同變量來構建某種特定格式的字符串,這種方式還是最合適的

字符串的高效轉換#

前面的例子中,我們看到了 string[]rune 之間的轉換,以及 string[]byte 的轉換,這兩個轉換都是可逆的。也就是說,string 可以和 []byte 以及 []rune 之間相互轉換。這是一個例子:

func main() {
    rs := []rune{
        0x4E2D,
        0x56FD,
        0x6B22,
        0x8FCE,
        0x60A8,
    }

    s := string(rs)
    fmt.Println(s)

    sl := []byte{
        0xE4, 0xB8, 0xAD,
        0xE5, 0x9B, 0xBD,
        0xE6, 0xAC, 0xA2,
        0xE8, 0xBF, 0x8E,
        0xE6, 0x82, 0xA8,
    }

    s = string(sl)
    fmt.Println(s)
}

$go run string_slice_to_string.go
中國歡迎您
中國歡迎您

不管是 stringslice 還是 slicestring,轉換都需要付出代價,也就是內存操作。這個代價的根源是,string 是不可變的,所以每次轉換,都需要為轉換後的類型分配新的內存,我們可以看一下 stringslice 之間的轉換過程中,內存分配情況:

func byteSliceToString() {
	sl := []byte{
		0xE4, 0xB8, 0xAD,
		0xE5, 0x9B, 0xBD,
		0xE6, 0xAC, 0xA2,
		0xE8, 0xBF, 0x8E,
		0xE6, 0x82, 0xA8,
		0xEF, 0xBC, 0x8C,
		0xE5, 0x8C, 0x97,
		0xE4, 0xBA, 0xAC,
		0xE6, 0xAC, 0xA2,
		0xE8, 0xBF, 0x8E,
		0xE6, 0x82, 0xA8,
	}

	_ = string(sl)
}

func stringToByteSlice() {
	s := "中國歡迎您,北京歡迎您"
	_ = []byte(s)
}

func main() {
	fmt.Println(testing.AllocsPerRun(1, byteSliceToString))
	fmt.Println(testing.AllocsPerRun(1, stringToByteSlice))
}
// output:
// 1
// 0

在這裡,出現了一個有趣的地方,我的操作環境是:go version go1.23.4 windows/amd64,在我將 s 字符串更換為 s := fmt.Sprintf("中國歡迎您,北京歡迎您") 之後,stringToBytesSlice 的內存操作次數從 0 變為 1。

也就是這樣的代碼:

// 其他代碼均無改變

func stringToByteSlice() {
	s := fmt.Sprintf("中國歡迎您,北京歡迎您")
	_ = []byte(s)
}

// output:
// 1
// 1

也就是說,如果一個字符串是編譯器可以預測到的,那麼編譯器會優化 stringToByteSlice 這樣的操作,使其內存操作次數為 0。但是如果,字符串是不可知的,也就是 fmt.Sprintf 這種在線構建的,只有在運行時才會知道字符串的內容,那麼就無法被編譯器所優化了,內存操作次數就會為 1。

那麼就可以確定一個概念:

如果是一個可以確定內容的字符串,那麼編譯器會在 stringToByteSlice 的過程中進行優化,如果字符串的內容是在運行時確定,那麼在轉換過程中仍然會出現內存分配。

那麼在 Go Runtime 層面呢?在 Golang runtime 中,負責轉換的函數如下:

// $GOROOT/src/runtime/string.go
slicebytetostring: []byte -> string
slicerunetostring: []rune -> string
stringtoslicebyte: string -> []byte
stringtoslicerune: string -> []rune

我們就以 byte slice 為例子,看看 stringbytetostringstringtoslicebyte 的具體實現:

// $GOROOT/src/runtime/string.go

const tmpStringBufSize = 32
type tmpBuf [tmpStringBufSize]byte

func stringtoslicebyte(buf *tmpBuf, s string) []byte {
	var b []byte
	if buf != nil && len(s) <= len(buf) {
		*buf = tmpBuf{}
		b = buf[:len(s)]
	} else {
		b = rawbyteslice(len(s))
	}
	copy(b, s)
	return b
}

func slicebytetostring(buf *tmpBuf, ptr *byte, n int) string {
	if n == 0 {
		// Turns out to be a relatively common case.
		// Consider that you want to parse out data between parens in "foo()bar",
		// you find the indices and convert the subslice to string.
		return ""
	}
	if raceenabled {
		racereadrangepc(unsafe.Pointer(ptr),
			uintptr(n),
			getcallerpc(),
			abi.FuncPCABIInternal(slicebytetostring))
	}
	if msanenabled {
		msanread(unsafe.Pointer(ptr), uintptr(n))
	}
	if asanenabled {
		asanread(unsafe.Pointer(ptr), uintptr(n))
	}
	if n == 1 {
		p := unsafe.Pointer(&staticuint64s[*ptr])
		if goarch.BigEndian {
			p = add(p, 7)
		}
		return unsafe.String((*byte)(p), 1)
	}

	var p unsafe.Pointer
	if buf != nil && n <= len(buf) {
		p = unsafe.Pointer(buf)
	} else {
		p = mallocgc(uintptr(n), nil, false)
	}
	memmove(p, unsafe.Pointer(ptr), uintptr(n))
	return unsafe.String((*byte)(p), n)
}

如果想要實現高效的轉換,那麼唯一的方法就是減少內存分配。我們可以看到,runtime 中實現類型轉換的函數已經加入了避免內存重複操作的代碼,比如 tmpBuf 复用,空字符串優化,單字節字符串優化。

slice 是不可比較的,而 string 是可以被比較的,所以我們經常會將 slice 轉換為 string。Go 編譯器為了這樣的場景專門做了優化,runtime 中有一個叫做 slicebytetostringtmp 的函數就是協助實現該優化的:

func slicebytetostringtmp(ptr *byte, n int) string {
	if raceenabled && n > 0 {
		racereadrangepc(unsafe.Pointer(ptr),
			uintptr(n),
			getcallerpc(),
			abi.FuncPCABIInternal(slicebytetostringtmp))
	}
	if msanenabled && n > 0 {
		msanread(unsafe.Pointer(ptr), uintptr(n))
	}
	if asanenabled && n > 0 {
		asanread(unsafe.Pointer(ptr), uintptr(n))
	}
	return unsafe.String(ptr, n)
}

那麼 slicebytetostringtmp 做了什麼優化?這個函數的優化操作是很激進的,它選擇直接復用 slice 的底層內存,這樣不會做任何的內存分配和值拷貝。但是用這個函數的前提是:原 slice 被修改後,這個字符串會直接不可用。所以這個函數一般會用在以下幾個場景中:

b := []byte("k", "e", "y")

// 1. string(b) 用在 map 類型的 key 中
m := make(map[string]string)
m[string(b)] = "value"
m[[3]string{string(b), "key1", "key2"}] = "value1"

// 2. string(b) 用在字符串連接語句中
s := "hello " + string(b) + "!"

// 3. string(b) 用在字符串比較中
s := "tom"

if s < string(b) {
    ...
}
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。