Go語言核心36講(Go語言實戰與應用十五)--學習筆記

MingsonZheng發表於2021-11-27

37 | strings包與字串操作

Go 語言不但擁有可以獨立代表 Unicode 字元的型別rune,而且還有可以對字串值進行 Unicode 字元拆分的for語句。

除此之外,標準庫中的unicode包及其子包還提供了很多的函式和資料型別,可以幫助我們解析各種內容中的 Unicode 字元。

這些程式實體都很好用,也都很簡單明瞭,而且有效地隱藏了 Unicode 編碼規範中的一些複雜的細節。我就不在這裡對它們進行專門的講解了。

我們今天主要來說一說標準庫中的strings程式碼包。這個程式碼包也用到了不少unicode包和unicode/utf8包中的程式實體。

  • 比如,strings.Builder型別的WriteRune方法。
  • 又比如,strings.Reader型別的ReadRune方法,等等。

下面這個問題就是針對strings.Builder型別的。我們今天的問題是:與string值相比,strings.Builder型別的值有哪些優勢?

這裡的典型回答是這樣的。

strings.Builder型別的值(以下簡稱Builder值)的優勢有下面的三種:

  • 已存在的內容不可變,但可以拼接更多的內容;
  • 減少了記憶體分配和內容拷貝的次數;
  • 可將內容重置,可重用值。

問題解析

先來說說string型別。 我們都知道,在 Go 語言中,string型別的值是不可變的。 如果我們想獲得一個不一樣的字串,那麼就只能基於原字串進行裁剪、拼接等操作,從而生成一個新的字串。

  • 裁剪操作可以使用切片表示式;
  • 拼接操作可以用操作符+實現。

在底層,一個string值的內容會被儲存到一塊連續的記憶體空間中。同時,這塊記憶體容納的位元組數量也會被記錄下來,並用於表示該string值的長度。

你可以把這塊記憶體的內容看成一個位元組陣列,而相應的string值則包含了指向位元組陣列頭部的指標值。如此一來,我們在一個string值上應用切片表示式,就相當於在對其底層的位元組陣列做切片。

另外,我們在進行字串拼接的時候,Go 語言會把所有被拼接的字串依次拷貝到一個嶄新且足夠大的連續記憶體空間中,並把持有相應指標值的string值作為結果返回。

顯然,當程式中存在過多的字串拼接操作的時候,會對記憶體的分配產生非常大的壓力。

注意,雖然string值在內部持有一個指標值,但其型別仍然屬於值型別。不過,由於string值的不可變,其中的指標值也為記憶體空間的節省做出了貢獻。

更具體地說,一個string值會在底層與它的所有副本共用同一個位元組陣列。由於這裡的位元組陣列永遠不會被改變,所以這樣做是絕對安全的。

與string值相比,Builder值的優勢其實主要體現在字串拼接方面。

Builder值中有一個用於承載內容的容器(以下簡稱內容容器)。它是一個以byte為元素型別的切片(以下簡稱位元組切片)。

由於這樣的位元組切片的底層陣列就是一個位元組陣列,所以我們可以說它與string值儲存內容的方式是一樣的。

實際上,它們都是通過一個unsafe.Pointer型別的欄位來持有那個指向了底層位元組陣列的指標值的。

正是因為這樣的內部構造,Builder值同樣擁有高效利用記憶體的前提條件。雖然,對於位元組切片本身來說,它包含的任何元素值都可以被修改,但是Builder值並不允許這樣做,其中的內容只能夠被拼接或者完全重置。

這就意味著,已存在於Builder值中的內容是不可變的。因此,我們可以利用Builder值提供的方法拼接更多的內容,而絲毫不用擔心這些方法會影響到已存在的內容。

這裡所說的方法指的是,Builder值擁有的一系列指標方法,包括:Write、WriteByte、WriteRune和WriteString。我們可以把它們統稱為拼接方法。

我們可以通過呼叫上述方法把新的內容拼接到已存在的內容的尾部(也就是右邊)。這時,如有必要,Builder值會自動地對自身的內容容器進行擴容。這裡的自動擴容策略與切片的擴容策略一致。

換句話說,我們在向Builder值拼接內容的時候並不一定會引起擴容。只要內容容器的容量夠用,擴容就不會進行,針對於此的記憶體分配也不會發生。同時,只要沒有擴容,Builder值中已存在的內容就不會再被拷貝。

