你不知道的 Go 之 string

簡介

字符串(string)是 Go 語言提供的一種基礎(chǔ)數(shù)據(jù)類型。在編程開發(fā)中幾乎隨時都會使用。本文介紹字符串相關(guān)的知識,幫助你更好地理解和使用它。

底層結(jié)構(gòu)

字符串底層結(jié)構(gòu)定義在源碼runtime包下的 string.go 文件中:

// src/runtime/string.go
type stringStruct struct {
  str unsafe.Pointer
  len int
}
  • str:一個指針,指向存儲實(shí)際字符串的內(nèi)存地址。
  • len:字符串的長度。與切片類似,在代碼中我們可以使用len()函數(shù)獲取這個值。注意,len存儲實(shí)際的字節(jié)數(shù),而非字符數(shù)。所以對于非單字節(jié)編碼的字符,結(jié)果可能讓人疑惑。后面會詳細(xì)介紹多字節(jié)字符。

對于字符串Hello,實(shí)際底層結(jié)構(gòu)如下:

[圖片上傳失敗...(image-4adbce-1622475656721)]

str中存儲的是字符對應(yīng)的編碼,H對應(yīng)編碼72,e對應(yīng)101等等。

我們可以使用下面的代碼輸出字符串的底層結(jié)構(gòu)和存儲的每個字節(jié):

package main

import (
  "fmt"
  "unsafe"
)

type stringStruct struct {
  str unsafe.Pointer
  len int
}

func main() {
  s := "Hello World!"
  fmt.Println(*(*stringStruct)(unsafe.Pointer(&s)))

  for _, b := range s {
    fmt.Println(b)
  }
}

運(yùn)行輸出:

{0x8edaff 5}

由于runtime.stringStruct結(jié)構(gòu)是非導(dǎo)出的,我們不能直接使用。所以我在代碼中手動定義了一個stringStruct結(jié)構(gòu)體,字段與runtime.stringStruct完全相同。

基本操作

創(chuàng)建

創(chuàng)建字符串有兩種基本方式,使用var定義和字符串字面量:

var s1 string
s2 := "Hello World!"

注意var s string定義了一個字符串的空值,字符串的空值是空字符串,即""。字符串不可能為nil

字符串字面量可以使用雙引號反引號定義。在雙引號中出現(xiàn)的特殊字符需要進(jìn)行轉(zhuǎn)義,而在單引號中不需要:

s1 := "Hello \nWorld"
s2 := `Hello
World`

上面代碼中,s1中出現(xiàn)的換行符需要使用轉(zhuǎn)義字符\ns2中直接鍵入換行。由于單引號定義的字面量與我們在代碼中看到的完全相同,在包含大段文本(通常有換行)或比較多的特殊字符時經(jīng)常使用。另外使用單引號時,注意首行后面其他行的空格問題:

package main

import "fmt"

func main() {
  s := `hello
  world`

  fmt.Println(s)
}

可能只是為了縮進(jìn)和美觀,在第二行的 "world" 前面加上了兩個空格。實(shí)際上這些空格也是字符串的一部分。如果這不是有意為之,可能會造成一些困惑。上面代碼輸出:

hello
  world

索引和切片

可以使用索引獲取字符串對應(yīng)位置上存儲的字節(jié)值,使用切片操作符獲取字符串的一個子串:

package main

import "fmt"

func main() {
  s := "Hello World!"
  fmt.Println(s[0])

  fmt.Println(s[:5])
}

輸出:

72
Hello

上篇文章你不知道的 Go 之 slice中也介紹過了,字符串的切片操作返回的不是切片,而是字符串。

字符串拼接

字符串拼接最簡單直白的方式就是使用+符號,+可以拼接任意多個字符串。但是+的缺點(diǎn)是待拼接的字符串必須是已知的。另一種方式就是使用標(biāo)準(zhǔn)庫strings包中的Join()函數(shù),這個函數(shù)接受一個字符串切片和一個分隔符,將切片中的元素拼接成以分隔符分隔的單個字符串:

func main() {
  s1 := "Hello" + " " + "World"
  fmt.Println(s1)

  ss := []string{"Hello", "World"}
  fmt.Println(strings.Join(ss, " "))
}

