詳解Golang中string的實現(xiàn)原理與高效使用
字符串類型是現(xiàn)代編程語言中最常使用的數(shù)據(jù)類型之一。在Go語言的先祖之一C語言當中,字符串類型并沒有被顯式定義,而是以字符串字面值常量或以'\0'結(jié)尾的字符類型(char)數(shù)組來呈現(xiàn)的。
const char * s = "hello world" char s[] = "hello gopher"
這給C程序員在使用字符串時帶來一些問題,諸如:
- 類型安全性差;
- 字符串操作要時時刻刻考慮結(jié)尾的'\0';
- 字符串數(shù)據(jù)可變(主要指以字符數(shù)組形式定義的字符串類型);
- 獲取字符串長度代價大(O(n)的時間復雜度);
- 未內(nèi)置對非ASCII字符(如中文字符)的處理。
Go語言修復了C語言的這一“缺陷”,內(nèi)置了string類型,統(tǒng)一了對字符串的抽象。
在Go語言中,無論是字符串常量、字符串變量還是代碼中出現(xiàn)的字符串字面量,它們的類型都被統(tǒng)一設(shè)置為string。
Go的string類型設(shè)計充分吸取了C語言字符串設(shè)計的經(jīng)驗教訓,并結(jié)合了其他主流語言在字符串類型設(shè)計上的最佳實踐,最終呈現(xiàn)的string類型具有如下功能特點。
(1)string類型的數(shù)據(jù)是不可變的,一旦聲明了一個string類型的標識符,無論是常量還是變量,該標識符所指代的數(shù)據(jù)在整個程序的生命周期內(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)) }
該程序的運行結(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)獲取長度的時間復雜度是O(1)級別
Go string類型數(shù)據(jù)是不可變的,因此一旦有了初值,那塊數(shù)據(jù)就不會改變,其長度也不會改變。Go將這個長度作為一個字段存儲在運行時的string類型的內(nèi)部表示結(jié)構(gòu)中(后文有說明)。這樣獲取string長度的操作,即len(s)實際上就是讀取存儲在運行時中的那個長度值,這是一個代價極低的O(1)操作。
(4)支持通過+/+=操作符進行字符串連接
對開發(fā)者而言,通過+/+=操作符進行的字符串連接是體驗最好的字符串連接操作,Go語言支持這種操作:
s := "Rob Pike, " s = s + "Robert Griesemer, " s += " Ken Thompson"
(5)支持各種比較關(guān)系操作符:==、!= 、>=、<=、>和<
由于Go string是不可變的,因此如果兩個字符串的長度不相同,那么無須比較具體字符串數(shù)據(jù)即可斷定兩個字符串是不同的。如果長度相同,則要進一步判斷數(shù)據(jù)指針是否指向同一塊底層存儲數(shù)據(jù)。如果相同,則兩個字符串是等價的;如果不同,則還需進一步比對實際的數(shù)據(jù)內(nèi)容。
(6)對非ASCII字符提供原生支持
Go語言源文件默認采用的Unicode字符集。Unicode字符集是目前市面上最流行的字符集,幾乎囊括了所有主流非ASCII字符(包括中文字符)。Go字符串的每個字符都是一個Unicode字符,并且這些Unicode字符是以UTF-8編碼格式存儲在內(nèi)存當中的。我們來看一個例子:
// func main() { // 中文字符 Unicode碼點 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中存儲的文本是“中國歡迎您”五個漢字字符(非ASCII字符范疇),這里輸出了每個中文字符對應(yīng)的Unicode碼點(Code Point,見輸出結(jié)果的第二列),一個rune對應(yīng)一個碼點。UTF-8編碼是Unicode碼點的一種字符編碼形式,是最常用的一種編碼格式,也是Go默認的字符編碼格式。我們還可以使用其他字符編碼格式來映射Unicode碼點,比如UTF-16等。
在UTF-8中,大多數(shù)中文字符都使用三字節(jié)表示。[]byte(s)的轉(zhuǎn)型讓我們獲得了s底層存儲的“復制品”,從而得到每個漢字字符對應(yīng)的UTF-8編碼字節(jié)(見輸出結(jié)果的第三列)。
(7)原生支持多行字符串
C語言中要構(gòu)造多行字符串,要么使用多個字符串的自然拼接,要么結(jié)合續(xù)行符“\”,很難控制好格式:
#include <stdio.h> char *s = "古藤老樹昏鴉\n" "小橋流水人家\n" "古道西風瘦馬\n" "斷腸人在天涯"; int main() { printf("%s\n", s); }
go語言方式:
const s = `古藤老樹昏鴉 小橋流水人家 古道西風瘦馬 斷腸人在天涯`; func main () { fmt.Println(s) }
字符串內(nèi)部結(jié)構(gòu)
Go string類型上述特性的實現(xiàn)與Go運行時對string類型的內(nèi)部表示是分不開的。Go string在運行時表示為下面的結(jié)構(gòu):
// $GOROOT/src/runtime/string.go type stringStruct struct { str unsafe.Pointer len int }
我們看到string類型也是一個描述符,它本身并不真正存儲數(shù)據(jù),而僅是由一個指向底層存儲的指針和字符串的長度字段組成。我們結(jié)合一個string的實例化過程來看。下面是runtime包中實例化一個字符串對應(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 }
我們看到每個字符串類型變量/常量對應(yīng)一個stringStruct實例,經(jīng)過rawstring實例化后,stringStruct中的str指針指向真正存儲字符串數(shù)據(jù)的底層內(nèi)存區(qū)域,len字段存儲的是字符串的長度(這里是5);rawstring同時還創(chuàng)建了一個臨時slice,該slice的array指針也指向存儲字符串數(shù)據(jù)的底層內(nèi)存區(qū)域。注意,rawstring調(diào)用后,新申請的內(nèi)存區(qū)域還未被寫入數(shù)據(jù),該slice就是供后續(xù)運行時層向其中寫入數(shù)據(jù)("hello")用的。寫完數(shù)據(jù)后,該slice就可以被回收掉了
根據(jù)string在運行時的表示可以得到這樣一個結(jié)論:直接將string類型通過函數(shù)/方法參數(shù)傳入也不會有太多的損耗,因為傳入的僅僅是一個“描述符”,而不是真正的字符串數(shù)據(jù)。我們通過一個簡單的基準測試來驗證一下:
// 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指針兩者的基準測試結(jié)果幾乎一模一樣,因此Gopher大可放心地直接使用string作為函數(shù)/方法參數(shù)類型。
高效構(gòu)造
前面提到過,Go原生支持通過+/+=操作符來連接多個字符串以構(gòu)造一個更長的字符串,并且通過+/+=操作符的字符串連接構(gòu)造是最自然、開發(fā)體驗最好的一種。但Go還提供了其他一些構(gòu)造字符串的方法,比如:
使用fmt.Sprintf;使用strings.Join;使用strings.Builder;使用bytes.Buffer。
在這些方法中哪種方法最為高效呢?我們使用基準測試的數(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) } }
運行:
$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
從基準測試的輸出結(jié)果的第三列,即每操作耗時的數(shù)值來看:做了預初始化的strings.Builder連接構(gòu)建字符串效率最高;帶有預初始化的bytes.Buffer和strings.Join這兩種方法效率十分接近,分列二三位;未做預初始化的strings.Builder、bytes.Buffer和操作符連接在第三檔次;fmt.Sprintf性能最差,排在末尾。由此可以得出一些結(jié)論:在能預估出最終字符串長度的情況下,使用預初始化的strings.Builder連接構(gòu)建字符串效率最高;strings.Join連接構(gòu)建字符串的平均性能最穩(wěn)定,如果輸入的多個字符串是以[]string承載的,那么strings.Join也是不錯的選擇;使用操作符連接的方式最直觀、最自然,在編譯器知曉欲連接的字符串個數(shù)的情況下,使用此種方式可以得到編譯器的優(yōu)化處理;fmt.Sprintf雖然效率不高,但也不是一無是處,如果是由多種不同類型變量來構(gòu)建特定格式的字符串,那么這種方式還是最適合的。
高效轉(zhuǎn)換
在前面的例子中,我們看到了string到[]rune以及string到[]byte的轉(zhuǎn)換,這兩個轉(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)換都是要付出代價的,這些代價的根源在于string是不可變的,運行時要為轉(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)) }
運行:
go run
1
1
我們看到,針對“中國歡迎您,北京歡迎您”這個長度的字符串,在string與byte slice互轉(zhuǎn)的過程中都要有一次內(nèi)存分配操作。
在Go運行時層面,字符串與rune slice、byte slice相互轉(zhuǎn)換對應(yīng)的函數(shù)如下:
// $GOROOT/src/runtime/string.go slicebytetostring: []byte -> string slicerunetostring: []rune -> string stringtoslicebyte: string -> []byte stringtoslicerune: string -> []rune
以byte slice為例,看看slicebytetostring和stringtoslicebyte的實現(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 }
想要更高效地進行轉(zhuǎn)換,唯一的方法就是減少甚至避免額外的內(nèi)存分配操作。我們看到運行時實現(xiàn)轉(zhuǎn)換的函數(shù)中已經(jīng)加入了一些避免每種情況都要分配新內(nèi)存操作的優(yōu)化(如tmpBuf的復用)。slice類型是不可比較的,而string類型是可比較的,因此在日常Go編碼中,我們會經(jīng)常遇到將slice臨時轉(zhuǎn)換為string的情況。Go編譯器為這樣的場景提供了優(yōu)化。在運行時中有一個名為slicebytetostringtmp的函數(shù)就是協(xié)助實現(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的底層存儲。當然使用這個函數(shù)的前提是:在原slice被修改后,這個string不能再被使用了。因此這樣的優(yōu)化是針對以下幾個特定場景的。
(1)string(b)用在map類型的key中
(2)string(b)用在字符串連接語句中
(3)string(b)用在字符串比較中
Go編譯器對用在for-range循環(huán)中的string到[]byte的轉(zhuǎn)換也有優(yōu)化處理,它不會為[]byte進行額外的內(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)) }
運行;
$go run
1
0
從結(jié)果我們看到,convertWithOptimize函數(shù)將string到[]byte的轉(zhuǎn)換放在for-range循環(huán)中,Go編譯器對其進行了優(yōu)化,節(jié)省了一次內(nèi)存分配操作。
以上就是詳解Golang中string的實現(xiàn)原理與高效使用的詳細內(nèi)容,更多關(guān)于Go string的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Go語言常見數(shù)據(jù)結(jié)構(gòu)的實現(xiàn)詳解
這篇文章主要為大家學習介紹了Go語言中的常見數(shù)據(jù)結(jié)構(gòu)(channal、slice和map)的實現(xiàn),文中的示例代碼簡潔易懂,需要的可以參考一下2023-07-07go開源Hugo站點構(gòu)建三步曲之集結(jié)渲染
這篇文章主要為大家介紹了go開源Hugo站點構(gòu)建三步曲之集結(jié)渲染詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-02-02Go?實現(xiàn)?Nginx?加權(quán)輪詢算法的方法步驟
本文主要介紹了Go?實現(xiàn)?Nginx?加權(quán)輪詢算法的方法步驟,文中通過示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2021-12-12