面對Unicode與UTF,你還傻傻分不清嗎?Go中沒有字元型態,如果使用rune儲存碼點,而字串就是UTF-8編碼後的位元組,在Go中要處理文字,開發者一開始就必須了解Unicode、UTF的差別。

string與[]byte

現代程式語言大多支援Unicode,然而,有不少開發者搞不清楚「支援」是怎麼一回事。

將Unicode、UTF-8/16/32視為等義詞的,大有人在,也常見有「Unicode使用16個位元儲存」這種錯誤的說法。

或許這樣的狀況不應該怪罪開發者,因為,多數程式語言選擇將編碼的細節隱藏起來,多半模糊地談到字串或字元支援Unicode,或者字串是由字元組成之類的說法。

然而,在〈Strings, bytes, runes and characters in Go〉(https://bit.ly/2rK7vTR)談到,「字元」的定義本身就很模糊,而在這模糊的定義上,又去定義「字串」是由字元組成這件事,就更令人搞不清楚了,因此衍生出許多文字處理方面的問題。

而為了避免這些問題,Go的字串從一開始,就以UTF-8為設計的中心。

初學Go的開發者,一開始就會面對「字串持有的就是位元組」這個事實,len("Go語言")的結果會是8,也是因為實際上"Go語言"就是長度為8的[]byte,也就是UTF-8編碼。

而開發者在指定索引時,就是指定UTF-8碼元(code unit)的位置,當下所取得的結果會是byte;此時,我們可以使用byte[]("Go語言")取得儲存的編碼位元組,切片操作的結果也會是[]byte。

許多與字串索引相關的API操作,傳回索引值時,指的都是字串持有的[]byte之位置。例如,strings套件有不少字串處理API,名稱有Index的字樣,例如,strings.Index傳回的整數。指的就是[]byte的索引位置;regex.Regexp實例上具有Index字樣方法,也是如此。

Go中可以使用for range走訪字串,取得的整數是[]byte的索引位置,然而,索引並不是遞增1的方式,這要看取得的另一個值而定,例如底下的範例,i依序會是0、1、2、5,cp依序就是'G'、'o'、'語'、'言':

for i, cp := range "Go語言" {
fmt.Printf("%d %q\n", i, cp)
}

rune與unicode

使用for range走訪"Go語言"時,最後一個i會是5,原因是:UTF-8在編碼時,中文字會使用三個碼元(也就是三個位元組),for range會試著識別一組碼元,確認是否對應至Unicode碼點(code point),若是指定給cp,而cp的型態是rune。

基本上,Go沒有所謂的字元對應型態,只有碼點的概念。

例如,rune為int32的別名,可用來儲存Unicode碼點,如果將方才範例%q改為%d,就會看到「語」、「言」的碼點的十進位數字,分別是35486、35328,在Go也可以用[]rune("Go語言")來取得[]rune,每個索引位置所儲存的都是碼點。

開發者這時就要搞清楚了!

因為Unicode為世界上大部分文字系統進行了整理,給予每個文字一個碼點號碼,而Go中的rune,儲存的就是文字的碼點號碼,其型態名稱不使用codepoint的原因在於,rune這個名稱比較簡短,並且來自一類已滅絕的盧恩字母(Runes)(https://bit.ly/2rKbjVa)。

每個文字的Unicode碼點是固定的,然而因為系統平臺的設計或是儲存空間上的考量,會轉換為不同的格式,所以,正式名稱為Unicode轉換格式(Unicode Transformation Format),簡稱為UTF,每一個Unicode碼點,可以轉換為UTF-8的位元組格式儲存,也可以轉換為UTF-16,或者是其他格式的儲存。

Go主要使用UTF-8位元組格式,作為字串的底層儲存,不過,也提供了unicode套件來協助Unicode碼點特性判斷。

例如,unicode/utf8可用來進行rune與UTF-8之間的驗證、轉換,unicode/utf16套件用來進行rune與UTF-16編碼之間的處理。

如果察看unicode/utf8,會發現處理的資料是[]byte,這是因為UTF-8的碼元是八個位元,Go中使用byte(也就是uint8)儲存;UTF-16編碼的碼元會是十六個位元,Go使用uint16 來儲存,因此unicode/utf16處理的資料是[]uint16。

其他編碼呢?

不論從哪個面向,都可以看出Go獨厚UTF-8,這可能是因為Go的設計者之一Ken Thompson,也曾經參與UTF-8的設計。

然而,如果文字資料的來源並不是UTF-8呢?例如,儲存時使用Big5的檔案?

解決的方法之一(也許是Go最希望的方式),是將檔案額外儲存為 UTF-8,再使用Go來讀取,當然,並非所有的場合都可以這麼做。

另一個方式是,使用golang.org/x/text套件。基本上,golang/x是一系列官方的擴充套件,我們必須額外安裝,而這些套件也是Go專案的一部份,只不過在相容性的維護上,比較沒那麼嚴格。

實際上,golang.org/x/text套件目前包含了文字編碼、轉換、國際化、本地化等文字性任務,而關於文字編碼的轉換,主要由其中的transform套件處理,至於Encoder、Decoder,都是transform.Transformer介面的實現。

對人類來說,Encode意謂著轉換為人難以理解的格式,Decode是轉換為人易於理解的格式;由於Go字串是以UTF-8為中心而設計,因此,關於從UTF-8轉換為其他編碼的這個動作,稱為Encode(轉換為Go本身不能理解的編碼),而從其他編碼轉換為UTF-8的這個動作,稱為Decode(轉換為Go本身可以理解的編碼)。

為了便於使用,擴充套件中的encoding定義了Encoding介面,NewDecoder、NewEncoder分別可以傳回*Decoder、*Encoder,而encoding中的traditionalchinese.Big5就是實現之一,因此要將Big5轉為UTF-8,基本的作法就是:

big5ToUTF8 := traditionalchinese.Big5.NewDecoder()
big5Test := "\xb4\xfa\xb8\xd5" // 測試的 Big5 編碼
utf8, _, _ := transform.String(big5ToUTF8, big5Test)

由於transform也定義了Reader、Writer,此時,我們可以用來將Transformer與io.Reader、io.Writer包裹在一起,若開發者想要這麼做,就可以基於它們而寫個Big5Reader、Big5Writer之類的API。

不優雅但務實的做法

Go字串就是UTF-8位元組的做法,使得撰寫文字處理相關程式碼時,絕對稱不上優雅,事實上,若仔細察看Go的發展歷史,我們可以知道,Go是為了解決工程問題而出現,既然文字處理時經常必須面對編碼的相關問題,那麼,就應該務實地面對這個問題。

因此,若開發者始終對Unicode、UTF之間的差異難以區分,可以對Go的string、[]byte、rune,以及unicode等相關套件做些瞭解,而且,在面對過去遇過的文字處理等疑問上,這不失為一種務實的釐清方式。

作者簡介


Advertisement

更多 iThome相關內容