Go語言 字符串

2023-02-16 17:37 更新

和很多其它編程語言一樣,字符串類型是Go中的一種重要類型。本文將列舉出關(guān)于字符串的各種事實(shí)。

字符串類型的內(nèi)部結(jié)構(gòu)定義

對(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è)別名。

關(guān)于字符串的一些簡(jiǎn)單事實(shí)

從前面的若干文章,我們已經(jīng)了解到下列關(guān)于字符串的一些事實(shí):

  • 字符串值(和布爾以及各種數(shù)值類型的值)可以被用做常量。
  • Go支持兩種風(fēng)格的字符串字面量表示形式:雙引號(hào)風(fēng)格(解釋型字面表示)和反引號(hào)風(fēng)格(直白字面表示)。具體介紹請(qǐng)閱讀前文。
  • 字符串類型的零值為空字符串。一個(gè)空字符串在字面上可以用""或者``來表示。
  • 我們可以用運(yùn)算符++=來銜接字符串。
  • 字符串類型都是可比較類型。同一個(gè)字符串類型的值可以用==!=比較運(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í):

  • 和Java語言一樣,字符串值的內(nèi)容(即底層字節(jié))是不可更改的。 字符串值的長(zhǎng)度也是不可獨(dú)立被更改的。 一個(gè)可尋址的字符串只能通過將另一個(gè)字符串賦值給它來整體修改它。
  • 字符串類型沒有內(nèi)置的方法。我們可以
    • 使用strings標(biāo)準(zhǔn)庫提供的函數(shù)來進(jìn)行各種字符串操作。
    • 調(diào)用內(nèi)置函數(shù)len來獲取一個(gè)字符串值的長(zhǎng)度(此字符串中存儲(chǔ)的字節(jié)數(shù))。
    • 使用容器元素索引語法aString[i]來獲取aString中的第i個(gè)字節(jié)。 表達(dá)式aString[i]是不可尋址的。換句話說,aString[i]不可被修改。
    • 使用子切片語法aString[start:end]來獲取aString的一個(gè)子字符串。 這里,startend均為aString中存儲(chǔ)的字節(jié)的下標(biāo)。
  • 對(duì)于標(biāo)準(zhǔn)編譯器來說,一個(gè)字符串的賦值完成之后,此賦值中的目標(biāo)值和源值將共享底層字節(jié)。 一個(gè)子切片表達(dá)式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
}

ab兩個(gè)變量估值不同的具體原因請(qǐng)閱讀移位操作類型推斷規(guī)則哪些函數(shù)調(diào)用在編譯時(shí)刻被估值。

字符串編碼和Unicode碼點(diǎn)

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é)組成。

字符串相關(guān)的類型轉(zhuǎn)換

常量和變量一文中,我們已經(jīng)了解到整數(shù)可以被顯式轉(zhuǎn)換為字符串類型(但是反之不行)。

這里介紹兩種新的字符串相關(guān)的類型轉(zhuǎn)換規(guī)則:

  1. 一個(gè)字符串值可以被顯式轉(zhuǎn)換為一個(gè)字節(jié)切片(byte slice),反之亦然。 一個(gè)字節(jié)切片類型是一個(gè)元素類型的底層類型為內(nèi)置類型byte的切片類型。
  2. 一個(gè)字符串值可以被顯式轉(zhuǎn)換為一個(gè)碼點(diǎn)切片(rune slice),反之亦然。 一個(gè)碼點(diǎn)切片類型是一個(gè)元素類型的底層類型為內(nèi)置類型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)換中,

  • 非法的UTF-8編碼字節(jié)序列將被保持原樣不變。
  • 標(biāo)準(zhǔn)編譯器做了一些優(yōu)化,從而使得這些轉(zhuǎn)換在某些情形下將不用深復(fù)制。 這樣的情形將在下一節(jié)中介紹。