上面代碼首先使用+拼接字符串,然后將各個字符串存放在一個切片中,使用strings.Join()函數(shù)拼接。結(jié)果是一樣的。需要注意的是,將待拼接的字符串放在一行中,使用+拼接,在 Go 語言內(nèi)部會先計(jì)算需要的空間,預(yù)先分配這個空間,最后將各個字符串拷貝過去。這個行為與其他很多語言是不同的,所以在 Go 語言中使用+拼接字符串不會有性能損失,甚至由于內(nèi)部優(yōu)化比其他方式性能還要更好一些。當(dāng)然前提拼接是一次完成的。下面代碼多次使用+拼接,會產(chǎn)生大量臨時字符串對象,影響性能:

s := "hello"
var result string
for i := 1; i < 100; i++ {
  result += s
}

我們來測試一下各種方式的性能差異。首先定義 3 個函數(shù),分別用 1 次+拼接,多次+拼接和Join()拼接:

func ConcatWithMultiPlus() {
  var s string
  for i := 0; i < 10; i++ {
    s += "hello"
  }
}

func ConcatWithOnePlus() {
  s1 := "hello"
  s2 := "hello"
  s3 := "hello"
  s4 := "hello"
  s5 := "hello"
  s6 := "hello"
  s7 := "hello"
  s8 := "hello"
  s9 := "hello"
  s10 := "hello"
  s := s1 + s2 + s3 + s4 + s5 + s6 + s7 + s8 + s9 + s10
  _ = s
}

func ConcatWithJoin() {
  s := []string{"hello", "hello", "hello", "hello", "hello", "hello", "hello", "hello", "hello", "hello"}
  _ = strings.Join(s, "")
}

然后在文件benchmark_test.go中定義基準(zhǔn)測試:

func BenchmarkConcatWithOnePlus(b *testing.B) {
  for i := 0; i < b.N; i++ {
    ConcatWithOnePlus()
  }
}

func BenchmarkConcatWithMultiPlus(b *testing.B) {
  for i := 0; i < b.N; i++ {
    ConcatWithMultiPlus()
  }
}

func BenchmarkConcatWithJoin(b *testing.B) {
  for i := 0; i < b.N; i++ {
    ConcatWithJoin()
  }
}

運(yùn)行測試:

$ go test -bench .
BenchmarkConcatWithOnePlus-8            11884388               170.5 ns/op
BenchmarkConcatWithMultiPlus-8           1227411              1006 ns/op
BenchmarkConcatWithJoin-8                6718507               157.5 ns/op

可以看到,使用+一次拼接和Join()函數(shù)性能差不多,而多次+拼接性能是其他兩種方式的近 1/9。另外需要注意我在ConcatWithOnePlus()函數(shù)中先定義 10 個字符串變量,然后再使用+拼接。如果直接使用+拼接字符串字面量,編譯器會直接優(yōu)化為一個字符串字面量,結(jié)果就沒有可比較性了。

runtime包中,使用concatstrings()函數(shù)來處理使用+拼接字符串的操作:

// src/runtime/string.go
func concatstrings(buf *tmpBuf, a []string) string {
  idx := 0
  l := 0
  count := 0
  for i, x := range a {
    n := len(x)
    if n == 0 {
      continue
    }
    if l+n < l {
      throw("string concatenation too long")
    }
    l += n
    count++
    idx = i
  }
  if count == 0 {
    return ""
  }

  // If there is just one string and either it is not on the stack
  // or our result does not escape the calling frame (buf != nil),
  // then we can return that string directly.
  if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
    return a[idx]
  }
  s, b := rawstringtmp(buf, l)
  for _, x := range a {
    copy(b, x)
    b = b[len(x):]
  }
  return s
}

類型轉(zhuǎn)換

我們經(jīng)常需要將 string 轉(zhuǎn)為 []byte,或者從 []byte 轉(zhuǎn)換回 string。這中間都會涉及一次內(nèi)存拷貝,所以要注意轉(zhuǎn)換頻次不宜過高。string 轉(zhuǎn)換為 []byte,轉(zhuǎn)換語法為[]byte(str)。首先創(chuàng)建一個[]byte并分配足夠的空間,然后將 string 內(nèi)容拷貝過去。

