您好,登錄后才能下訂單哦!
字符編碼的問題,是計算機領域中非常基礎的一個問題。
Go語言中的標識符可以包含任何Unicode編碼可以表示的字母字符。可以直接把一個整數數值轉換為一個string類型的值。被轉換的整數值應該是一個有效的Unicode碼點,否則會顯示為一個“?”字符:
package main
import "fmt"
func main() {
s1 := '你' // 這是一個字符類型,不是字符串
fmt.Println(int(s1)) // 字符“你”轉為整數是20320
s2 := rune(20320)
fmt.Println(string(s2))
s3 := rune(-1) // 不用費心找一個不存在的Unicode碼點,用-1就好
fmt.Println(string(s3)) // 不存在的碼點顯示的效果
}
當一個string類型的值被轉換為[]rune類型值的時候,其中的字符串會被拆分成一個一個的Unicode字符:
func main() {
s := "你好,世界!"
r := []rune(s)
fmt.Println(r)
for _, c := range(r) {
fmt.Printf("%c ", c)
}
fmt.Println()
}
Go語言采用的字符編碼方案從屬于Unicode編碼規范。更準確的說,Go語言的代碼正是由Unicode字符組成的。所有源代碼,都必須按照Unicode編碼規范這的UTF-8編碼格式進行編碼。
Go語言的源碼文件必須使用UTF-8編碼格式進行存儲。如果源碼中出現了非UTF-8編碼的字符,那么在構建、安裝以及運行的時候,Go命令就會報告錯誤“illegal UTF-8 encoding”。
在計算機系統的內部,抽象的字符會被編碼為整數。這些整數的范圍被稱為代碼空間。在代碼空間之內,每一個特定的整數都被稱為一個碼點。一個受支持的抽象字符會被映射并分配給某個特定的碼點。反過來,一個碼點總是可以看成一個被編碼的字符。
Unicode編碼規范通常使用16進制表示法來表示Unicode碼點的整數數值,并使用“U+”作為前綴。比如,字母a的Unicode碼點是U+0061。在Unicode編碼規范中,一個字符能且只能與它對應的那個碼點表示。
Unicode編碼規范提供了3種不同的編碼格式:
上面的名稱中,右邊的整數是有含義的。就是以多少個比特位作為一個編碼單元。以UTF-8為例,它會以8個比特位,就是1個字節作為一個編碼單元。并且,它與標準的ASCII編碼是完全兼容的。在[0x00, 0x7f]的范圍內,這兩種編碼表示的字符是相同的。
UTF-8是一種可變寬的編碼方案。它會用一個或多個字節的二進制數來表示某個字符,最多使用4個字節。比如,一個英文字符,僅占用1個字節,而一個中文字符,占用3個字節。不論怎樣,一個受支持的字符總是可以用UTF-8進行編碼,成為一個字節序列。
在底層,一個string類型的值,是由一系列相對應的Unicode碼點的UTF-8編碼值來表達的。
在Go語言中,一個string類型的值既可以被拆分為一個包含多個字符的序列([]runc 類型),也可以被拆分為一個包含多個字節的序列([]byte 類型)。
rune是Go語言特有的一個基本數據類型,它的一個值就代碼一個字符,即:Unicode字符。UTF-8編碼方案會把一個Unicode字符編碼為一個長度在[1,4]范圍內的字節序列。所以,一個rune類型的值也可以由一個或多個字節來代表。下面是rune類型的聲明:
type rune = int32
rune類型實際上是int32類型的一個別名類型。一個rune類型的值會由4個字節寬度的空間來存儲。一個rune類型的值在底層就是一個UTF-8編碼值。
把一個字符串轉換為[]rune類型的話,不論是英文占1個字節還是中文占3個字節,其中每一個字符,都會獨立成為一個rune類型的元素值:
str := "你好,世界! This is Golang."
fmt.Printf("%q\n", []rune(str))
而每個rune類型的值在底層都是由一個UTF-8編碼值來表達的,所以可以換一種方式展示為整數的序列:
fmt.Printf("%x\n", []rune(str))
還可以再進一步的拆分,拆分為字節序列:
fmt.Printf("[% x]\n", []byte(str))
字節切片中,英文字符的值和上面的字符切片里是一樣的。都是一個字節來表示。
而中文字符占字節切片中的3個元素,在字符切片中占1個元素。以中文字符“你”為例,UTF-8編碼的整數為0x4f60,就是10進制的20320,而在字節切片中是3個數:e4、bd、a0。
UTF-8是由1至4個字節表示,是變長的。在編碼的時候,第一個字節的高位指明了后面還有多少個字節:
分析一下“你”這個中文字。UTF-8是0x4f60,就是:
0100 1111 0110 0000
把上面的二進制位替換掉1110xxxx 10xxxxxx 10xxxxxx里的x:
11100100 10111101 10100000 就是 e4 bd a0
使用range遍歷字符串的時候,會先把字符串拆成一個字節序列,然后再試圖找出每個字節對應的Unicode字符。用for range迭代的時候可以返回2個變量,第一個是索引值,第二個就是字符,類型是rune:
func main() {
s := "Hi 世界"
for i, c := range(s) {
fmt.Printf("%d: %q\t[% x]\n", i, c, []byte(string(c)))
}
}
/* 執行結果
PS G:\Steed\Documents\Go\src\Go36\article36\example04> go run main.go
0: 'H' [48]
1: 'i' [69]
2: ' ' [20]
3: '世' [e4 b8 96]
6: '界' [e7 95 8c]
PS G:\Steed\Documents\Go\src\Go36\article36\example04>
*/
這里要注意一下執行后的結果,主要是返回的第一個變量也就是下標,或者叫索引值。索引值不是每次都加1的,英文中文字符占3個字節,所以中文字符后的下一個索引值是加3的。
這樣的for range可以逐一迭代出字符串里的每一個Unicode字符。但是相鄰的Unicode字符的索引值并不一定是連續的。這取決于前一個Unicode字符的寬度。如果想要得到其中某個Unicode字符對應的UTF-8編碼的寬度,可以不用去了解上面的UTF-8與Unicode的轉換的編碼格式。而是可以把下一個字符的索引值減去當前字符的索引值就算好了。
標準庫中的unicode包及其子包,提供了很多的函數和數據類型,可以解析各種內容中的Unicode字符。這些程序實體都很好用,也都很簡單明了,而且有效的隱藏了Unicode編碼規范中的一些復雜的細節。不過這部分只是提了一下,沒有展開,也沒有進行講解。
另外去找了幾個unicode包使用的示例,放這里充實點內容。
統計字符數:
func main() {
s := "Hi 世界" // 3個ASCII字符,2個中文字符
fmt.Println(len(s)) // 9
fmt.Println(utf8.RuneCountInString(s)) // 5
}
返回字符串第一個字符的編碼和寬度:
func main() {
s := "Hi 世界"
for i := 0; i < len(s); {
r, size := utf8.DecodeRuneInString(s[i:])
fmt.Printf("%d %c\n", i, r)
i += size
}
}
因為Go的for range本身就可以直接遍歷Unicode字符,所以其實要處理字符也不需要借助編碼工具,用好for range就也是可以的:
func main() {
s := "Hi 世界" // 3個ASCII字符,2個中文字符
var n uint
for range s {
n++
}
fmt.Println(n)
for i, r := range s {
fmt.Printf("%d\t%c\t%d\n", i, r, len(string(r)))
}
}
這個是真正的字數統計了,用了unicode包里的一個函數,排除非文字的字符,主要是會有標點符號的干擾:
func main() {
s := "Make the plan. Execute the plan. Expect the plan to go off the rails. Throw away the plan."
var n uint
for _, r := range s {
if unicode.IsLetter(r) {
n++
}
}
fmt.Println(n)
}
標準庫中的strings代碼包,在這個包里用到了不少unicode包和unicode/utf8包中的程序實體。比如,strings.Builder類型的WriteRune方法,strings.Reader類型的ReadRune方法,等等。
原生的string類型的值出不可變的。如果要獲得一個不一樣的字符串,就需要生成一個新的字符串。在底層,string值的內容會被存儲到一塊連續的內存空間。同時,這塊內存容納的字節數量也被記錄下來了,并用于表示string值的長度。
在進行字符串拼接的時候,Go語言會把所有被拼接的字符串一次拷貝到一個嶄新且足夠大的連續內存空間中,并把持有相應指針值的string值作為結果返回。當程序中存在過多的字符串拼接操作的時候,會對內存的分配產生非常大的壓力。雖然string值在內部持有一個指針值,但其類型仍然屬于值類型。不過,由于string值的不可變,其中的指針值也為內存空間的節省做出了貢獻。就是string值會在底層與它所有的副本共用同一個字節數組。不過,由于string值的不可變,所以這樣做是絕對安全的。
strings.Builder是1.10加入strings包中的新類型。如果是舊版本就沒有了。
Golang貌似不支持升級,所以需要卸載,然后安裝新版本。
與string的值相比,strings.Builder類型的值有以下3個優勢:
比較string
與string值相比,Builder值的優勢主要體現在字符串拼接方面。Builder值中有一個用于承載內容的容器,內容容器。它是一個以byte為元素類型的切片,字節切片。
字節切片的底層數據就是一個字節數組,它與string值存儲內容的方式是一樣的。實際上,它們都是通過一個unsafe.Pointer類型的字段來持有那個指向了底層字節數組的指針值的。因為有這樣一樣的構造,使得Builder值擁有同樣高效利用內存的前提條件。雖然對于字節切片本身來說,它包含的任何元素值都可以被修改,但是Builder值并不允許這樣做,其中的內容只能夠進行拼接或者完全被重置。
拼接方法
這樣,已經存在的Builder值中的內容是不可變的。利用Builder值提供的方法拼接更多的內容時就不用擔心這些方法會影響到已存在的內容。這里所說的方法就是Builder值擁有的一系列指針方法,或者統稱為拼接方法:
拼接方法的示例代碼:
package main
import (
"fmt"
"strings"
)
func main() {
var b1 strings.Builder
b1.WriteString("Make The Plan.")
fmt.Println(b1)
fmt.Println(b1.Len(), b1.String())
b1.WriteByte(' ')
b1.WriteString("Execute the plan")
b1.Write([]byte{'.', ' '})
s := "Expect the plan to go off the rails."
for _, r := range s {
b1.WriteRune(r)
}
fmt.Println(b1.Len(), b1.String())
b1.WriteByte(' ')
s = "Throw away the plan."
for _, c := range []byte(s) {
b1.WriteByte(c)
}
fmt.Println(b1.Len(), b1.String())
}
利用上面這些方法,就可以把新的內容拼接到已存在的內容的尾部。如果需要,Builder值會自動的對自身的內容容器進行擴容。這里的自動擴容策略與切片的擴容策略一致。
除了Builder值的自動擴容,還可以選擇手動擴容,這通過調用Builder值的Grow方法實現。Grow方法也可以稱為擴容方法,它接受一個int類型的參數n,參數表示將要擴充的字節數量。Grow方法會把內容容器的容量增加n個字節。就是生成一個字節切片作為新的內容容器,切片的容量會是原容器容量的2倍再加上n。之后。把原容器中的所有字節全部拷貝到新容器中。文字描述不如看一下源碼更清楚:
func (b *Builder) grow(n int) {
buf := make([]byte, len(b.buf), 2*cap(b.buf)+n)
copy(buf, b.buf)
b.buf = buf
}
即使是手動調用的Grow方法,也可能什么都不做,這個還是從源碼里看吧:
func (b *Builder) Grow(n int) {
b.copyCheck()
if n < 0 {
panic("strings.Builder.Grow: negative count")
}
if cap(b.buf)-len(b.buf) < n {
b.grow(n)
}
}
就是擴容前會檢查當前容量夠不夠,如果當前有足夠的容量就不做擴容了。
調用手動擴容的場景
如果只是拼接一次數據,直接進行拼接就好了,不需要手動進行擴容。如果容量不夠,那么自動擴容也是一樣的。
在需要多次拼接大量的數據之前,先進行手動擴容就可以達到提高性能的效果。如果自動擴容,多次拼接的過程中,就會有多次的擴容操作。而每次擴容操作相對來說都是代價昂貴的。如果提前就把之后需要的空間準備好,只進行一次擴容,減少了擴容操作的次數,應該是會提高性能的。這里應該還可以做一個性能測試,直觀的看到效果。
調用擴容方法
調用擴容方法很簡單,本想再觀察一下擴容前后的效果的,可是封裝的太好,沒有方便的手段查看。關于Grow方法的效果,關鍵變量都是私有的,并且包也沒有提供相關的方法,就看不到效果了:
func main() {
var b1 strings.Builder
b1.WriteString("你好")
fmt.Println(b1.Len(), b1.String())
b1.Grow(10)
fmt.Println(b1.Len(), b1.String())
}
strings.Builder類型的Len方法,源碼中是這樣的:
type Builder struct {
addr *Builder // of receiver, to detect copies by value
buf []byte
}
func (b *Builder) Len() int { return len(b.buf) }
Len方法返回的就是buf這個切片的長度,Grow方法的擴容就是對buf切片的擴容,檢驗的方法需要查看buf切片的容量就是cap(b.buf)
。字段不可導出,也沒有提供相應的方法,就不深究了。
還有一個Reset方法,可以讓Builder值重新回到零值狀態,就好像從未被使用過那樣。Reset之后,Builder值中的內容會被直接丟棄。之后會被Go語言的垃圾回收器標記并回收掉。下面是Reset方法的源碼:
func (b *Builder) Reset() {
b.addr = nil
b.buf = nil
}
全部字段設為零值,就是創建結構體時的狀態。所以如果要使用一個Builder,新創建一個和重用一個,獲得的Builder都是一樣的。重用的時候會把之前的內容都丟棄掉,釋放了內存資源。
Builder在被真正使用后,就不可再被復制了。
只要調用了Builder值的拼接方法或擴容方法,就意味著真正開始使用它了。一旦調用了它們,就不能再以任何的方式對其所屬值進行復制。否則只要在任何副本上調用上述方法,就會引發panic。在源碼里,這些都是通過一個copyCheck方法來實現的:
func (b *Builder) copyCheck() {
if b.addr == nil {
b.addr = (*Builder)(noescape(unsafe.Pointer(b)))
} else if b.addr != b {
panic("strings: illegal use of non-zero Builder copied by value")
}
}
在執行copuCheck方法后,如果此時Builder還沒有分配地址,就會設置一個地址了。此時就是真正被使用了。
如果有地址,就會和addr字段進行比較。addr字段里存的就是結構體本身的指針地址,copyCheck方法是個指針方法,本身也是指針,就是比較兩個指針是否一樣,不過不一樣,就引發panic。
copyCheck方法會在所有的4個拼接方法以及擴容方法里執行。這幾個方法都是會改變Builder里的內容的,擴容方法看似不改變內容,但是會對buf字段執行copy,拷貝到新的內存區域,拷貝前后引用的位置是不同的。如果此時調用的方法的對象是一個副本,就會在檢查指針的時候引發panic。
不能復制是因為不能使用副本調用以上這些方法,而本質就是Builder的內存地址不能變,會產生這種情況的復制行為包括但不限于下面這些:
這種約束還是有好處的,這樣肯定不會出現多個Builder值中的內容容器,就是buf字段的字節切面,共用一個底層數據的情況。這樣也就避免了多個同源的Builder值在拼接內容時可能產生的沖突問題。
從本質上看,也不是不能復制。副本是可以產生的,只有在對副本調用擴容方法和拼接方法的時候才會引發panic。
可以把聲明后還沒用過的Builder值,或者是Reset后的Builder值,將它的副本傳到各處。似乎先賦值出去再Reset也是可以的,至少是不會引發panic,不過會比傳遞空值多復制2個指針。另外副本還是可以調用Len方法和String方法的,包括Reset方法,這些都不會改變原Builder值的內容。不過似乎也沒什么用,需要的話,只要復制一份String方法的結果保存就可以了。下面試一下復制后調用String方法:
func main() {
var b1 strings.Builder
b1.WriteString("Test Copy 1")
b1.Grow(100) // 消除擴容時copy的情況對底層數組的影響
b2 := b1
fmt.Println(b1.Len(), b1.String())
fmt.Println(b2.Len(), b2.String())
b1.WriteString(" 再增加點內容") // 不會對副本的內容產生影響
fmt.Println(b1.Len(), b1.String())
fmt.Println(b2.Len(), b2.String()) // 副本的內容還是原樣
}
副本的內容容器里的內容不會跟著原Builder而改變。這是一個切片,不考慮擴容的情況,其實副本和原值還是同一個底層數組,但是副本對底層數組的引用范圍沒變,而且已經被引用的這些內容是不允許改變的。再考慮到擴容的情況,也不可能讓副本感知到原來的內容的變化。
由于其內容不是完全不可變的,所以需要調用方自行解決操作沖突和并發安全問題。
雖然Builder值不能被復制,但它的指針值是可以的。無論什么時候,都可以通過任何方式復制這樣的指針值。只要記住,這樣的指針值都會是同一個Builder值。這時又會產生一個新問題,Builder值被多方同時操作,就會有操作沖突和并發安全問題。
Builder值自己是無法解決問題的。在傳遞其指針共享Builder值的時候,一定要確保各方對它的使用時正確、有序的,并且是并發安全的。最好還是不要共享Builder值以及它的指針值。雖然可以通過某些方法實現共享Builder值,但是最好不要這么用。
與strings.Builder類型相反,strings.Reader類型是為了高效讀取字符串而存在的。高效主要體現在它對字符串的讀取機制上,它封裝了很多用于在string值上讀取內容的最佳實踐。
通過Reader值,可以方便地讀取一個字符串中的內容。在讀取過程中,Reader值會保存已讀取的字節的計數,就是已讀計數。已讀計數也代表著下一次讀取的起始索引位置。Reader值正是依靠這樣的一個計數,以及針對字符串的切片表達式,從而實現快速讀取。這個已讀計數還是讀取回退和位置設定是的重要依據。雖然它是Reader值的內部結構,但是還是可以通過Len方法和Size方法把它計算出來的:
func main() {
str := "Make the plan. Execute the plan. Expect the plan to go off the rails. Throw away the plan."
r1 := strings.NewReader(str)
fmt.Printf("Size: %d, Len: %d\n", r1.Size(), r1.Len())
buf := make([]byte, 14)
n, _ := r1.Read(buf) // 忽略錯誤
fmt.Println(string(buf)) // 都讀到這里來了
fmt.Printf("Read: %d\n", n)
fmt.Printf("Size: %d, Len: %d, Read: %d\n", r1.Size(), r1.Len(), r1.Size()-int64(r1.Len()))
}
Size是整體的長度,Len是剩余未讀內容的長度。相減就是已讀計數了,這里注意兩個數值類型不一樣,需要轉一下。
Reader值擁有的大部分用于讀取的方法都會及時地更新已讀計數。比如,ReadByte方法會在讀取成功后講這個計數的值加1,ReadRune方法會在讀取成功后,把讀取到的字符所占的字節數作為計數的增量。
不過ReadAt方法例外,不會依賴已讀計數進行讀取,也不會在讀取后更新已讀計數。所以讀取的是需要多傳一個參數,指定起始位置。
還有一個Seek方法,可以更新已讀計數。它的主要作用正式設定下一次讀取的起始索引位置。它的第二個參數,可以指定以什么方式和第一個參數的offset計算起始索引位置:
package main
import (
"fmt"
"strings"
"io"
)
func main() {
str := "Make the plan. Execute the plan. Expect the plan to go off the rails. Throw away the plan."
r1 := strings.NewReader(str)
buf := make([]byte, 17)
offset := int64(15)
n, _ := r1.ReadAt(buf, offset)
fmt.Println(n, string(buf))
r1.Seek(offset + int64(n) + 1, io.SeekStart)
buf = make([]byte, 36)
n, _ = r1.Read(buf)
fmt.Println(n, string(buf))
}
Seek方法還有2個返回值,返回新的計數值和err。
這篇講了strings包中的兩個重要的類型:
在字符串拼接方法,Builder值會比原生的string值更有優勢。而在字符串的讀取時,Reader值更高效。
在strings包中有用的程序實體不止這2個,還提供了大量的函數:
關于包內各種函數的用法,在下面這篇里有列舉:
https://blog.51cto.com/steed/2299514
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。