Go并不支持字節(jié)切片和碼點(diǎn)切片之間的直接轉(zhuǎn)換。我們可以用下面列出的方法來實(shí)現(xiàn)這樣的轉(zhuǎn)換:

  • 利用字符串做為中間過渡。這種方法相對(duì)方便但效率較低,因?yàn)樾枰鰞纱紊顝?fù)制。
  • 使用unicode/utf8標(biāo)準(zhǔn)庫包中的函數(shù)來實(shí)現(xiàn)這些轉(zhuǎn)換。 這種方法效率較高,但使用起來不太方便。
  • 使用bytes標(biāo)準(zhǔn)庫包中的Runes函數(shù)來將一個(gè)字節(jié)切片轉(zhuǎn)換為碼點(diǎn)切片。 但此包中沒有將碼點(diǎn)切片轉(zhuǎn)換為字節(jié)切片的函數(shù)。

一個(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
}

字符串和字節(jié)切片之間的轉(zhuǎn)換的編譯器優(yōu)化

上面已經(jīng)提到了字符串和字節(jié)切片之間的轉(zhuǎn)換將深復(fù)制它們的底層字節(jié)序列。 標(biāo)準(zhǔn)編譯器做了一些優(yōu)化,從而在某些情形下避免了深復(fù)制。 至少這些優(yōu)化在當(dāng)前(Go官方工具鏈1.18)是存在的。 這樣的情形包括:

  • 一個(gè)for-range循環(huán)中跟隨range關(guān)鍵字的從字符串到字節(jié)切片的轉(zhuǎn)換;
  • 一個(gè)在映射元素讀取索引語法中被用做鍵值的從字節(jié)切片到字符串的轉(zhuǎn)換(注意:對(duì)修改寫入索引語法無效);
  • 一個(gè)字符串比較表達(dá)式中被用做比較值的從字節(jié)切片到字符串的轉(zhuǎn)換;
  • 一個(gè)(至少有一個(gè)被銜接的字符串值為非空字符串常量的)字符串銜接表達(dá)式中的從字節(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)遍歷字符串中的碼點(diǎn)

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é)果可以看出:

  1. 下標(biāo)循環(huán)變量的值并非連續(xù)。原因是下標(biāo)循環(huán)變量為字符串中字節(jié)的下標(biāo),而一個(gè)碼點(diǎn)可能需要多個(gè)字節(jié)進(jìn)行UTF-8編碼。
  2. 第一個(gè)字符e?由兩個(gè)碼點(diǎn)(共三字節(jié))組成,其中一個(gè)碼點(diǎn)需要兩個(gè)字節(jié)進(jìn)行UTF-8編碼。
  3. 第二個(gè)字符????由四個(gè)碼點(diǎn)(共12字節(jié))組成,每個(gè)碼點(diǎn)需要三個(gè)字節(jié)進(jìn)行UTF-8編碼。
  4. 英語字符a由一個(gè)碼點(diǎn)組成,此碼點(diǎn)只需一個(gè)字節(jié)進(jìn)行UTF-8編碼。
  5. 字符π由一個(gè)碼點(diǎn)組成,此碼點(diǎn)只需兩個(gè)字節(jié)進(jìn)行UTF-8編碼。
  6. 漢字由一個(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è)字符串。
  • 從Go 1.10開始,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)行字符串銜接是比較高效的。

語法糖:將字符串當(dāng)作字節(jié)切片使用

上一篇文章中,我們了解到內(nèi)置函數(shù)copyappend可以用來復(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))
}

更多關(guān)于字符串的比較

上面已經(jīng)提到了比較兩個(gè)字符串事實(shí)上逐個(gè)比較這兩個(gè)字符串中的字節(jié)。 Go編譯器一般會(huì)做出如下的優(yōu)化:

  • 對(duì)于==!=比較,如果這兩個(gè)字符串的長(zhǎng)度不相等,則這兩個(gè)字符串肯定不相等(無需進(jìn)行字節(jié)比較)。
  • 如果這兩個(gè)字符串底層引用著字符串切片的指針相等,則比較結(jié)果等同于比較這兩個(gè)字符串的長(zhǎng)度。

所以兩個(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é)序列的相等的(或者幾乎相等的)字符串。


以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)