[圖片上傳失敗...(image-cda838-1622475656721)]

func main() {
  s := "Hello"

  b := []byte(s)
  fmt.Println(len(b), cap(b))
}

注意,輸出的cap可能與len不同,多出的容量處于對后續(xù)追加的性能考慮。

[]byte轉(zhuǎn)換為 string 轉(zhuǎn)換語法為string(bs),過程也是類似。

你不知道的 string

1 編碼

在計(jì)算機(jī)發(fā)展早期,只有單字節(jié)編碼,最知名的是 ASCII(American Standard Code for Information Interchange,美國信息交換標(biāo)準(zhǔn)代碼)。單字節(jié)編碼最多只能編碼 256 個字符,這對英語國家可能夠用了。但是隨著計(jì)算機(jī)在全世界的普及,要編碼其他國家的語言(典型的就是漢字),單字節(jié)顯然是不夠的。為此提出了 Unicode 編碼方案。Unicode 編碼為全世界所有國家的語言符號規(guī)定了統(tǒng)一的編碼方案。Unicode 相關(guān)的知識請查看參考鏈接每個程序員都必須知道的 Unicode 知識。

有很多人不知道 Unicode 與 UTF8、UTF16、UTF32 這些有什么關(guān)系。實(shí)際上可以理解為 Unicode 只是規(guī)定了每個字符對應(yīng)的編碼值,實(shí)際很少直接存儲和傳輸這個值。UTF8/UTF16/UTF32 則定義這些編碼值如何在內(nèi)存或文件中存儲以及在網(wǎng)絡(luò)上傳輸?shù)母袷?。例如,漢字“中”,Unicode 編碼值為00004E2D,其他編碼如下:

UTF8編碼:E4B8AD
UTF16BE編碼:FEFF4E2D
UTF16LE編碼:FFFE2D4E
UTF32BE編碼:0000FEFF00004E2D
UTF32LE編碼:FFFE00002D4E0000

Go 語言中的字符串存儲是 UTF-8 編碼。UTF8 是可變長編碼,優(yōu)點(diǎn)是兼容 ASCII。對非英語國家的字符采用多字節(jié)編碼方案,而且對使用比較頻繁的字符采用較短的編碼,提升編碼效率。缺點(diǎn)是 UTF8 的可變長編碼讓我們不能直接、直觀地確定字符串的字符長度。一般的中文字符使用 3 個字節(jié)來編碼,例如上面的“中”。對于生僻字,可能采用更多的字節(jié)來編碼,例如“魋”的 UTF-8 編碼為E9AD8B20。

我們使用len()函數(shù)獲取到的都是編碼后的字節(jié)長度,而非字符長度,這一點(diǎn)在使用非 ASCII 字符時很重要:

func main() {
  s1 := "Hello World!"
  s2 := "你好,中國"

  fmt.Println(len(s1))
  fmt.Println(len(s2))
}

輸出:

12
15

Hello World!有 12 個字符很好理解,你好,中國有 5 個中文字符,每個中文字符占 3 個字節(jié),所以輸出 15。

對于使用非 ASCII 字符的字符串,我們可以使用標(biāo)準(zhǔn)庫的 unicode/utf8 包中的RuneCountInString()方法獲取實(shí)際字符數(shù):

func main() {
  s1 := "Hello World!"
  s2 := "你好,中國"

  fmt.Println(utf8.RuneCountInString(s1)) // 12
  fmt.Println(utf8.RuneCountInString(s2)) // 5
}

為了便于理解,下面給出字符串“中國”的底層結(jié)構(gòu)圖:

[圖片上傳失敗...(image-f3fff8-1622475656721)]

2 索引和遍歷

使用索引操作字符串,獲取的是對應(yīng)位置上的字節(jié)值,如果該位置是某個多字節(jié)編碼的中間位置,可能返回的字節(jié)值不是一個合法的編碼值:

s := "中國"
fmt.Println(s[0])

