Go 部落格
Go 中的字串、位元組、符文和字元
引言
上一篇部落格文章說明了切片在 Go 中是如何運作的,並使用許多範例來說明它們的實作機制。在此基礎上,這篇文章討論了 Go 中的字串。一開始,字串對於部落格文章來說似乎是一個過於簡單的主題,但要好好使用字串,不只要了解它們如何運作,還要了解位元組、字元和符文之間的差異、Unicode 和 UTF-8 之間的差異、字串和字串文字之間的差異,以及其他更微妙的區別。
探討這個主題的方法之一是將它視為對常見問題的回答:「當我在第 n 個位置索引一個 Go 字串時,我為何不會取得第 n 個字元?」正如你將看到的,這個問題會引導我們深入了解現代文字運作的許多細節。
若要獨立於 Go 了解這些問題中的一部分,極佳的入門文章是 Joel Spolsky 著名的部落格文章,軟體開發人員絕對、肯定必須知道的 Unicode 和字元集的絕對最低限度(沒有藉口!)。他提出的許多觀點將在此重述。
字串是什麼?
讓我們從一些基礎知識開始。
在 Go 中,字串實際上是一個唯讀的位元組切片。如果您不太確定什麼是位元組切片,或其如何運作,請閱讀上一篇部落格文章;我們假設您已經閱讀過。
立即聲明這一點非常重要,字串包含任意位元組。它不需要包含 Unicode 文字、UTF-8 文字或任何其他預定義格式。就字串的內容而言,它與位元組切片完全相同。
以下是一個字串文字(稍後會詳細說明)使用\xNN符號定義包含一些特殊位元組值的字串常數。(當然,位元組的範圍從十六進位值00到FF,含括兩者。)
const sample = "\xbd\xb2\x3d\xbc\x20\xe2\x8c\x98"
列印字串
由於範例字串中的一些位元組不是有效的 ASCII,甚至不是有效的 UTF-8,直接列印字串會產生難看的輸出。單純的列印陳述
fmt.Println(sample)
會產生這種雜亂的輸出(其確切外觀因環境而異)
��=� ⌘
要找出那個字串實際包含的內容,我們需要將它分解並檢查各個部分。有幾種方法可以做到這一點。最明顯的方法是在其內容上做迴圈,並分別提取位元組,就像在這個for
迴圈中一樣
for i := 0; i < len(sample); i++ { fmt.Printf("%x ", sample[i]) }
如前面所述,索引字串可以存取個別位元組,而不是字元。我們將在下面詳細回歸這個主題。現在,讓我們只處理位元組。這是位元組迴圈的輸出
bd b2 3d bc 20 e2 8c 98
請注意個別位元組如何與定義字串的十六進位轉譯字元相符。
為雜亂的字串產生可呈現輸出的較短方法是使用fmt.Printf
的 %x
(十六進位)格式動詞。它只是將字串的順序位元組當成十六進位數字輸出,每個位元組兩個數字。
fmt.Printf("%x\n", sample)
將其輸出與上面的輸出進行比較
bdb23dbc20e28c98
一個好方法是,在那種格式中使用「空格」標誌,在%
與x
之間加上一個空格。比較這裡使用的格式字串與上面的格式字串,
fmt.Printf("% x\n", sample)
並注意位元組如何以空格分隔輸出,讓結果看起來不那麼難以理解
bd b2 3d bc 20 e2 8c 98
還有更多。 %q
(帶引號)動詞會跳過字串中的任何不可列印位元組序列,因此輸出不會產生歧義。
fmt.Printf("%q\n", sample)
此技術在當大部分字串可解讀為文字,但需要根除奇特性時很方便;它會產生
"\xbd\xb2=\xbc ⌘"
如果對此內容斜視,我們可以看到隱藏在雜訊中的一個 ASCII 等號,連同一個正規空格,並且在最後顯示出著名的瑞典「景點」符號。該符號具有 Unicode 值 U+2318,由空格後的位元組編碼為 UTF-8(十六進位值 20
):e2
8c
98
。
如果我們不熟悉或對字串中的奇怪值感到困惑,則可以使用「加號」旗標到 %q
動詞。此旗標會導致輸出不僅跳脫非可列印序列,也會跳脫任何非 ASCII 位元組,同時在解譯 UTF-8 時也會跳脫這些序列和位元組。結果是它會顯示正確格式化的 UTF-8 的 Unicode 值,而這些 UTF-8 代表字串中的非 ASCII 資料
fmt.Printf("%+q\n", sample)
使用該格式時,瑞典符號的 Unicode 值會顯示為 \u
跳脫
"\xbd\xb2=\xbc \u2318"
在偵錯字串內容時,了解這些列印技術很有用,並且在隨後的討論中會很方便。值得指出的是,所有這些方法對位元組分區的行為與對字串的行為完全相同。
這裡是我們列出的完整列印選項集,會以您可以直接在瀏覽器中執行(和編輯)的完整程式呈現
package main import "fmt" func main() { const sample = "\xbd\xb2\x3d\xbc\x20\xe2\x8c\x98" fmt.Println("Println:") fmt.Println(sample) fmt.Println("Byte loop:") for i := 0; i < len(sample); i++ { fmt.Printf("%x ", sample[i]) } fmt.Printf("\n") fmt.Println("Printf with %x:") fmt.Printf("%x\n", sample) fmt.Println("Printf with % x:") fmt.Printf("% x\n", sample) fmt.Println("Printf with %q:") fmt.Printf("%q\n", sample) fmt.Println("Printf with %+q:") fmt.Printf("%+q\n", sample) }
[練習:修改以上範例,以使用位元組分區代替字串。提示:使用轉換來建立分區。]
[練習:使用每個位元組的 %q
格式對字串進行迴圈。輸出告訴你什麼?]
UTF-8 和字串文字
正如我們所見,索引字串會產生其位元組,而不是字元:字串只是一串位元組。這表示我們在字串中儲存字元值時,我們會儲存其逐位元組表示。我們來看一個受控範例,看看是如何發生的。
以下是一個簡單的程式,它使用三種不同的方式列印字串常數,其中包括一個單一字元,一種為普通字串,一種為僅限 ASCII 的引號字串,一種為十六進制的個別位元組。為避免任何混淆,我們會建立一個「原始字串」,加上反引號,因此它只能包含文字文字。(正規字串,加上雙引號,可以包含跳脫序列,如上所述。)
func main() { const placeOfInterest = `⌘` fmt.Printf("plain string: ") fmt.Printf("%s", placeOfInterest) fmt.Printf("\n") fmt.Printf("quoted string: ") fmt.Printf("%+q", placeOfInterest) fmt.Printf("\n") fmt.Printf("hex bytes: ") for i := 0; i < len(placeOfInterest); i++ { fmt.Printf("%x ", placeOfInterest[i]) } fmt.Printf("\n") }
輸出是
plain string: ⌘
quoted string: "\u2318"
hex bytes: e2 8c 98
這讓我們記起 Unicode 字元值 U+2318,「景點」符號 ⌘,是由位元組 e2
8c
98
表示的,並且這些位元組是十六進制值 2318 的 UTF-8 編碼。
視你熟悉 UTF-8 與否,這點可能很明顯,或較難理解,不過還是值得花點時間說明如何建立字串的 UTF-8 呈現方式。簡單的說,就是當原始碼撰寫完成時就已建立完成。
Go 中的原始碼是定義為 UTF-8 文字,不允許其他呈現方式。這表示當我們在原始碼中撰寫文字時
`⌘`
用於建立程式碼的文字編輯器會將符號 ⌘ 的 UTF-8 編碼置於原始碼文字中。當我們印出十六進位元組時,我們只是剖析編輯器置於檔案中的資料。
簡而言之,Go 原始碼是 UTF-8,因此字串字面值的原始碼是 UTF-8 文字。如果那個字串字面值不包含跳脫序列(原始字串不能包含),建構的字串會包含引號間的完整原始碼文字。因此依定義和建構方式,原始字串的內容將永遠包含有效的 UTF-8 呈現方式。同樣地,除非它包含像上一節提到的那些會中斷 UTF-8 的跳脫字元,否則普通字串字面值也會永遠包含有效的 UTF-8。
有些人認為 Go 字串永遠是 UTF-8,但其實不然:只有字串字面值是 UTF-8。如同我們在前一節所示,字串值可以包含任意位元組;如同我們在此節所示,只要不具備位元組層級的跳脫字元,字串字面值永遠包含 UTF-8 文字。
總結來說,字串可以包含任意位元組,但字串字面值建構完成後,那些位元組會(幾乎)總是 UTF-8。
碼點、字元和符文
到目前為止,我們在使用「位元組」和「字元」字詞時都很謹慎小心。這部分是因為字串包含位元組,部分也是因為「字元」的概念有點難以定義。Unicode 標準使用「碼點」一詞來表示由單一值呈現的項目。碼點 U+2318,十六進位值 2318,指的是符號 ⌘。(有關那個碼點的更多資訊,請參閱其 Unicode 網頁:http://unicode.org/cldr/utility/character.jsp?a=2318。)
選擇一個較通俗易懂的範例來說明,Unicode 碼點 U+0061 是小寫拉丁字母 A:a。
但小寫揚抑符字母 A,à 呢?那是一個字元,同時也是一個碼點 (U+00E0),但它還有其他呈現方式。例如,我們可以使用「組合」揚抑符碼點 U+0300,並將它附加至小寫字母 a,U+0061,就能建立同一個字元 à。一般來說,一個字元可能由各種不同的碼點序列,以及因此而來的不同 UTF-8 位元組序列來呈現。
字元在計算中的概念難以捉摸,至少令人困惑,所以我們小心使用字元。為使事物的呈現依賴程度降低,我們採用正規化技術保證特定字元一直由相同的碼點所代表,不過此主題在此偏離題目太遠。後續的部落格文章會說明 Go 函式庫如何處理正規化。
「碼點」聽起來有點冗長,所以 Go 為此概念引入了較簡短的詞:rune。此術語出現在函式庫和原始程式碼中,其意思與「碼點」完全相同,僅有一個有趣的附加功能。
Go 語言將 rune
一詞定義為 int32
類型的別名,所以程式可以在整數值代表碼點時清楚。此外,您或許會想到字元常數在 Go 中稱為rune 常數。表達式
'⌘'
的類型和值為 rune
,整數值為 0x2318
。
總結來說,以下重點要點
- Go 原始程式碼總是 UTF-8。
- 字串存放任意位元組。
- 字串文字,不帶位元組層級的跳脫字元,總是存放合法的 UTF-8 順序。
- 這些順序表示 Unicode 碼點,稱為 runes。
- Go 不保證字串中的字元是正規的。
範圍迴圈
除了 Go 原始碼是 UTF-8 的公理式細節之外,Go 真的只有在對字串使用 for
range
迴圈時會特別處理 UTF-8。
我們已經看過常規 for
迴圈發生了什麼事。相對之下,for
range
迴圈會在每次反覆運算中解碼一個 UTF-8 編碼的 rune。每次迴圈遍歷,迴圈的索引都是目前 rune 的起始位置,單位是位元組,而碼點則是它的值。以下是一個範例,使用了另一個方便的 Printf
格式 %#U
,用於顯示碼點的 Unicode 值及它的列印代表
const nihongo = "日本語" for index, runeValue := range nihongo { fmt.Printf("%#U starts at byte position %d\n", runeValue, index) }
輸出顯示每個碼點如何佔用多個位元組
U+65E5 '日' starts at byte position 0
U+672C '本' starts at byte position 3
U+8A9E '語' starts at byte position 6
[練習:將一個無效的 UTF-8 位元組順序放入字串。(如何?)迴圈的反覆運算會發生什麼狀況?]
函式庫
Go 的標準函式庫提供強大的 UTF-8 文字詮釋功能。如果 for
range
迴圈無法滿足您的目的,您需要的設施很有可能由函式庫中的套件提供。
此類最重要的套件是 unicode/utf8
,其中包含可驗證、解組和重新組裝 UTF-8 字串的輔助 常式。以下是等同於上述 for
range
範例的程式,但使用該套件中的 DecodeRuneInString
函式來執行工作。函式的回傳值是符文及其在 UTF-8 編碼的位元組寬度。
const nihongo = "日本語" for i, w := 0, 0; i < len(nihongo); i += w { runeValue, width := utf8.DecodeRuneInString(nihongo[i:]) fmt.Printf("%#U starts at byte position %d\n", runeValue, i) w = width }
執行它以查看它是否執行相同動作。for
range
迴圈和 DecodeRuneInString
的定義可產生完全相同的反覆運算序列。
查看 unicode/utf8 套件的文件,以查看它提供的其他功能。
結論
回答一開始提出的問題:字串是由位元組建構的,所以索引它們會產生位元組,而非字元。字串甚至可能不包含字元。事實上,「字元」的定義模稜兩可,且嘗試透過定義字串是由字元組成來解決此模糊性將會是一個錯誤。
關於 Unicode、UTF-8 和多語言文字處理的世界還有更多可說明的地方,但可以留待另一篇文章了。就目前而言,我們希望您能更了解 Go 字串的行為,以及雖然它們可能包含任意位元組,但 UTF-8 是其設計的核心部分。
下一篇文章: Go 的四年時光
上一篇文章: 陣列、切片(和字串):附加的機制
網誌索引