和很多其它編程語言一樣,字符串類型是Go中的一種重要類型。本文將列舉出關(guān)于字符串的各種事實(shí)。
對(duì)于標(biāo)準(zhǔn)編譯器,字符串類型的內(nèi)部結(jié)構(gòu)聲明如下:
type _string struct {
elements *byte // 引用著底層的字節(jié)
len int // 字符串中的字節(jié)數(shù)
}
從這個(gè)聲明來看,我們可以將一個(gè)字符串的內(nèi)部定義看作為一個(gè)字節(jié)序列。 事實(shí)上,我們確實(shí)可以把一個(gè)字符串看作是一個(gè)元素類型為byte
的(且元素不可修改的)切片。
注意,前面的文章已經(jīng)提到過多次,byte
是內(nèi)置類型uint8
的一個(gè)別名。
從前面的若干文章,我們已經(jīng)了解到下列關(guān)于字符串的一些事實(shí):
""
或者``
來表示。+
和+=
來銜接字符串。==
和!=
比較運(yùn)算符來比較。 并且和整數(shù)/浮點(diǎn)數(shù)一樣,同一個(gè)字符串類型的值也可以用>
、<
、>=
和<=
比較運(yùn)算符來比較。 當(dāng)比較兩個(gè)字符串值的時(shí)候,它們的底層字節(jié)將逐一進(jìn)行比較。如果一個(gè)字符串是另一個(gè)字符串的前綴,并且另一個(gè)字符串較長(zhǎng),則另一個(gè)字符串為兩者中的較大者。一個(gè)例子:
package main
import "fmt"
func main() {
const World = "world"
var hello = "hello"
// 銜接字符串。
var helloWorld = hello + " " + World
helloWorld += "!"
fmt.Println(helloWorld) // hello world!
// 比較字符串。
fmt.Println(hello == "hello") // true
fmt.Println(hello > helloWorld) // false
}
更多關(guān)于字符串類型和值的事實(shí):
strings
標(biāo)準(zhǔn)庫提供的函數(shù)來進(jìn)行各種字符串操作。len
來獲取一個(gè)字符串值的長(zhǎng)度(此字符串中存儲(chǔ)的字節(jié)數(shù))。aString[i]
來獲取aString
中的第i
個(gè)字節(jié)。 表達(dá)式aString[i]
是不可尋址的。換句話說,aString[i]
不可被修改。aString[start:end]
來獲取aString
的一個(gè)子字符串。 這里,start
和end
均為aString
中存儲(chǔ)的字節(jié)的下標(biāo)。aString[start:end]
的估值結(jié)果也將和基礎(chǔ)字符串aString
共享一部分底層字節(jié)。一個(gè)例子:
package main
import (
"fmt"
"strings"
)
func main() {
var helloWorld = "hello world!"
var hello = helloWorld[:5] // 取子字符串
// 104是英文字符h的ASCII(和Unicode)碼。
fmt.Println(hello[0]) // 104
fmt.Printf("%T \n", hello[0]) // uint8
// hello[0]是不可尋址和不可修改的,所以下面
// 兩行編譯不通過。
/*
hello[0] = 'H' // error
fmt.Println(&hello[0]) // error
*/
// 下一條語句將打印出:5 12 true
fmt.Println(len(hello), len(helloWorld),
strings.HasPrefix(helloWorld, hello))
}
注意:如果在aString[i]
和aString[start:end]
中,aString
和各個(gè)下標(biāo)均為常量,則編譯器將在編譯時(shí)刻驗(yàn)證這些下標(biāo)的合法性,但是這樣的元素訪問和子切片表達(dá)式的估值結(jié)果總是非常量(這是Go語言設(shè)計(jì)之初的一個(gè)失誤,但因?yàn)榧嫒菪缘脑驅(qū)е码y以彌補(bǔ))。比如下面這個(gè)程序?qū)⒋蛞?code>4 0。
package main
import "fmt"
const s = "Go101.org" // len(s) == 9
// len(s)是一個(gè)常量表達(dá)式,但len(s[:])卻不是。
var a byte = 1 << len(s) / 128
var b byte = 1 << len(s[:]) / 128
func main() {
fmt.Println(a, b) // 4 0
}
a
和b
兩個(gè)變量估值不同的具體原因請(qǐng)閱讀移位操作類型推斷規(guī)則和哪些函數(shù)調(diào)用在編譯時(shí)刻被估值。
Unicode標(biāo)準(zhǔn)為全球各種人類語言中的每個(gè)字符制定了一個(gè)獨(dú)一無二的值。 但Unicode標(biāo)準(zhǔn)中的基本單位不是字符,而是碼點(diǎn)(code point)。大多數(shù)的碼點(diǎn)實(shí)際上就對(duì)應(yīng)著一個(gè)字符。 但也有少數(shù)一些字符是由多個(gè)碼點(diǎn)組成的。
碼點(diǎn)值在Go中用rune值來表示。 內(nèi)置rune
類型為內(nèi)置int32
類型的一個(gè)別名。
在具體應(yīng)用中,碼點(diǎn)值的編碼方式有很多,比如UTF-8編碼和UTF-16編碼等。 目前最流行編碼方式為UTF-8編碼。在Go中,所有的字符串常量都被視為是UTF-8編碼的。 在編譯時(shí)刻,非法UTF-8編碼的字符串常量將導(dǎo)致編譯失敗。 在運(yùn)行時(shí)刻,Go運(yùn)行時(shí)無法阻止一個(gè)字符串是非法UTF-8編碼的。
在UTF-8編碼中,一個(gè)碼點(diǎn)值可能由1到4個(gè)字節(jié)組成。 比如,每個(gè)英語碼點(diǎn)值(均對(duì)應(yīng)一個(gè)英語字符)均由一個(gè)字節(jié)組成,而每個(gè)中文碼點(diǎn)值(均對(duì)應(yīng)一個(gè)中文字符)均由三個(gè)字節(jié)組成。
在常量和變量一文中,我們已經(jīng)了解到整數(shù)可以被顯式轉(zhuǎn)換為字符串類型(但是反之不行)。
這里介紹兩種新的字符串相關(guān)的類型轉(zhuǎn)換規(guī)則:
byte
的切片類型。rune
的切片類型。在一個(gè)從碼點(diǎn)切片到字符串的轉(zhuǎn)換中,碼點(diǎn)切片中的每個(gè)碼點(diǎn)值將被UTF-8編碼為一到四個(gè)字節(jié)至結(jié)果字符串中。 如果一個(gè)碼點(diǎn)值是一個(gè)不合法的Unicode碼點(diǎn)值,則它將被視為Unicode替換字符(碼點(diǎn))值0xFFFD
(Unicode replacement character)。 替換字符值0xFFFD
將被UTF-8編碼為三個(gè)字節(jié)0xef 0xbf 0xbd
。
當(dāng)一個(gè)字符串被轉(zhuǎn)換為一個(gè)碼點(diǎn)切片時(shí),此字符串中存儲(chǔ)的字節(jié)序列將被解讀為一個(gè)一個(gè)碼點(diǎn)的UTF-8編碼序列。 非法的UTF-8編碼字節(jié)序列將被轉(zhuǎn)化為Unicode替換字符值0xFFFD
。
當(dāng)一個(gè)字符串被轉(zhuǎn)換為一個(gè)字節(jié)切片時(shí),結(jié)果切片中的底層字節(jié)序列是此字符串中存儲(chǔ)的字節(jié)序列的一份深復(fù)制。 即Go運(yùn)行時(shí)將為結(jié)果切片開辟一塊足夠大的內(nèi)存來容納被復(fù)制過來的所有字節(jié)。當(dāng)此字符串的長(zhǎng)度較長(zhǎng)時(shí),此轉(zhuǎn)換開銷是比較大的。 同樣,當(dāng)一個(gè)字節(jié)切片被轉(zhuǎn)換為一個(gè)字符串時(shí),此字節(jié)切片中的字節(jié)序列也將被深復(fù)制到結(jié)果字符串中。 當(dāng)此字節(jié)切片的長(zhǎng)度較長(zhǎng)時(shí),此轉(zhuǎn)換開銷同樣是比較大的。 在這兩種轉(zhuǎn)換中,必須使用深復(fù)制的原因是字節(jié)切片中的字節(jié)元素是可修改的,但是字符串中的字節(jié)是不可修改的,所以一個(gè)字節(jié)切片和一個(gè)字符串是不能共享底層字節(jié)序列的。
請(qǐng)注意,在字符串和字節(jié)切片之間的轉(zhuǎn)換中,
Go并不支持字節(jié)切片和碼點(diǎn)切片之間的直接轉(zhuǎn)換。我們可以用下面列出的方法來實(shí)現(xiàn)這樣的轉(zhuǎn)換:
一個(gè)展示了上述各種轉(zhuǎn)換的例子:
package main
import (
"bytes"
"unicode/utf8"
)
func Runes2Bytes(rs []rune) []byte {
n := 0
for _, r := range rs {
n += utf8.RuneLen(r)
}
n, bs := 0, make([]byte, n)
for _, r := range rs {
n += utf8.EncodeRune(bs[n:], r)
}
return bs
}
func main() {
s := "顏色感染是一個(gè)有趣的游戲。"
bs := []byte(s) // string -> []byte
s = string(bs) // []byte -> string
rs := []rune(s) // string -> []rune
s = string(rs) // []rune -> string
rs = bytes.Runes(bs) // []byte -> []rune
bs = Runes2Bytes(rs) // []rune -> []byte
}
上面已經(jīng)提到了字符串和字節(jié)切片之間的轉(zhuǎn)換將深復(fù)制它們的底層字節(jié)序列。 標(biāo)準(zhǔn)編譯器做了一些優(yōu)化,從而在某些情形下避免了深復(fù)制。 至少這些優(yōu)化在當(dāng)前(Go官方工具鏈1.18)是存在的。 這樣的情形包括:
for-range
循環(huán)中跟隨range
關(guān)鍵字的從字符串到字節(jié)切片的轉(zhuǎn)換;一個(gè)例子:
package main
import "fmt"
func main() {
var str = "world"
// 這里,轉(zhuǎn)換[]byte(str)將不需要一個(gè)深復(fù)制。
for i, b := range []byte(str) {
fmt.Println(i, ":", b)
}
key := []byte{'k', 'e', 'y'}
m := map[string]string{}
// 這個(gè)string(key)轉(zhuǎn)換仍然需要深復(fù)制。
m[string(key)] = "value"
// 這里的轉(zhuǎn)換string(key)將不需要一個(gè)深復(fù)制。
// 即使key是一個(gè)包級(jí)變量,此優(yōu)化仍然有效。
fmt.Println(m[string(key)]) // value
}
注意:在最后一行中,如果在估值string(key)
的時(shí)候有數(shù)據(jù)競(jìng)爭(zhēng)的情況,則這行的輸出有可能并不是value
。 但是,無論如何,此行都不會(huì)造成恐慌(即使有數(shù)據(jù)競(jìng)爭(zhēng)的情況發(fā)生)。
另一個(gè)例子:
package main
import "fmt"
import "testing"
var s string
var x = []byte{1023: 'x'}
var y = []byte{1023: 'y'}
func fc() {
// 下面的四個(gè)轉(zhuǎn)換都不需要深復(fù)制。
if string(x) != string(y) {
s = (" " + string(x) + string(y))[1:]
}
}
func fd() {
// 兩個(gè)在比較表達(dá)式中的轉(zhuǎn)換不需要深復(fù)制,
// 但兩個(gè)字符串銜接中的轉(zhuǎn)換仍需要深復(fù)制。
// 請(qǐng)注意此字符串銜接和fc中的銜接的差別。
if string(x) != string(y) {
s = string(x) + string(y)
}
}
func main() {
fmt.Println(testing.AllocsPerRun(1, fc)) // 1
fmt.Println(testing.AllocsPerRun(1, fd)) // 3
}
for-range
循環(huán)控制中的range
關(guān)鍵字后可以跟隨一個(gè)字符串,用來遍歷此字符串中的碼點(diǎn)(而非字節(jié)元素)。 字符串中非法的UTF-8編碼字節(jié)序列將被解讀為Unicode替換碼點(diǎn)值0xFFFD
。
一個(gè)例子:
package main
import "fmt"
func main() {
s := "e?????aπ囧"
for i, rn := range s {
fmt.Printf("%2v: 0x%x %v \n", i, rn, string(rn))
}
fmt.Println(len(s))
}
此程序的輸出如下:
0: 0x65 e
1: 0x301 ?
3: 0x915 ?
6: 0x94d ?
9: 0x937 ?
12: 0x93f ?
15: 0x61 a
16: 0x3c0 π
18: 0x56e7 囧
21
從此輸出結(jié)果可以看出:
e?
由兩個(gè)碼點(diǎn)(共三字節(jié))組成,其中一個(gè)碼點(diǎn)需要兩個(gè)字節(jié)進(jìn)行UTF-8編碼。????
由四個(gè)碼點(diǎn)(共12字節(jié))組成,每個(gè)碼點(diǎn)需要三個(gè)字節(jié)進(jìn)行UTF-8編碼。a
由一個(gè)碼點(diǎn)組成,此碼點(diǎn)只需一個(gè)字節(jié)進(jìn)行UTF-8編碼。π
由一個(gè)碼點(diǎn)組成,此碼點(diǎn)只需兩個(gè)字節(jié)進(jìn)行UTF-8編碼。囧
由一個(gè)碼點(diǎn)組成,此碼點(diǎn)只需三個(gè)字節(jié)進(jìn)行UTF-8編碼。那么如何遍歷一個(gè)字符串中的字節(jié)呢?使用傳統(tǒng)for
循環(huán):
package main
import "fmt"
func main() {
s := "e?????aπ囧"
for i := 0; i < len(s); i++ {
fmt.Printf("第%v個(gè)字節(jié)為0x%x\n", i, s[i])
}
}
當(dāng)然,我們也可以利用前面介紹的編譯器優(yōu)化來使用for-range
循環(huán)遍歷一個(gè)字符串中的字節(jié)元素。 對(duì)于官方標(biāo)準(zhǔn)編譯器來說,此方法比剛展示的方法效率更高。
package main
import "fmt"
func main() {
s := "e?????aπ囧"
// 這里,[]byte(s)不需要深復(fù)制底層字節(jié)。
for i, b := range []byte(s) {
fmt.Printf("The byte at index %v: 0x%x \n", i, b)
}
}
從上面幾個(gè)例子可以看出,len(s)
將返回字符串s
中的字節(jié)數(shù)。 len(s)
的時(shí)間復(fù)雜度為O(1)
。 如何得到一個(gè)字符串中的碼點(diǎn)數(shù)呢?使用剛介紹的for-range
循環(huán)來統(tǒng)計(jì)一個(gè)字符串中的碼點(diǎn)數(shù)是一種方法,使用unicode/utf8
標(biāo)準(zhǔn)庫包中的RuneCountInString是另一種方法。 這兩種方法的效率基本一致。第三種方法為使用len([]rune(s))
來獲取字符串s
中碼點(diǎn)數(shù)。標(biāo)準(zhǔn)編譯器從1.11版本開始,對(duì)此表達(dá)式做了優(yōu)化以避免一個(gè)不必要的深復(fù)制,從而使得它的效率和前兩種方法一致。 注意,這三種方法的時(shí)間復(fù)雜度均為O(n)
。
除了使用+
運(yùn)算符來銜接字符串,我們也可以用下面的方法來銜接字符串:
fmt
標(biāo)準(zhǔn)庫包中的Sprintf
/Sprint
/Sprintln
函數(shù)可以用來銜接各種類型的值的字符串表示,當(dāng)然也包括字符串類型的值。strings
標(biāo)準(zhǔn)庫包中的Join
函數(shù)。bytes
標(biāo)準(zhǔn)庫包提供的Buffer
類型可以用來構(gòu)建一個(gè)字節(jié)切片,然后我們可以將此字節(jié)切片轉(zhuǎn)換為一個(gè)字符串。strings
標(biāo)準(zhǔn)庫包中的Builder
類型可以用來拼接字符串。 和bytes.Buffer
類型類似,此類型內(nèi)部也維護(hù)著一個(gè)字節(jié)切片,但是它在將此字節(jié)切片轉(zhuǎn)換為字符串時(shí)避免了底層字節(jié)的深復(fù)制。標(biāo)準(zhǔn)編譯器對(duì)使用+
運(yùn)算符的字符串銜接做了特別的優(yōu)化。 所以,一般說來,在被銜接的字符串的數(shù)量是已知的情況下,使用+
運(yùn)算符進(jìn)行字符串銜接是比較高效的。
在上一篇文章中,我們了解到內(nèi)置函數(shù)copy
和append
可以用來復(fù)制和添加切片元素。 事實(shí)上,做為一個(gè)特例,如果這兩個(gè)函數(shù)的調(diào)用中的第一個(gè)實(shí)參為一個(gè)字節(jié)切片的話,那么第二個(gè)實(shí)參可以是一個(gè)字符串。 (對(duì)于append
函數(shù)調(diào)用,字符串實(shí)參后必須跟隨三個(gè)點(diǎn)...
。)
換句話說,在此特例中,字符串可以當(dāng)作字節(jié)切片來使用。
一個(gè)例子:
package main
import "fmt"
func main() {
hello := []byte("Hello ")
world := "world!"
// helloWorld := append(hello, []byte(world)...) // 正常的語法
helloWorld := append(hello, world...) // 語法糖
fmt.Println(string(helloWorld))
helloWorld2 := make([]byte, len(hello) + len(world))
copy(helloWorld2, hello)
// copy(helloWorld2[len(hello):], []byte(world)) // 正常的語法
copy(helloWorld2[len(hello):], world) // 語法糖
fmt.Println(string(helloWorld2))
}
上面已經(jīng)提到了比較兩個(gè)字符串事實(shí)上逐個(gè)比較這兩個(gè)字符串中的字節(jié)。 Go編譯器一般會(huì)做出如下的優(yōu)化:
==
和!=
比較,如果這兩個(gè)字符串的長(zhǎng)度不相等,則這兩個(gè)字符串肯定不相等(無需進(jìn)行字節(jié)比較)。
所以兩個(gè)相等的字符串的比較的時(shí)間復(fù)雜度取決于它們底層引用著字符串切片的指針是否相等。 如果相等,則對(duì)它們的比較的時(shí)間復(fù)雜度為O(1)
,否則時(shí)間復(fù)雜度為O(n)
。
上面已經(jīng)提到了,對(duì)于標(biāo)準(zhǔn)編譯器,一個(gè)字符串賦值完成之后,目標(biāo)字符串和源字符串將共享同一個(gè)底層字節(jié)序列。 所以比較這兩個(gè)字符串的代價(jià)很小。
一個(gè)例子:
package main
import (
"fmt"
"time"
)
func main() {
bs := make([]byte, 1<<26)
s0 := string(bs)
s1 := string(bs)
s2 := s1
// s0、s1和s2是三個(gè)相等的字符串。
// s0的底層字節(jié)序列是bs的一個(gè)深復(fù)制。
// s1的底層字節(jié)序列也是bs的一個(gè)深復(fù)制。
// s0和s1底層字節(jié)序列為兩個(gè)不同的字節(jié)序列。
// s2和s1共享同一個(gè)底層字節(jié)序列。
startTime := time.Now()
_ = s0 == s1
duration := time.Now().Sub(startTime)
fmt.Println("duration for (s0 == s1):", duration)
startTime = time.Now()
_ = s1 == s2
duration = time.Now().Sub(startTime)
fmt.Println("duration for (s1 == s2):", duration)
}
輸出如下:
duration for (s0 == s1): 10.462075ms
duration for (s1 == s2): 136ns
1ms等于1000000ns!所以請(qǐng)盡量避免比較兩個(gè)很長(zhǎng)的不共享底層字節(jié)序列的相等的(或者幾乎相等的)字符串。
更多建議: