詳解Golang中string的實(shí)現(xiàn)原理與高效使用
字符串類型是現(xiàn)代編程語言中最常使用的數(shù)據(jù)類型之一。在Go語言的先祖之一C語言當(dāng)中,字符串類型并沒有被顯式定義,而是以字符串字面值常量或以'\0'結(jié)尾的字符類型(char)數(shù)組來呈現(xiàn)的。
const char * s = "hello world" char s[] = "hello gopher"
這給C程序員在使用字符串時(shí)帶來一些問題,諸如:
- 類型安全性差;
- 字符串操作要時(shí)時(shí)刻刻考慮結(jié)尾的'\0';
- 字符串?dāng)?shù)據(jù)可變(主要指以字符數(shù)組形式定義的字符串類型);
- 獲取字符串長度代價(jià)大(O(n)的時(shí)間復(fù)雜度);
- 未內(nèi)置對(duì)非ASCII字符(如中文字符)的處理。
Go語言修復(fù)了C語言的這一“缺陷”,內(nèi)置了string類型,統(tǒng)一了對(duì)字符串的抽象。
在Go語言中,無論是字符串常量、字符串變量還是代碼中出現(xiàn)的字符串字面量,它們的類型都被統(tǒng)一設(shè)置為string。
Go的string類型設(shè)計(jì)充分吸取了C語言字符串設(shè)計(jì)的經(jīng)驗(yàn)教訓(xùn),并結(jié)合了其他主流語言在字符串類型設(shè)計(jì)上的最佳實(shí)踐,最終呈現(xiàn)的string類型具有如下功能特點(diǎn)。
(1)string類型的數(shù)據(jù)是不可變的,一旦聲明了一個(gè)string類型的標(biāo)識(shí)符,無論是常量還是變量,該標(biāo)識(shí)符所指代的數(shù)據(jù)在整個(gè)程序的生命周期內(nèi)便無法更改。下面嘗試修改一下string數(shù)據(jù),看看能得到怎樣的結(jié)果。
func main() { // 原始字符串 var s string = "hello" fmt.Println("original string:", s) // 切片化后試圖改變原字符串 sl := []byte(s) sl[0] = 't' fmt.Println("slice:", string(sl)) fmt.Println("after reslice, the original string is:", string(s)) }
該程序的運(yùn)行結(jié)果如下:
$go run string.go
original string: hello
slice: tello
after reslice, the original string is: hello
(2)零值可用
Go string類型支持“零值可用”的理念。Go字符串無須像C語言中那樣考慮結(jié)尾'\0'字符,因此其零值為"",長度為0。
(3)獲取長度的時(shí)間復(fù)雜度是O(1)級(jí)別
Go string類型數(shù)據(jù)是不可變的,因此一旦有了初值,那塊數(shù)據(jù)就不會(huì)改變,其長度也不會(huì)改變。Go將這個(gè)長度作為一個(gè)字段存儲(chǔ)在運(yùn)行時(shí)的string類型的內(nèi)部表示結(jié)構(gòu)中(后文有說明)。這樣獲取string長度的操作,即len(s)實(shí)際上就是讀取存儲(chǔ)在運(yùn)行時(shí)中的那個(gè)長度值,這是一個(gè)代價(jià)極低的O(1)操作。
(4)支持通過+/+=操作符進(jìn)行字符串連接
對(duì)開發(fā)者而言,通過+/+=操作符進(jìn)行的字符串連接是體驗(yàn)最好的字符串連接操作,Go語言支持這種操作:
s := "Rob Pike, " s = s + "Robert Griesemer, " s += " Ken Thompson"
(5)支持各種比較關(guān)系操作符:==、!= 、>=、<=、>和<
由于Go string是不可變的,因此如果兩個(gè)字符串的長度不相同,那么無須比較具體字符串?dāng)?shù)據(jù)即可斷定兩個(gè)字符串是不同的。如果長度相同,則要進(jìn)一步判斷數(shù)據(jù)指針是否指向同一塊底層存儲(chǔ)數(shù)據(jù)。如果相同,則兩個(gè)字符串是等價(jià)的;如果不同,則還需進(jìn)一步比對(duì)實(shí)際的數(shù)據(jù)內(nèi)容。
(6)對(duì)非ASCII字符提供原生支持
Go語言源文件默認(rèn)采用的Unicode字符集。Unicode字符集是目前市面上最流行的字符集,幾乎囊括了所有主流非ASCII字符(包括中文字符)。Go字符串的每個(gè)字符都是一個(gè)Unicode字符,并且這些Unicode字符是以UTF-8編碼格式存儲(chǔ)在內(nèi)存當(dāng)中的。我們來看一個(gè)例子:
// func main() { // 中文字符 Unicode碼點(diǎn) UTF8編碼 // 中 U+4E2D E4B8AD // 國 U+56FD E59BBD // 歡 U+6B22 E6ACA2 // 迎 U+8FCE E8BF8E // 您 U+60A8 E682A8 s := "中國歡迎您" rs := []rune(s) sl := []byte(s) for i, v := range rs { var utf8Bytes []byte for j := i * 3; j < (i+1)*3; j++ { utf8Bytes = append(utf8Bytes, sl[j]) } fmt.Printf("%s => %X => %X\n", string(v), v, utf8Bytes) } } $go run 中 => 4E2D => E4B8AD 國 => 56FD => E59BBD 歡 => 6B22 => E6ACA2 迎 => 8FCE => E8BF8E 您 => 60A8 => E682A8
我們看到字符串變量s中存儲(chǔ)的文本是“中國歡迎您”五個(gè)漢字字符(非ASCII字符范疇),這里輸出了每個(gè)中文字符對(duì)應(yīng)的Unicode碼點(diǎn)(Code Point,見輸出結(jié)果的第二列),一個(gè)rune對(duì)應(yīng)一個(gè)碼點(diǎn)。UTF-8編碼是Unicode碼點(diǎn)的一種字符編碼形式,是最常用的一種編碼格式,也是Go默認(rèn)的字符編碼格式。我們還可以使用其他字符編碼格式來映射Unicode碼點(diǎn),比如UTF-16等。
在UTF-8中,大多數(shù)中文字符都使用三字節(jié)表示。[]byte(s)的轉(zhuǎn)型讓我們獲得了s底層存儲(chǔ)的“復(fù)制品”,從而得到每個(gè)漢字字符對(duì)應(yīng)的UTF-8編碼字節(jié)(見輸出結(jié)果的第三列)。
(7)原生支持多行字符串
C語言中要構(gòu)造多行字符串,要么使用多個(gè)字符串的自然拼接,要么結(jié)合續(xù)行符“\”,很難控制好格式:
#include <stdio.h> char *s = "古藤老樹昏鴉\n" "小橋流水人家\n" "古道西風(fēng)瘦馬\n" "斷腸人在天涯"; int main() { printf("%s\n", s); }
go語言方式:
const s = `古藤老樹昏鴉 小橋流水人家 古道西風(fēng)瘦馬 斷腸人在天涯`; func main () { fmt.Println(s) }
字符串內(nèi)部結(jié)構(gòu)
Go string類型上述特性的實(shí)現(xiàn)與Go運(yùn)行時(shí)對(duì)string類型的內(nèi)部表示是分不開的。Go string在運(yùn)行時(shí)表示為下面的結(jié)構(gòu):
// $GOROOT/src/runtime/string.go type stringStruct struct { str unsafe.Pointer len int }
我們看到string類型也是一個(gè)描述符,它本身并不真正存儲(chǔ)數(shù)據(jù),而僅是由一個(gè)指向底層存儲(chǔ)的指針和字符串的長度字段組成。我們結(jié)合一個(gè)string的實(shí)例化過程來看。下面是runtime包中實(shí)例化一個(gè)字符串對(duì)應(yīng)的函數(shù):
// $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 }
我們看到每個(gè)字符串類型變量/常量對(duì)應(yīng)一個(gè)stringStruct實(shí)例,經(jīng)過rawstring實(shí)例化后,stringStruct中的str指針指向真正存儲(chǔ)字符串?dāng)?shù)據(jù)的底層內(nèi)存區(qū)域,len字段存儲(chǔ)的是字符串的長度(這里是5);rawstring同時(shí)還創(chuàng)建了一個(gè)臨時(shí)slice,該slice的array指針也指向存儲(chǔ)字符串?dāng)?shù)據(jù)的底層內(nèi)存區(qū)域。注意,rawstring調(diào)用后,新申請的內(nèi)存區(qū)域還未被寫入數(shù)據(jù),該slice就是供后續(xù)運(yùn)行時(shí)層向其中寫入數(shù)據(jù)("hello")用的。寫完數(shù)據(jù)后,該slice就可以被回收掉了
根據(jù)string在運(yùn)行時(shí)的表示可以得到這樣一個(gè)結(jié)論:直接將string類型通過函數(shù)/方法參數(shù)傳入也不會(huì)有太多的損耗,因?yàn)閭魅氲膬H僅是一個(gè)“描述符”,而不是真正的字符串?dāng)?shù)據(jù)。我們通過一個(gè)簡單的基準(zhǔn)測試來驗(yàn)證一下:
// var s string = `Go, also known as Golang, is a statically typed, compiled programming language designed at Google by Robert Griesemer, Rob Pike, and Ken Thompson. Go is syntactically similar to C, but with memory safety, garbage collection, structural typing, and communicating sequential processes (CSP)-style concurrency.` func handleString(s string) { _ = s + "hello" } func handlePtrToString(s *string) { _ = *s + "hello" } func BenchmarkHandleString(b *testing.B) { for n := 0; n < b.N; n++ { handleString(s) } } func BenchmarkHandlePtrToString(b *testing.B) { for n := 0; n < b.N; n++ { handlePtrToString(&s) } }
$go test -bench . -benchmem string_as_param_benchmark_test.go
goos: darwin
goarch: amd64
BenchmarkHandleString-8 15668872 70.7 ns/op 320 B/op 1 allocs/op
BenchmarkHandlePtrToString-8 15809401 71.8 ns/op 320 B/op 1 allocs/op
PASS
ok command-line-arguments 2.407s
我們看到直接傳入string與傳入string指針兩者的基準(zhǔn)測試結(jié)果幾乎一模一樣,因此Gopher大可放心地直接使用string作為函數(shù)/方法參數(shù)類型。
高效構(gòu)造
前面提到過,Go原生支持通過+/+=操作符來連接多個(gè)字符串以構(gòu)造一個(gè)更長的字符串,并且通過+/+=操作符的字符串連接構(gòu)造是最自然、開發(fā)體驗(yàn)最好的一種。但Go還提供了其他一些構(gòu)造字符串的方法,比如:
使用fmt.Sprintf;使用strings.Join;使用strings.Builder;使用bytes.Buffer。
在這些方法中哪種方法最為高效呢?我們使用基準(zhǔn)測試的數(shù)據(jù)作為參考:
// 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) } }
運(yùn)行:
$go test -bench=. -benchmem ./string_concat_benchmark_test.go
goos: darwin
goarch: amd64
BenchmarkConcatStringByOperator-8 11744653 89.1 ns/op 80 B/op 2 allocs/op
BenchmarkConcatStringBySprintf-8 2792876 420 ns/op 176 B/op 8 allocs/op
BenchmarkConcatStringByJoin-8 22923051 49.1 ns/op 48 B/op 1 allocs/op
BenchmarkConcatStringByStringsBuilder-8 11347185 96.6 ns/op 112 B/op 3 allocs/op
BenchmarkConcatStringByStringsBuilderWithInitSize-8 26315769 42.3 ns/op 64 B/op 1 allocs/op
BenchmarkConcatStringByBytesBuffer-8 14265033 82.6 ns/op 112 B/op 2 allocs/op
BenchmarkConcatStringByBytesBufferWithInitSize-8 24777525 48.1 ns/op 48 B/op 1 allocs/op
PASS
ok command-line-arguments 8.816s
從基準(zhǔn)測試的輸出結(jié)果的第三列,即每操作耗時(shí)的數(shù)值來看:做了預(yù)初始化的strings.Builder連接構(gòu)建字符串效率最高;帶有預(yù)初始化的bytes.Buffer和strings.Join這兩種方法效率十分接近,分列二三位;未做預(yù)初始化的strings.Builder、bytes.Buffer和操作符連接在第三檔次;fmt.Sprintf性能最差,排在末尾。由此可以得出一些結(jié)論:在能預(yù)估出最終字符串長度的情況下,使用預(yù)初始化的strings.Builder連接構(gòu)建字符串效率最高;strings.Join連接構(gòu)建字符串的平均性能最穩(wěn)定,如果輸入的多個(gè)字符串是以[]string承載的,那么strings.Join也是不錯(cuò)的選擇;使用操作符連接的方式最直觀、最自然,在編譯器知曉欲連接的字符串個(gè)數(shù)的情況下,使用此種方式可以得到編譯器的優(yōu)化處理;fmt.Sprintf雖然效率不高,但也不是一無是處,如果是由多種不同類型變量來構(gòu)建特定格式的字符串,那么這種方式還是最適合的。
高效轉(zhuǎn)換
在前面的例子中,我們看到了string到[]rune以及string到[]byte的轉(zhuǎn)換,這兩個(gè)轉(zhuǎn)換也是可逆的,也就是說string和[]rune、[]byte可以雙向轉(zhuǎn)換。下面就是從[]rune或[]byte反向轉(zhuǎn)換為string的例子:
// 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 中國歡迎您 中國歡迎您
無論是string轉(zhuǎn)slice還是slice轉(zhuǎn)string,轉(zhuǎn)換都是要付出代價(jià)的,這些代價(jià)的根源在于string是不可變的,運(yùn)行時(shí)要為轉(zhuǎn)換后的類型分配新內(nèi)存。我們以byte slice與string相互轉(zhuǎn)換為例,看看轉(zhuǎn)換過程的內(nèi)存分配情況:
// 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)) }
運(yùn)行:
go run
1
1
我們看到,針對(duì)“中國歡迎您,北京歡迎您”這個(gè)長度的字符串,在string與byte slice互轉(zhuǎn)的過程中都要有一次內(nèi)存分配操作。
在Go運(yùn)行時(shí)層面,字符串與rune slice、byte slice相互轉(zhuǎn)換對(duì)應(yīng)的函數(shù)如下:
// $GOROOT/src/runtime/string.go slicebytetostring: []byte -> string slicerunetostring: []rune -> string stringtoslicebyte: string -> []byte stringtoslicerune: string -> []rune
以byte slice為例,看看slicebytetostring和stringtoslicebyte的實(shí)現(xiàn):
// $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, b []byte) (str string) { l := len(b) if l == 0 { return "" } // 此處省略一些代碼 if l == 1 { stringStructOf(&str).str = unsafe.Pointer(&staticbytes[b[0]]) stringStructOf(&str).len = 1 return } var p unsafe.Pointer if buf != nil && len(b) <= len(buf) { p = unsafe.Pointer(buf) } else { p = mallocgc(uintptr(len(b)), nil, false) } stringStructOf(&str).str = p stringStructOf(&str).len = len(b) memmove(p, (*(*slice)(unsafe.Pointer(&b))).array, uintptr(len(b))) return }
想要更高效地進(jìn)行轉(zhuǎn)換,唯一的方法就是減少甚至避免額外的內(nèi)存分配操作。我們看到運(yùn)行時(shí)實(shí)現(xiàn)轉(zhuǎn)換的函數(shù)中已經(jīng)加入了一些避免每種情況都要分配新內(nèi)存操作的優(yōu)化(如tmpBuf的復(fù)用)。slice類型是不可比較的,而string類型是可比較的,因此在日常Go編碼中,我們會(huì)經(jīng)常遇到將slice臨時(shí)轉(zhuǎn)換為string的情況。Go編譯器為這樣的場景提供了優(yōu)化。在運(yùn)行時(shí)中有一個(gè)名為slicebytetostringtmp的函數(shù)就是協(xié)助實(shí)現(xiàn)這一優(yōu)化的:
// $GOROOT/src/runtime/string.go func slicebytetostringtmp(b []byte) string { if raceenabled && len(b) > 0 { racereadrangepc(unsafe.Pointer(&b[0]), uintptr(len(b)), getcallerpc(), funcPC(slicebytetostringtmp)) } if msanenabled && len(b) > 0 { msanread(unsafe.Pointer(&b[0]), uintptr(len(b))) } return *(*string)(unsafe.Pointer(&b)) }
該函數(shù)的“秘訣”就在于不為string新開辟一塊內(nèi)存,而是直接使用slice的底層存儲(chǔ)。當(dāng)然使用這個(gè)函數(shù)的前提是:在原slice被修改后,這個(gè)string不能再被使用了。因此這樣的優(yōu)化是針對(duì)以下幾個(gè)特定場景的。
(1)string(b)用在map類型的key中
(2)string(b)用在字符串連接語句中
(3)string(b)用在字符串比較中
Go編譯器對(duì)用在for-range循環(huán)中的string到[]byte的轉(zhuǎn)換也有優(yōu)化處理,它不會(huì)為[]byte進(jìn)行額外的內(nèi)存分配,而是直接使用string的底層數(shù)據(jù)??聪旅娴睦?/p>
func convert() { s := "中國歡迎您,北京歡迎您" sl := []byte(s) for _, v := range sl { _ = v } } func convertWithOptimize() { s := "中國歡迎您,北京歡迎您" for _, v := range []byte(s) { _ = v } } func main() { fmt.Println(testing.AllocsPerRun(1, convert)) fmt.Println(testing.AllocsPerRun(1, convertWithOptimize)) }
運(yùn)行;
$go run
1
0
從結(jié)果我們看到,convertWithOptimize函數(shù)將string到[]byte的轉(zhuǎn)換放在for-range循環(huán)中,Go編譯器對(duì)其進(jìn)行了優(yōu)化,節(jié)省了一次內(nèi)存分配操作。
以上就是詳解Golang中string的實(shí)現(xiàn)原理與高效使用的詳細(xì)內(nèi)容,更多關(guān)于Go string的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Go語言常見數(shù)據(jù)結(jié)構(gòu)的實(shí)現(xiàn)詳解
這篇文章主要為大家學(xué)習(xí)介紹了Go語言中的常見數(shù)據(jù)結(jié)構(gòu)(channal、slice和map)的實(shí)現(xiàn),文中的示例代碼簡潔易懂,需要的可以參考一下2023-07-07Golang cron 定時(shí)器和定時(shí)任務(wù)的使用場景
Ticker是一個(gè)周期觸發(fā)定時(shí)的計(jì)時(shí)器,它會(huì)按照一個(gè)時(shí)間間隔往channel發(fā)送系統(tǒng)當(dāng)前時(shí)間,而channel的接收者可以以固定的時(shí)間間隔從channel中讀取事件,這篇文章主要介紹了Golang cron 定時(shí)器和定時(shí)任務(wù),需要的朋友可以參考下2022-09-09go開源Hugo站點(diǎn)構(gòu)建三步曲之集結(jié)渲染
這篇文章主要為大家介紹了go開源Hugo站點(diǎn)構(gòu)建三步曲之集結(jié)渲染詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-02-02Go?實(shí)現(xiàn)?Nginx?加權(quán)輪詢算法的方法步驟
本文主要介紹了Go?實(shí)現(xiàn)?Nginx?加權(quán)輪詢算法的方法步驟,文中通過示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-12-12