簡(jiǎn)介
字符串(string)是 Go 語(yǔ)言提供的一種基礎(chǔ)數(shù)據(jù)類(lèi)型蜓陌。在編程開(kāi)發(fā)中幾乎隨時(shí)都會(huì)使用泰讽。本文介紹字符串相關(guān)的知識(shí)算凿,幫助你更好地理解和使用它囱井。
底層結(jié)構(gòu)
字符串底層結(jié)構(gòu)定義在源碼runtime
包下的 string.go 文件中:
// src/runtime/string.go
type stringStruct struct {
str unsafe.Pointer
len int
}
-
str
:一個(gè)指針,指向存儲(chǔ)實(shí)際字符串的內(nèi)存地址六剥。 -
len
:字符串的長(zhǎng)度晚顷。與切片類(lèi)似,在代碼中我們可以使用len()
函數(shù)獲取這個(gè)值疗疟。注意该默,len
存儲(chǔ)實(shí)際的字節(jié)數(shù),而非字符數(shù)策彤。所以對(duì)于非單字節(jié)編碼的字符栓袖,結(jié)果可能讓人疑惑。后面會(huì)詳細(xì)介紹多字節(jié)字符店诗。
對(duì)于字符串Hello
裹刮,實(shí)際底層結(jié)構(gòu)如下:
[圖片上傳失敗...(image-4adbce-1622475656721)]
str
中存儲(chǔ)的是字符對(duì)應(yīng)的編碼,H
對(duì)應(yīng)編碼72
庞瘸,e
對(duì)應(yīng)101
等等捧弃。
我們可以使用下面的代碼輸出字符串的底層結(jié)構(gòu)和存儲(chǔ)的每個(gè)字節(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)出的,我們不能直接使用恕洲。所以我在代碼中手動(dòng)定義了一個(gè)stringStruct
結(jié)構(gòu)體塔橡,字段與runtime.stringStruct
完全相同梅割。
基本操作
創(chuàng)建
創(chuàng)建字符串有兩種基本方式霜第,使用var
定義和字符串字面量:
var s1 string
s2 := "Hello World!"
注意var s string
定義了一個(gè)字符串的空值,字符串的空值是空字符串户辞,即""
泌类。字符串不可能為nil
。
字符串字面量可以使用雙引號(hào)或反引號(hào)定義。在雙引號(hào)中出現(xiàn)的特殊字符需要進(jìn)行轉(zhuǎn)義刃榨,而在單引號(hào)中不需要:
s1 := "Hello \nWorld"
s2 := `Hello
World`
上面代碼中弹砚,s1
中出現(xiàn)的換行符需要使用轉(zhuǎn)義字符\n
,s2
中直接鍵入換行枢希。由于單引號(hào)定義的字面量與我們?cè)诖a中看到的完全相同桌吃,在包含大段文本(通常有換行)或比較多的特殊字符時(shí)經(jīng)常使用。另外使用單引號(hào)時(shí)苞轿,注意首行后面其他行的空格問(wèn)題:
package main
import "fmt"
func main() {
s := `hello
world`
fmt.Println(s)
}
可能只是為了縮進(jìn)和美觀茅诱,在第二行的 "world" 前面加上了兩個(gè)空格。實(shí)際上這些空格也是字符串的一部分搬卒。如果這不是有意為之瑟俭,可能會(huì)造成一些困惑。上面代碼輸出:
hello
world
索引和切片
可以使用索引獲取字符串對(duì)應(yīng)位置上存儲(chǔ)的字節(jié)值契邀,使用切片操作符獲取字符串的一個(gè)子串:
package main
import "fmt"
func main() {
s := "Hello World!"
fmt.Println(s[0])
fmt.Println(s[:5])
}
輸出:
72
Hello
上篇文章你不知道的 Go 之 slice中也介紹過(guò)了摆寄,字符串的切片操作返回的不是切片,而是字符串坯门。
字符串拼接
字符串拼接最簡(jiǎn)單直白的方式就是使用+
符號(hào)微饥,+
可以拼接任意多個(gè)字符串。但是+
的缺點(diǎn)是待拼接的字符串必須是已知的田盈。另一種方式就是使用標(biāo)準(zhǔn)庫(kù)strings
包中的Join()
函數(shù)畜号,這個(gè)函數(shù)接受一個(gè)字符串切片和一個(gè)分隔符,將切片中的元素拼接成以分隔符分隔的單個(gè)字符串:
func main() {
s1 := "Hello" + " " + "World"
fmt.Println(s1)
ss := []string{"Hello", "World"}
fmt.Println(strings.Join(ss, " "))
}
上面代碼首先使用+
拼接字符串允瞧,然后將各個(gè)字符串存放在一個(gè)切片中简软,使用strings.Join()
函數(shù)拼接。結(jié)果是一樣的述暂。需要注意的是痹升,將待拼接的字符串放在一行中,使用+
拼接畦韭,在 Go 語(yǔ)言?xún)?nèi)部會(huì)先計(jì)算需要的空間疼蛾,預(yù)先分配這個(gè)空間,最后將各個(gè)字符串拷貝過(guò)去艺配。這個(gè)行為與其他很多語(yǔ)言是不同的察郁,所以在 Go 語(yǔ)言中使用+
拼接字符串不會(huì)有性能損失,甚至由于內(nèi)部?jī)?yōu)化比其他方式性能還要更好一些转唉。當(dāng)然前提拼接是一次完成的皮钠。下面代碼多次使用+
拼接,會(huì)產(chǎn)生大量臨時(shí)字符串對(duì)象赠法,影響性能:
s := "hello"
var result string
for i := 1; i < 100; i++ {
result += s
}
我們來(lái)測(cè)試一下各種方式的性能差異麦轰。首先定義 3 個(gè)函數(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)測(cè)試:
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)行測(cè)試:
$ 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 個(gè)字符串變量新锈,然后再使用+
拼接甲脏。如果直接使用+
拼接字符串字面量,編譯器會(huì)直接優(yōu)化為一個(gè)字符串字面量妹笆,結(jié)果就沒(méi)有可比較性了剃幌。
在runtime
包中,使用concatstrings()
函數(shù)來(lái)處理使用+
拼接字符串的操作:
// 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
}
類(lèi)型轉(zhuǎn)換
我們經(jīng)常需要將 string 轉(zhuǎn)為 []byte晾浴,或者從 []byte 轉(zhuǎn)換回 string负乡。這中間都會(huì)涉及一次內(nèi)存拷貝,所以要注意轉(zhuǎn)換頻次不宜過(guò)高脊凰。string 轉(zhuǎn)換為 []byte抖棘,轉(zhuǎn)換語(yǔ)法為[]byte(str)
。首先創(chuàng)建一個(gè)[]byte
并分配足夠的空間狸涌,然后將 string 內(nèi)容拷貝過(guò)去切省。
[圖片上傳失敗...(image-cda838-1622475656721)]
func main() {
s := "Hello"
b := []byte(s)
fmt.Println(len(b), cap(b))
}
注意,輸出的cap
可能與len
不同帕胆,多出的容量處于對(duì)后續(xù)追加的性能考慮朝捆。
[]byte
轉(zhuǎn)換為 string 轉(zhuǎn)換語(yǔ)法為string(bs)
,過(guò)程也是類(lèi)似懒豹。
你不知道的 string
1 編碼
在計(jì)算機(jī)發(fā)展早期芙盘,只有單字節(jié)編碼,最知名的是 ASCII(American Standard Code for Information Interchange脸秽,美國(guó)信息交換標(biāo)準(zhǔn)代碼)儒老。單字節(jié)編碼最多只能編碼 256 個(gè)字符,這對(duì)英語(yǔ)國(guó)家可能夠用了记餐。但是隨著計(jì)算機(jī)在全世界的普及驮樊,要編碼其他國(guó)家的語(yǔ)言(典型的就是漢字),單字節(jié)顯然是不夠的片酝。為此提出了 Unicode 編碼方案囚衔。Unicode 編碼為全世界所有國(guó)家的語(yǔ)言符號(hào)規(guī)定了統(tǒng)一的編碼方案。Unicode 相關(guān)的知識(shí)請(qǐng)查看參考鏈接每個(gè)程序員都必須知道的 Unicode 知識(shí)雕沿。
有很多人不知道 Unicode 與 UTF8练湿、UTF16、UTF32 這些有什么關(guān)系晦炊。實(shí)際上可以理解為 Unicode 只是規(guī)定了每個(gè)字符對(duì)應(yīng)的編碼值鞠鲜,實(shí)際很少直接存儲(chǔ)和傳輸這個(gè)值。UTF8/UTF16/UTF32 則定義這些編碼值如何在內(nèi)存或文件中存儲(chǔ)以及在網(wǎng)絡(luò)上傳輸?shù)母袷蕉瞎@缦湍罚瑵h字“中”,Unicode 編碼值為00004E2D
稳衬,其他編碼如下:
UTF8編碼:E4B8AD
UTF16BE編碼:FEFF4E2D
UTF16LE編碼:FFFE2D4E
UTF32BE編碼:0000FEFF00004E2D
UTF32LE編碼:FFFE00002D4E0000
Go 語(yǔ)言中的字符串存儲(chǔ)是 UTF-8 編碼霞捡。UTF8 是可變長(zhǎng)編碼,優(yōu)點(diǎn)是兼容 ASCII薄疚。對(duì)非英語(yǔ)國(guó)家的字符采用多字節(jié)編碼方案碧信,而且對(duì)使用比較頻繁的字符采用較短的編碼,提升編碼效率街夭。缺點(diǎn)是 UTF8 的可變長(zhǎng)編碼讓我們不能直接砰碴、直觀地確定字符串的字符長(zhǎng)度。一般的中文字符使用 3 個(gè)字節(jié)來(lái)編碼板丽,例如上面的“中”呈枉。對(duì)于生僻字,可能采用更多的字節(jié)來(lái)編碼埃碱,例如“魋”的 UTF-8 編碼為E9AD8B20
猖辫。
我們使用len()
函數(shù)獲取到的都是編碼后的字節(jié)長(zhǎng)度,而非字符長(zhǎng)度砚殿,這一點(diǎn)在使用非 ASCII 字符時(shí)很重要:
func main() {
s1 := "Hello World!"
s2 := "你好啃憎,中國(guó)"
fmt.Println(len(s1))
fmt.Println(len(s2))
}
輸出:
12
15
Hello World!
有 12 個(gè)字符很好理解,你好似炎,中國(guó)
有 5 個(gè)中文字符辛萍,每個(gè)中文字符占 3 個(gè)字節(jié),所以輸出 15羡藐。
對(duì)于使用非 ASCII 字符的字符串叹阔,我們可以使用標(biāo)準(zhǔn)庫(kù)的 unicode/utf8 包中的RuneCountInString()
方法獲取實(shí)際字符數(shù):
func main() {
s1 := "Hello World!"
s2 := "你好,中國(guó)"
fmt.Println(utf8.RuneCountInString(s1)) // 12
fmt.Println(utf8.RuneCountInString(s2)) // 5
}
為了便于理解传睹,下面給出字符串“中國(guó)”的底層結(jié)構(gòu)圖:
[圖片上傳失敗...(image-f3fff8-1622475656721)]
2 索引和遍歷
使用索引操作字符串耳幢,獲取的是對(duì)應(yīng)位置上的字節(jié)值,如果該位置是某個(gè)多字節(jié)編碼的中間位置欧啤,可能返回的字節(jié)值不是一個(gè)合法的編碼值:
s := "中國(guó)"
fmt.Println(s[0])
前面介紹過(guò)“中”的 UTF8 編碼為E4B8AD
睛藻,故s[0]
取第一個(gè)字節(jié)值,結(jié)果為 228(十六進(jìn)制 E4 的值)邢隧。
為了方便地遍歷字符串店印,Go 語(yǔ)言中for-range
循環(huán)對(duì)多字符編碼有特殊的支持。每次遍歷返回的索引是每個(gè)字符開(kāi)始的字節(jié)位置倒慧,值為該字符的編碼值:
func main() {
s := "Go 語(yǔ)言"
for index, c := range s {
fmt.Println(index, c)
}
}
所以遇到多字節(jié)字符按摘,索引就不是連續(xù)的包券。上面“語(yǔ)”占用 3 個(gè)字節(jié),所以“言”的索引就是“中”的索引 3 加上它的字節(jié)數(shù) 3炫贤,結(jié)果就是 6溅固。上面的代碼輸出如下:
0 71
1 111
2 32
3 35821
6 35328
我們也可以以字符形式輸出:
func main() {
s := "Go 語(yǔ)言"
for index, c := range s {
fmt.Printf("%d %c\n", index, c)
}
}
輸出:
0 G
1 o
2
3 語(yǔ)
6 言
按照這個(gè)方法,我們可以編寫(xiě)一個(gè)簡(jiǎn)單的RuneCountInString()
函數(shù)兰珍,就叫做Utf8Count
吧:
func Utf8Count(s string) int {
var count int
for range s {
count++
}
return count
}
fmt.Println(Utf8Count("中國(guó)")) // 2
3 亂碼和不可打印字符
如果 string 中出現(xiàn)不合法的 utf8 編碼侍郭,打印時(shí)對(duì)于每個(gè)不合法的編碼字節(jié)都會(huì)輸出一個(gè)特定的符號(hào)?
:
func main() {
s := "中國(guó)"
fmt.Println(s[:5])
b := []byte{129, 130, 131}
fmt.Println(string(b))
}
上面輸出:
中??
???
因?yàn)椤皣?guó)”編碼有 3 個(gè)字節(jié),s[:5]
只取了前兩個(gè)掠河,這兩個(gè)字節(jié)無(wú)法組成一個(gè)合法的 UTF8 字符亮元,故輸出兩個(gè)?
。
另外需要警惕不可打印字符唠摹,之前有個(gè)同事請(qǐng)教我一個(gè)問(wèn)題爆捞,兩個(gè)字符串輸出的內(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é)寫(xiě)出來(lái)了勾拉,可能一眼就看出來(lái)了嵌削。但是我們當(dāng)時(shí)遇到這個(gè)問(wèn)題還是稍微費(fèi)了一番功夫來(lái)調(diào)試的。因?yàn)楫?dāng)時(shí)字符串是從文件中讀取的望艺,而文件采用的是帶 BOM 的 UTF8 編碼格式苛秕。我們都知道 BOM 格式會(huì)自動(dòng)在文件頭部加上 3 個(gè)字節(jié)0xEFBBBF
。而字符串比較是會(huì)比較長(zhǎng)度和每個(gè)字節(jié)的找默。讓問(wèn)題更難調(diào)試的是艇劫,在文件中 BOM 頭也是不顯示的。
4 編譯優(yōu)化
[]byte
轉(zhuǎn)換為 string 的場(chǎng)景很多惩激,處于性能上的考慮店煞。如果轉(zhuǎn)換后的 string 只是臨時(shí)使用,這時(shí)轉(zhuǎn)換并不會(huì)進(jìn)行內(nèi)存拷貝风钻。返回的 string
會(huì)指向切片的內(nèi)存顷蟀。編譯器會(huì)識(shí)別如下場(chǎng)景:
- map 查找:
m[string(b)]
; - 字符串拼接:
"<" + string(b) + ">"
骡技; - 字符串比較:
string(b) == "foo"
鸣个。
因?yàn)?string 只是臨時(shí)使用,期間切片不會(huì)發(fā)生變化布朦。故這樣使用沒(méi)有問(wèn)題囤萤。
總結(jié)
字符串是使用頻率最高的基本類(lèi)型之一,熟悉掌握它可以幫助我們更好地編碼和解決問(wèn)題是趴。
參考
- 《Go 專(zhuān)家編程》
- 每個(gè)程序員都必須知道的 Unicode 知識(shí)涛舍,https://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/
- 你不知道的Go GitHub:https://github.com/darjun/you-dont-know-go
我
歡迎關(guān)注我的微信公眾號(hào)【GoUpUp】,共同學(xué)習(xí)唆途,一起進(jìn)步~