到目前為止,我們已經一起陸陸續續地學完了 Go 語言中那些最重要也最有特色的概念、語法和編程方式。我對於它們非常喜愛,簡直可以用如數家珍來形容了。
在開始今天的內容之前,我先來做一個簡單的總結。
Go 語言經典知識總結
基於混合線程的並發編程模型自然不必多說。
在數據類型方面有:
- 基於底層數組的切片;
- 用來傳遞數據的通道;
- 作為一等類型的函數;
- 可實現面向對象的結構體;
- 能無侵入實現的接口等。
在語法方面有:
- 異步編程神器go語句;
- 函數的最后關卡defer語句;
- 可做類型判斷的switch語句;
- 多通道操作利器select語句;
- 非常有特色的異常處理函數panic和recover。
除了這些,我們還一起討論了測試 Go 程序的主要方式。這涉及了 Go 語言自帶的程序測試套件,相關的概念和工具包括:
- 獨立的測試源碼文件;
- 三種功用不同的測試函數;
- 專用的testing代碼包;
- 功能強大的go test命令。
另外,就在前不久,我還為你深入講解了 Go 語言提供的那些同步工具。它們也是 Go 語言並發編程工具箱中不可或缺的一部分。這包括了:
- 經典的互斥鎖;
- 讀寫鎖;
- 條件變量;
- 原子操作。
以及 Go 語言特有的一些數據類型,即:
- 單次執行小助手sync.Once;
- 臨時對象池sync.Pool;
- 幫助我們實現多 goroutine 協作流程的sync.WaitGroup、context.Context;
- 一種高效的並發安全字典sync.Map。
毫不誇張地說,如果你真正地掌握了上述這些知識,那么就已經獲得了 Go 語言編程的精髓。
在這之后,你再去研讀 Go 語言標准庫和那些優秀第三方庫中的代碼的時候,就一定會事半功倍。同時,在使用 Go 語言編寫軟件的時候,你肯定也會如魚得水、游刃有余的。
我用了大量的篇幅講解了 Go 語言中最核心的知識點,真心希望你已經搞懂了這些內容。
在后面的日子里,我會與你一起去探究 Go 語言標准庫中最常用的那些代碼包,弄清它們的用法、了解它們的機理。當然了,我還會順便講一講那些必備的周邊知識。
前導內容 1:Go 語言字符編碼基礎
首先,讓我們來關注字符編碼方面的問題。這應該是在計算機軟件領域中非常基礎的一個問題了。
我在前面說過,Go 語言中的標識符可以包含“任何 Unicode 編碼可以表示的字母字符”。我還說過,雖然我們可以直接把一個整數值轉換為一個string類型的值。
但是,被轉換的整數值應該可以代表一個有效的 Unicode 代碼點,否則轉換的結果就將會是"�",即:一個僅由高亮的問號組成的字符串值。
另外,當一個string類型的值被轉換為[]rune類型值的時候,其中的字符串會被拆分成一個一個的 Unicode 字符。
顯然,Go 語言采用的字符編碼方案從屬於 Unicode 編碼規范。更確切地說,Go 語言的代碼正是由 Unicode 字符組成的。Go 語言的所有源代碼,都必須按照 Unicode 編碼規范中的 UTF-8 編碼格式進行編碼。
換句話說,Go 語言的源碼文件必須使用 UTF-8 編碼格式進行存儲。如果源碼文件中出現了非 UTF-8 編碼的字符,那么在構建、安裝以及運行的時候,go 命令就會報告錯誤“illegal UTF-8 encoding”。
在這里,我們首先要對 Unicode 編碼規范有所了解。不過,在講述它之前,我先來簡要地介紹一下 ASCII 編碼。
前導內容 2: ASCII 編碼
ASCII 是英文“American Standard Code for Information Interchange”的縮寫,中文譯為美國信息交換標准代碼。它是由美國國家標准學會(ANSI)制定的單字節字符編碼方案,可用於基於文本的數據交換。
它最初是美國的國家標准,后又被國際標准化組織(ISO)定為國際標准,稱為 ISO 646 標准,並適用於所有的拉丁文字字母。
ASCII 編碼方案使用單個字節(byte)的二進制數來編碼一個字符。標准的 ASCII 編碼用一個字節的最高比特(bit)位作為奇偶校驗位,而擴展的 ASCII 編碼則將此位也用於表示字符。ASCII 編碼支持的可打印字符和控制字符的集合也被叫做 ASCII 編碼集。
我們所說的 Unicode 編碼規范,實際上是另一個更加通用的、針對書面字符和文本的字符編碼標准。它為世界上現存的所有自然語言中的每一個字符,都設定了一個唯一的二進制編碼。
它定義了不同自然語言的文本數據在國際間交換的統一方式,並為全球化軟件創建了一個重要的基礎。
Unicode 編碼規范以 ASCII 編碼集為出發點,並突破了 ASCII 只能對拉丁字母進行編碼的限制。它不但提供了可以對世界上超過百萬的字符進行編碼的能力,還支持所有已知的轉義序列和控制代碼。
我們都知道,在計算機系統的內部,抽象的字符會被編碼為整數。這些整數的范圍被稱為代碼空間。在代碼空間之內,每一個特定的整數都被稱為一個代碼點。
一個受支持的抽象字符會被映射並分配給某個特定的代碼點,反過來講,一個代碼點總是可以被看成一個被編碼的字符。
Unicode 編碼規范通常使用十六進制表示法來表示 Unicode 代碼點的整數值,並使用“U+”作為前綴。比如,英文字母字符“a”的 Unicode 代碼點是 U+0061。在 Unicode 編碼規范中,一個字符能且只能由與它對應的那個代碼點表示。
Unicode 編碼規范現在的最新版本是 11.0,並會於 2019 年 3 月發布 12.0 版本。而 Go 語言從 1.10 版本開始,已經對 Unicode 的 10.0 版本提供了全面的支持。對於絕大多數的應用場景來說,這已經完全夠用了。
Unicode 編碼規范提供了三種不同的編碼格式,即:UTF-8、UTF-16 和 UTF-32。其中的 UTF 是 UCS Transformation Format 的縮寫。而 UCS 又是 Universal Character Set 的縮寫,但也可以代表 Unicode Character Set。所以,UTF 也可以被翻譯為 Unicode 轉換格式。它代表的是字符與字節序列之間的轉換方式。
在這幾種編碼格式的名稱中,“-”右邊的整數的含義是,以多少個比特位作為一個編碼單元。以 UTF-8 為例,它會以 8 個比特,也就是一個字節,作為一個編碼單元。並且,它與標准的 ASCII 編碼是完全兼容的。也就是說,在[0x00, 0x7F]的范圍內,這兩種編碼表示的字符都是相同的。這也是 UTF-8 編碼格式的一個巨大優勢。
UTF-8 是一種可變寬的編碼方案。換句話說,它會用一個或多個字節的二進制數來表示某個字符,最多使用四個字節。比如,對於一個英文字符,它僅用一個字節的二進制數就可以表示,而對於一個中文字符,它需要使用三個字節才能夠表示。不論怎樣,一個受支持的字符總是可以由 UTF-8 編碼為一個字節序列。以下會簡稱后者為 UTF-8 編碼值。
現在,在你初步地了解了這些知識之后,請認真地思考並回答下面的問題。別擔心,我會在后面進一步闡述 Unicode、UTF-8 以及 Go 語言對它們的運用。
問題:一個string類型的值在底層是怎樣被表達的?
典型回答 是在底層,一個string類型的值是由一系列相對應的 Unicode 代碼點的 UTF-8 編碼值來表達的。
問題解析
在 Go 語言中,一個string類型的值既可以被拆分為一個包含多個字符的序列,也可以被拆分為一個包含多個字節的序列。
前者可以由一個以rune為元素類型的切片來表示,而后者則可以由一個以byte為元素類型的切片代表。
rune是 Go 語言特有的一個基本數據類型,它的一個值就代表一個字符,即:一個 Unicode 字符。
比如,'G'、'o'、'愛'、'好'、'者'代表的就都是一個 Unicode 字符。
我們已經知道,UTF-8 編碼方案會把一個 Unicode 字符編碼為一個長度在[1, 4]范圍內的字節序列。所以,一個rune類型的值也可以由一個或多個字節來代表。
type rune = int32
根據rune類型的聲明可知,它實際上就是int32類型的一個別名類型。也就是說,一個rune類型的值會由四個字節寬度的空間來存儲。它的存儲空間總是能夠存下一個 UTF-8 編碼值。
一個rune類型的值在底層其實就是一個 UTF-8 編碼值。前者是(便於我們人類理解的)外部展現,后者是(便於計算機系統理解的)內在表達。
請看下面的代碼:
str := "Go愛好者" fmt.Printf("The string: %q\n", str) fmt.Printf(" => runes(char): %q\n", []rune(str)) fmt.Printf(" => runes(hex): %x\n", []rune(str)) fmt.Printf(" => bytes(hex): [% x]\n", []byte(str))
字符串值"Go愛好者"如果被轉換為[]rune類型的值的話,其中的每一個字符(不論是英文字符還是中文字符)就都會獨立成為一個rune類型的元素值。因此,這段代碼打印出的第二行內容就會如下所示:
=> runes(char): ['G' 'o' '愛' '好' '者']
又由於,每個rune類型的值在底層都是由一個 UTF-8 編碼值來表達的,所以我們可以換一種方式來展現這個字符序列:
=> runes(hex): [47 6f 7231 597d 8005]
可以看到,五個十六進制數與五個字符相對應。很明顯,前兩個十六進制數47和6f代表的整數都比較小,它們分別表示字符'G'和'o'。
因為它們都是英文字符,所以對應的 UTF-8 編碼值用一個字節表達就足夠了。一個字節的編碼值被轉換為整數之后,不會大到哪里去。
而后三個十六進制數7231、597d和8005都相對較大,它們分別表示中文字符'愛'、'好'和'者'。
這些中文字符對應的 UTF-8 編碼值,都需要使用三個字節來表達。所以,這三個數就是把對應的三個字節的編碼值,轉換為整數后得到的結果。
我們還可以進一步地拆分,把每個字符的 UTF-8 編碼值都拆成相應的字節序列。上述代碼中的第五行就是這么做的。它會得到如下的輸出:
=> bytes(hex): [47 6f e7 88 b1 e5 a5 bd e8 80 85]
這里得到的字節切片比前面的字符切片明顯長了很多。這正是因為一個中文字符的 UTF-8 編碼值需要用三個字節來表達。
這個字節切片的前兩個元素值與字符切片的前兩個元素值是一致的,而在這之后,前者的每三個元素值才對應字符切片中的一個元素值。
注意,對於一個多字節的 UTF-8 編碼值來說,我們可以把它當做一個整體轉換為單一的整數,也可以先把它拆成字節序列,再把每個字節分別轉換為一個整數,從而得到多個整數。
這兩種表示法展現出來的內容往往會很不一樣。比如,對於中文字符'愛'來說,它的 UTF-8 編碼值可以展現為單一的整數7231,也可以展現為三個整數,即:e7、88和b1。
總之,一個string類型的值會由若干個 Unicode 字符組成,每個 Unicode 字符都可以由一個rune類型的值來承載。
這些字符在底層都會被轉換為 UTF-8 編碼值,而這些 UTF-8 編碼值又會以字節序列的形式表達和存儲。因此,一個string類型的值在底層就是一個能夠表達若干個 UTF-8 編碼值的字節序列。
知識擴展
問題 1:使用帶有range子句的for語句遍歷字符串值的時候應該注意什么?
帶有range子句的for語句會先把被遍歷的字符串值拆成一個字節序列,然后再試圖找出這個字節序列中包含的每一個 UTF-8 編碼值,或者說每一個 Unicode 字符。
這樣的for語句可以為兩個迭代變量賦值。如果存在兩個迭代變量,那么賦給第一個變量的值,就將會是當前字節序列中的某個 UTF-8 編碼值的第一個字節所對應的那個索引值。
而賦給第二個變量的值,則是這個 UTF-8 編碼值代表的那個 Unicode 字符,其類型會是rune。
例如,有這么幾行代碼:
str := "Go愛好者" for i, c := range str { fmt.Printf("%d: %q [% x]\n", i, c, []byte(string(c))) }
這里被遍歷的字符串值是"Go愛好者"。在每次迭代的時候,這段代碼都會打印出兩個迭代變量的值,以及第二個值的字節序列形式。完整的打印內容如下:
0: 'G' [47] 1: 'o' [6f] 2: '愛' [e7 88 b1] 5: '好' [e5 a5 bd] 8: '者' [e8 80 85]
第一行內容中的關鍵信息有0、'G'和[47]。這是由於這個字符串值中的第一個 Unicode 字符是'G'。該字符是一個單字節字符,並且由相應的字節序列中的第一個字節表達。這個字節的十六進制表示為47。
第二行展示的內容與之類似,即:第二個 Unicode 字符是'o',由字節序列中的第二個字節表達,其十六進制表示為6f。
再往下看,第三行展示的是'愛',也是第三個 Unicode 字符。因為它是一個中文字符,所以由字節序列中的第三、四、五個字節共同表達,其十六進制表示也不再是單一的整數,而是e7、88和b1組成的序列。
下面要注意了,正是因為'愛'是由三個字節共同表達的,所以第四個 Unicode 字符'好'對應的索引值並不是3,而是2加3后得到的5。
這里的2代表的是'愛'對應的索引值,而3代表的則是'愛'對應的 UTF-8 編碼值的寬度。對於這個字符串值中的最后一個字符'者'來說也是類似的,因此,它對應的索引值是8。
由此可以看出,這樣的for語句可以逐一地迭代出字符串值里的每個 Unicode 字符。但是,相鄰的 Unicode 字符的索引值並不一定是連續的。這取決於前一個 Unicode 字符是否為單字節字符。
正因為如此,如果我們想得到其中某個 Unicode 字符對應的 UTF-8 編碼值的寬度,就可以用下一個字符的索引值減去當前字符的索引值。
初學者可能會對for語句的這種行為感到困惑,因為它給予兩個迭代變量的值看起來並不總是對應的。不過,一旦我們了解了它的內在機制就會撥雲見日、豁然開朗。
總結
我們今天把目光聚焦在了 Unicode 編碼規范、UTF-8 編碼格式,以及 Go 語言對字符串和字符的相關處理方式上。
Go 語言的代碼是由 Unicode 字符組成的,它們都必須由 Unicode 編碼規范中的 UTF-8 編碼格式進行編碼並存儲,否則就會導致 go 命令的報錯。
Unicode 編碼規范中的編碼格式定義的是:字符與字節序列之間的轉換方式。其中的 UTF-8 是一種可變寬的編碼方案。
它會用一個或多個字節的二進制數來表示某個字符,最多使用四個字節。一個受支持的字符,總是可以由 UTF-8 編碼為一個字節序列,后者也可以被稱為 UTF-8 編碼值。
Go 語言中的一個string類型值會由若干個 Unicode 字符組成,每個 Unicode 字符都可以由一個rune類型的值來承載。
這些字符在底層都會被轉換為 UTF-8 編碼值,而這些 UTF-8 編碼值又會以字節序列的形式表達和存儲。因此,一個string類型的值在底層就是一個能夠表達若干個 UTF-8 編碼值的字節序列。
初學者可能會對帶有range子句的for語句遍歷字符串值的行為感到困惑,因為它給予兩個迭代變量的值看起來並不總是對應的。但事實並非如此。
這樣的for語句會先把被遍歷的字符串值拆成一個字節序列,然后再試圖找出這個字節序列中包含的每一個 UTF-8 編碼值,或者說每一個 Unicode 字符。
相鄰的 Unicode 字符的索引值並不一定是連續的。這取決於前一個 Unicode 字符是否為單字節字符。一旦我們清楚了這些內在機制就不會再困惑了。
對於 Go 語言來說,Unicode 編碼規范和 UTF-8 編碼格式算是基礎之一了。我們應該了解到它們對 Go 語言的重要性。這對於正確理解 Go 語言中的相關數據類型以及日后的相關程序編寫都會很有好處。
思考題
判斷一個 Unicode 字符是否為單字節字符通常有幾種方式?