除了Builder值的自動擴容,我們還可以選擇手動擴容,這通過呼叫Builder值的Grow方法就可以做到。Grow方法也可以被稱為擴容方法,它接受一個int型別的引數n,該引數用於代表將要擴充的位元組數量。

如有必要,Grow方法會把其所屬值中內容容器的容量增加n個位元組。更具體地講,它會生成一個位元組切片作為新的內容容器,該切片的容量會是原容器容量的二倍再加上n。之後,它會把原容器中的所有位元組全部拷貝到新容器中。

var builder1 strings.Builder
// 省略若干程式碼。
fmt.Println("Grow the builder ...")
builder1.Grow(10)
fmt.Printf("The length of contents in the builder is %d.\n", builder1.Len())

當然,Grow方法還可能什麼都不做。這種情況的前提條件是:當前的內容容器中的未用容量已經夠用了,即:未用容量大於或等於n。這裡的前提條件與前面提到的自動擴容策略中的前提條件是類似的。

fmt.Println("Reset the builder ...")
builder1.Reset()
fmt.Printf("The third output(%d):\n%q\n", builder1.Len(), builder1.String())

最後,Builder值是可以被重用的。通過呼叫它的Reset方法,我們可以讓Builder值重新回到零值狀態,就像它從未被使用過那樣。

一旦被重用,Builder值中原有的內容容器會被直接丟棄。之後,它和其中的所有內容,將會被 Go 語言的垃圾回收器標記並回收掉。

package main

import (
	"fmt"
	"strings"
)

func main() {
	// 示例1。
	var builder1 strings.Builder
	builder1.WriteString("A Builder is used to efficiently build a string using Write methods.")
	fmt.Printf("The first output(%d):\n%q\n", builder1.Len(), builder1.String())
	fmt.Println()
	builder1.WriteByte(' ')
	builder1.WriteString("It minimizes memory copying. The zero value is ready to use.")
	builder1.Write([]byte{'\n', '\n'})
	builder1.WriteString("Do not copy a non-zero Builder.")
	fmt.Printf("The second output(%d):\n\"%s\"\n", builder1.Len(), builder1.String())
	fmt.Println()

	// 示例2。
	fmt.Println("Grow the builder ...")
	builder1.Grow(10)
	fmt.Printf("The length of contents in the builder is %d.\n", builder1.Len())
	fmt.Println()

	// 示例3。
	fmt.Println("Reset the builder ...")
	builder1.Reset()
	fmt.Printf("The third output(%d):\n%q\n", builder1.Len(), builder1.String())
}

知識擴充套件

問題 1:strings.Builder型別在使用上有約束嗎?

答案是:有約束,概括如下:

  • 在已被真正使用後就不可再被複制;
  • 由於其內容不是完全不可變的,所以需要使用方自行解決操作衝突和併發安全問題。

我們只要呼叫了Builder值的拼接方法或擴容方法,就意味著開始真正使用它了。顯而易見,這些方法都會改變其所屬值中的內容容器的狀態。

一旦呼叫了它們,我們就不能再以任何的方式對其所屬值進行復制了。否則,只要在任何副本上呼叫上述方法就都會引發 panic。

這種 panic 會告訴我們,這樣的使用方式是並不合法的,因為這裡的Builder值是副本而不是原值。順便說一句,這裡所說的複製方式,包括但不限於在函式間傳遞值、通過通道傳遞值、把值賦予變數等等。

var builder1 strings.Builder
builder1.Grow(1)
builder3 := builder1
//builder3.Grow(1) // 這裡會引發panic。
_ = builder3

雖然這個約束非常嚴格,但是如果我們仔細思考一下的話,就會發現它還是有好處的。

正是由於已使用的Builder值不能再被複制,所以肯定不會出現多個Builder值中的內容容器(也就是那個位元組切片)共用一個底層位元組陣列的情況。這樣也就避免了多個同源的Builder值在拼接內容時可能產生的衝突問題。

不過,雖然已使用的Builder值不能再被複制,但是它的指標值卻可以。無論什麼時候,我們都可以通過任何方式複製這樣的指標值。注意,這樣的指標值指向的都會是同一個Builder值。