前面介紹過“中”的 UTF8 編碼為E4B8AD,故s[0]取第一個字節(jié)值,結(jié)果為 228(十六進(jìn)制 E4 的值)。

為了方便地遍歷字符串,Go 語言中for-range循環(huán)對多字符編碼有特殊的支持。每次遍歷返回的索引是每個字符開始的字節(jié)位置,值為該字符的編碼值:

func main() {
  s := "Go 語言"

  for index, c := range s {
    fmt.Println(index, c)
  }
}

所以遇到多字節(jié)字符,索引就不是連續(xù)的。上面“語”占用 3 個字節(jié),所以“言”的索引就是“中”的索引 3 加上它的字節(jié)數(shù) 3,結(jié)果就是 6。上面的代碼輸出如下:

0 71
1 111
2 32
3 35821
6 35328

我們也可以以字符形式輸出:

func main() {
  s := "Go 語言"

  for index, c := range s {
    fmt.Printf("%d %c\n", index, c)
  }
}

輸出:

0 G
1 o
2 
3 語
6 言

按照這個方法,我們可以編寫一個簡單的RuneCountInString()函數(shù),就叫做Utf8Count吧:

func Utf8Count(s string) int {
  var count int
  for range s {
    count++
  }
  return count
}

fmt.Println(Utf8Count("中國")) // 2

3 亂碼和不可打印字符

如果 string 中出現(xiàn)不合法的 utf8 編碼,打印時對于每個不合法的編碼字節(jié)都會輸出一個特定的符號?

func main() {
  s := "中國"
  fmt.Println(s[:5])

  b := []byte{129, 130, 131}
  fmt.Println(string(b))
}

上面輸出:

中??
???

因?yàn)椤皣本幋a有 3 個字節(jié),s[:5]只取了前兩個,這兩個字節(jié)無法組成一個合法的 UTF8 字符,故輸出兩個?。

另外需要警惕不可打印字符,之前有個同事請教我一個問題,兩個字符串輸出的內(nèi)容相同,但是它們就是不相等:

func main() {
  b1 := []byte{0xEF, 0xBB, 0xBF, 72, 101, 108, 108, 111}
  b2 := []byte{72, 101, 108, 108, 111}

  s1 := string(b1)
  s2 := string(b2)

  fmt.Println(s1)
  fmt.Println(s2)
  fmt.Println(s1 == s2)
}

輸出:

hello
hello
false

我直接把字符串內(nèi)部字節(jié)寫出來了,可能一眼就看出來了。但是我們當(dāng)時遇到這個問題還是稍微費(fèi)了一番功夫來調(diào)試的。因?yàn)楫?dāng)時字符串是從文件中讀取的,而文件采用的是帶 BOM 的 UTF8 編碼格式。我們都知道 BOM 格式會自動在文件頭部加上 3 個字節(jié)0xEFBBBF。而字符串比較是會比較長度和每個字節(jié)的。讓問題更難調(diào)試的是,在文件中 BOM 頭也是不顯示的。

4 編譯優(yōu)化

[]byte轉(zhuǎn)換為 string 的場景很多,處于性能上的考慮。如果轉(zhuǎn)換后的 string 只是臨時使用,這時轉(zhuǎn)換并不會進(jìn)行內(nèi)存拷貝。返回的 string會指向切片的內(nèi)存。編譯器會識別如下場景:

  • map 查找:m[string(b)];
  • 字符串拼接:"<" + string(b) + ">";
  • 字符串比較:string(b) == "foo"

因?yàn)?string 只是臨時使用,期間切片不會發(fā)生變化。故這樣使用沒有問題。

總結(jié)

字符串是使用頻率最高的基本類型之一,熟悉掌握它可以幫助我們更好地編碼和解決問題。

參考

  1. 《Go 專家編程》
  2. 每個程序員都必須知道的 Unicode 知識,https://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/
  3. 你不知道的Go GitHub:https://github.com/darjun/you-dont-know-go

我的博客:https://darjun.github.io

歡迎關(guān)注我的微信公眾號【GoUpUp】,共同學(xué)習(xí),一起進(jìn)步~

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容