Go 部落格
Go 中的字串、位元組、rune 和字元
引言
上一篇博文解釋了 Go 中切片(slice)的工作原理,並使用許多示例說明了其實現的機制。在此基礎上,本文討論 Go 中的字串。乍一看,字串似乎是一個過於簡單的話題,不值得寫一篇博文,但要用好字串,不僅需要理解它們的工作原理,還需要理解位元組(byte)、字元(character)和 rune 之間的區別,Unicode 和 UTF-8 之間的區別,字串(string)和字串字面量(string literal)之間的區別,以及其他更細微的區別。
探討這個話題的一種方式是將其視為回答常見問題:“當我透過索引 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 字元,直接列印字串會產生難看的輸出。簡單的 print 語句
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,在空格(十六進位制值 20
)之後的位元組中被編碼為 UTF-8:e2
8c
98
。
如果我們不熟悉或對字串中的奇怪值感到困惑,可以使用 %q
動詞的“加號”標誌。此標誌會導致輸出不僅轉義不可列印序列,還轉義任何非 ASCII 位元組,同時解析 UTF-8。結果是它會顯示字串中表示非 ASCII 資料的、格式正確的 UTF-8 的 Unicode 值
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 字元的帶引號字串,一次以十六進位制形式表示單個位元組。為了避免混淆,我們建立了一個用反引號括起來的“原始字串”(raw string),這樣它只能包含字面文字。(像上面所示,用雙引號括起來的普通字串可以包含轉義序列。)
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 編碼的。
碼點(Code points)、字元(characters)和 rune
到目前為止,我們在使用“byte”(位元組)和“character”(字元)這兩個詞時非常謹慎。這部分是因為字串儲存的是位元組,部分是因為“character”這個概念有點難以定義。Unicode 標準使用術語“code point”(碼點)來指代由單個值表示的項。碼點 U+2318,十六進位制值為 2318,代表符號 ⌘。(有關該碼點的更多資訊,請參閱其 Unicode 頁面。)
舉一個更通俗的例子,Unicode 碼點 U+0061 是拉丁小寫字母 ‘A’:a。
但是帶有重音符號的拉丁小寫字母 ‘A’,à 又如何呢?它是一個字元,也是一個碼點(U+00E0),但它還有其他表示形式。例如,我們可以使用“組合用”重音符號碼點 U+0300,並將其附加到小寫字母 a(U+0061)後面,從而建立相同的字元 à。一般來說,一個字元可能由多種不同的碼點序列表示,因此也可能由不同的 UTF-8 位元組序列表示。
因此,計算中的字元概念是模糊的,或者至少是令人困惑的,所以我們謹慎使用它。為了使事情可靠,有一些規範化技術可以保證給定的字元始終由相同的碼點表示,但這個主題目前會讓我們離題太遠。後續的博文將解釋 Go 標準庫如何處理規範化。
“Code point”(碼點)這個詞有點拗口,所以 Go 為這個概念引入了一個更短的術語:rune。這個術語出現在標準庫和原始碼中,其含義與“code point”完全相同,但有一個有趣的補充。
Go 語言將 rune
定義為 int32
型別的一個別名,這樣程式就可以清楚地知道一個整數值代表一個碼點。此外,你可能認為是字元常量(character constant)的東西在 Go 中稱為rune 常量(rune constant)。表示式
'⌘'
的型別是 rune
,整數值為 0x2318
。
總結一下,以下是重點:
- Go 原始碼始終是 UTF-8 編碼的。
- 字串儲存任意位元組。
- 字串字面量,在沒有位元組級別轉義的情況下,始終包含有效的 UTF-8 序列。
- 這些序列代表 Unicode 碼點,稱為 rune。
- Go 中不對字串中的字元進行規範化提供保證。
Range 迴圈
除了 Go 原始碼是 UTF-8 這一公理般的細節外,Go 處理 UTF-8 的特殊方式實際上只有一種,那就是在使用 for
range
迴圈遍歷字串時。
我們已經看到了普通 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
函式來完成工作。該函式的返回值是 rune 及其在 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 的四年
上一篇文章:陣列、切片(以及字串):'append' 的機制
部落格索引