f2 := func(bp *strings.Builder) {
 (*bp).Grow(1) // 這裡雖然不會引發panic,但不是併發安全的。
 builder4 := *bp
 //builder4.Grow(1) // 這裡會引發panic。
 _ = builder4
}
f2(&builder1)

正因為如此,這裡就產生了一個問題,即:如果Builder值被多方同時操作,那麼其中的內容就很可能會產生混亂。這就是我們所說的操作衝突和併發安全問題。

Builder值自己是無法解決這些問題的。所以,我們在通過傳遞其指標值共享Builder值的時候,一定要確保各方對它的使用是正確、有序的,並且是併發安全的;而最徹底的解決方案是,絕不共享Builder值以及它的指標值。

我們可以在各處分別宣告一個Builder值來使用,也可以先宣告一個Builder值,然後在真正使用它之前,便將它的副本傳到各處。另外,我們還可以先使用再傳遞,只要在傳遞之前呼叫它的Reset方法即可。

builder1.Reset()
builder5 := builder1
builder5.Grow(1) // 這裡不會引發panic。

總之,關於複製Builder值的約束是有意義的,也是很有必要的。雖然我們仍然可以通過某些方式共享Builder值,但最好還是不要以身犯險,“各自為政”是最好的解決方案。不過,對於處在零值狀態的Builder值,複製不會有任何問題。

package main

import (
	"strings"
)

func main() {
	// 示例1。
	var builder1 strings.Builder
	builder1.Grow(1)

	f1 := func(b strings.Builder) {
		//b.Grow(1) // 這裡會引發panic。
	}
	f1(builder1)

	ch1 := make(chan strings.Builder, 1)
	ch1 <- builder1
	builder2 := <-ch1
	//builder2.Grow(1) // 這裡會引發panic。
	_ = builder2

	builder3 := builder1
	//builder3.Grow(1) // 這裡會引發panic。
	_ = builder3

	// 示例2。
	f2 := func(bp *strings.Builder) {
		(*bp).Grow(1) // 這裡雖然不會引發panic,但不是併發安全的。
		builder4 := *bp
		//builder4.Grow(1) // 這裡會引發panic。
		_ = builder4
	}
	f2(&builder1)

	builder1.Reset()
	builder5 := builder1
	builder5.Grow(1) // 這裡不會引發panic。
}

問題 2:為什麼說strings.Reader型別的值可以高效地讀取字串?

與strings.Builder型別恰恰相反,strings.Reader型別是為了高效讀取字串而存在的。後者的高效主要體現在它對字串的讀取機制上,它封裝了很多用於在string值上讀取內容的最佳實踐。

strings.Reader型別的值(以下簡稱Reader值)可以讓我們很方便地讀取一個字串中的內容。在讀取的過程中,Reader值會儲存已讀取的位元組的計數(以下簡稱已讀計數)。

已讀計數也代表著下一次讀取的起始索引位置。Reader值正是依靠這樣一個計數,以及針對字串值的切片表示式,從而實現快速讀取。

此外,這個已讀計數也是讀取回退和位置設定時的重要依據。雖然它屬於Reader值的內部結構,但我們還是可以通過該值的Len方法和Size把它計算出來的。程式碼如下:

var reader1 strings.Reader
// 省略若干程式碼。
readingIndex := reader1.Size() - int64(reader1.Len()) // 計算出的已讀計數。

Reader值擁有的大部分用於讀取的方法都會及時地更新已讀計數。比如,ReadByte方法會在讀取成功後將這個計數的值加1。

又比如,ReadRune方法在讀取成功之後,會把被讀取的字元所佔用的位元組數作為計數的增量。

不過,ReadAt方法算是一個例外。它既不會依據已讀計數進行讀取,也不會在讀取後更新它。正因為如此,這個方法可以自由地讀取其所屬的Reader值中的任何內容。

除此之外,Reader值的Seek方法也會更新該值的已讀計數。實際上,這個Seek方法的主要作用正是設定下一次讀取的起始索引位置。

另外,如果我們把常量io.SeekCurrent的值作為第二個引數值傳給該方法,那麼它還會依據當前的已讀計數,以及第一個引數offset的值來計算新的計數值。

由於Seek方法會返回新的計數值,所以我們可以很容易地驗證這一點。比如像下面這樣:

