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

MingsonZheng 發表於 2021-11-27
Go

36 | unicode與字元編碼

在開始今天的內容之前,我先來做一個簡單的總結。

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 語言標準庫中最常用的那些程式碼包,弄清它們的用法、瞭解它們的機理。當然了,我還會順便講一講那些必備的周邊知識。

前導內容 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。

image

(字串值的底層表示)

總之,一個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 字元是否為單位元組字元通常有幾種方式?

筆記原始碼

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

知識共享許可協議

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

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