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