offset2 := int64(17)
expectedIndex := reader1.Size() - int64(reader1.Len()) + offset2
fmt.Printf("Seek with offset %d and whence %d ...\n", offset2, io.SeekCurrent)
readingIndex, _ := reader1.Seek(offset2, io.SeekCurrent)
fmt.Printf("The reading index in reader: %d (returned by Seek)\n", readingIndex)
fmt.Printf("The reading index in reader: %d (computed by me)\n", expectedIndex)

綜上所述,Reader值實現高效讀取的關鍵就在於它內部的已讀計數。計數的值就代表著下一次讀取的起始索引位置。它可以很容易地被計算出來。Reader值的Seek方法可以直接設定該值中的已讀計數值。

package main

import (
	"fmt"
	"io"
	"strings"
)

func main() {
	// 示例1。
	reader1 := strings.NewReader(
		"NewReader returns a new Reader reading from s. " +
			"It is similar to bytes.NewBufferString but more efficient and read-only.")
	fmt.Printf("The size of reader: %d\n", reader1.Size())
	fmt.Printf("The reading index in reader: %d\n",
		reader1.Size()-int64(reader1.Len()))

	buf1 := make([]byte, 47)
	n, _ := reader1.Read(buf1)
	fmt.Printf("%d bytes were read. (call Read)\n", n)
	fmt.Printf("The reading index in reader: %d\n",
		reader1.Size()-int64(reader1.Len()))
	fmt.Println()

	// 示例2。
	buf2 := make([]byte, 21)
	offset1 := int64(64)
	n, _ = reader1.ReadAt(buf2, offset1)
	fmt.Printf("%d bytes were read. (call ReadAt, offset: %d)\n", n, offset1)
	fmt.Printf("The reading index in reader: %d\n",
		reader1.Size()-int64(reader1.Len()))
	fmt.Println()

	// 示例3。
	offset2 := int64(17)
	expectedIndex := reader1.Size() - int64(reader1.Len()) + offset2
	fmt.Printf("Seek with offset %d and whence %d ...\n", offset2, io.SeekCurrent)
	readingIndex, _ := reader1.Seek(offset2, io.SeekCurrent)
	fmt.Printf("The reading index in reader: %d (returned by Seek)\n", readingIndex)
	fmt.Printf("The reading index in reader: %d (computed by me)\n", expectedIndex)

	n, _ = reader1.Read(buf2)
	fmt.Printf("%d bytes were read. (call Read)\n", n)
	fmt.Printf("The reading index in reader: %d\n",
		reader1.Size()-int64(reader1.Len()))
}

總結

今天,我們主要討論了strings程式碼包中的兩個重要型別,即:Builder和Reader。前者用於構建字串,而後者則用於讀取字串。

與string值相比,Builder值的優勢主要體現在字串拼接方面。它可以在保證已存在的內容不變的前提下,拼接更多的內容,並且會在拼接的過程中,儘量減少記憶體分配和內容拷貝的次數。

不過,這類值在使用上也是有約束的。它在被真正使用之後就不能再被複制了,否則就會引發 panic。雖然這個約束很嚴格,但是也可以帶來一定的好處。它可以有效地避免一些操作衝突。雖然我們可以通過一些手段(比如傳遞它的指標值)繞過這個約束,但這是弊大於利的。最好的解決方案就是分別宣告、分開使用、互不干涉。

Reader值可以讓我們很方便地讀取一個字串中的內容。它的高效主要體現在它對字串的讀取機制上。在讀取的過程中,Reader值會儲存已讀取的位元組的計數,也稱已讀計數。

這個計數代表著下一次讀取的起始索引位置,同時也是高效讀取的關鍵所在。我們可以利用這類值的Len方法和Size方法,計算出其中的已讀計數的值。有了它,我們就可以更加靈活地進行字串讀取了。

我只在本文介紹了上述兩個資料型別,但並不意味著strings包中有用的程式實體只有這兩個。實際上,strings包還提供了大量的函式。比如:

`Count`、`IndexRune`、`Map`、`Replace`、`SplitN`、`Trim`,等等。

它們都是非常易用和高效的。你可以去看看它們的原始碼,也許會因此有所感悟。

思考題

今天的思考題是:strings.Builder和strings.Reader都分別實現了哪些介面?這樣做有什麼好處嗎?

筆記原始碼

https://github.com/MingsonZheng/go-core-demo

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

相關文章