以下文章翻譯自羅伯·派克發表在Go Blog的文章,文章中為讀者詳述了Go語言中字符串與我們經常提起的位元組、字符還有rune的關係和相互之間的不同。正如派克在文中所說
字符串這個話題對於一篇博客文章來說似乎太簡單了,但是要很好地使用它們,不僅需要了解它們的工作原理,還需要了解位元組,字符和 rune 的區別,以及 Unicode 和 UTF- 8,字符串和字符串直接量之間的區別,以及其他甚至更細微的區別。
原文地址: https://blog.golang.org/strings
文章篇幅還是挺長的,大家時間都很寶貴所以我先把文章探究的問題的結論放在前面,有時間的同學還是建議整篇讀一下。
rune
原文的語法、句式都很好學習Go 語言的同時還能加強一下英文閱讀推薦去讀英文原文,有翻譯不清楚的歡迎指正。
上一篇博客文章 使用許多示例說明了切片在其實現背後的機制,從而說明了切片在 Go 中的工作方式。以此為背景,本文會討論 Go 中的字符串。一開始會讓人覺得,字符串這個話題對於一篇博客文章來說似乎太簡單了,但是要很好地使用它們,不僅需要了解它們的工作原理,還需要了解位元組,字符和 rune 的區別,以及 Unicode 和 UTF- 8,字符串和字符串直接量之間的區別,以及其他甚至更細微的區別。
展開討論這個話題的一種方法是將其視為對以下常見問題的解答:「當我索引 Go 字符串時,在 n 個位置為什麼沒有得到第 n 個字符?」 如您所見,這個問題將我們引向了許多文本在現實世界中是如何工作的細節中。
獨立於 Go 語言之外,Joel Spolsky 的著名博客文章 絕對絕對是每個軟體開發人員絕對絕對肯定地了解 Unicode 和字符集 (無藉口!) 很好地介紹了這些問題的細節。他提出的許多觀點將在這裡進行闡述。
讓我們從一些基礎知識開始。
在 Go 中,字符串實際上是只讀的位元組切片。如果你完全不知道一個位元組切片是什麼以及它是如何工作的,請閱讀 上一篇博客文章 ; 我們在這裡假設你已經知道這些。
預先說明字符串可以包含任意位元組很重要,字符串沒有規定只能包含 Unicode 文本,UTF-8 文本或任何其他預定義格式。就字符串的內容而言,它完全相當於一個位元組切片。
下面一個字符串文字 (稍後將進一步介紹),該文字使用 .NN 表示法定義了一個包含某些特殊位元組值的字符串常量。 (當然,一個位元組的範圍是十六進位值 00 到 FF)。
const sample =「 .bd.b2.3d.bc.20.e2.8c.98」
由於字符串常量 sample 中的某些位元組不是有效的 ASCII,甚至不是有效的 UTF-8,因此直接列印字符串將產生詭異的輸出。下面使用簡單的列印語句列印 sample
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.", sample)
將其輸出與上面的輸出進行比較:
bdb23dbc20e28c98
一個不錯的技巧是在格式標記符中使用 「空格」 標誌,在 % 和 x 之間放置一個空格。然後將此處使用的格式字符串與上面的格式字符串進行比較,
fmt.Printf("% x.", sample)
注意位元組之間留有的空格,從而使結果不那麼難以理解:
bd b2 3d bc 20 e2 8c 98
還有一件事。 %q (帶引號) 動詞將轉義字符串中所有不可列印的位元組序列,會讓輸出無歧義。
fmt.Printf("%q.", sample)
當字符串的大部分為可理解文本,但有一些特殊的含義可以根除時,這個技巧很方便。它會輸出:
".bd.b2=.bc "
如果斜視一下,我們可以看到噪聲點中隱藏的是一個 ASCII 等號以及一個規則的空格,最後出現了著名的瑞典 「景點」 符號。該符號的 Unicode 值為 U + 2318,由空格後的位元組編碼為 UTF-8 (十六進位值 20 ): e2 8c 98 。
如果我們不熟悉字符串或對字符串中奇奇怪怪的值感到困惑,可以在 %q 動詞上使用 「加號」 標誌。此標誌使輸出在解釋 UTF-8 時不僅轉義不可列印的序列,而且還會轉義所有非 ASCII 位元組。結果是它輸出了格式正確的 UTF-8 的 Unicode 值,該值表示字符串中的非 ASCII 數據:
fmt.Printf("%+q.", sample)
使用這種格式時,瑞典符號的 Unicode 值顯示為 . 轉義符:
".bd.b2=.bc .2318"
在調試字符串的內容時,這些列印技巧會很有用,並且在下面的討論中使用也會很方便。值得指出的是,所有這些方法對於位元組切片的行為與對字符串的行為完全相同。
下面是我們已列出的所有列印選項的全集,以完整的程序形式呈現出來,您可以在瀏覽器中直接運行 (和編輯):
譯註:指的是在 go playground 的瀏覽器運行環境中。
package main
import "fmt"
func main() {
const sample = ".bd.b2.3d.bc.20.e2.8c.98"
fmt.Println("Println:")
fmt.Println(sample)
fmt.Println("Byte loop:")
for i := 0; i < len(sample); i++ {
fmt.Printf("%x ", sample[i])
}
fmt.Printf(".")
fmt.Println("Printf with %x:")
fmt.Printf("%x.", sample)
fmt.Println("Printf with % x:")
fmt.Printf("% x.", sample)
fmt.Println("Printf with %q:")
fmt.Printf("%q.", sample)
fmt.Println("Printf with %+q:")
fmt.Printf("%+q.", sample)
}
[練習:修改上面的示例,以使用一個位元組切片代替字符串。提示:使用轉換來創建切片。]
[練習:循環遍歷字符串在每個位元組上使用 %q 格式化標記符。看看輸出告訴您什麼?]
如我們所見,索引字符串會產生其位元組,而不是其字符:字符串只是一堆位元組。這意味著,當我們將字符存儲在字符串中時,將存儲其位元組表示。讓我們通過一個更容易控制的示例,看看這個過程是如何發生。
下面是一個簡單的程序,使用了三種不同的方式列印一個只有一個字符的字符串常量。一次作為普通字符串,一次是用引號括起來的純 ASCII 字符串,一次是十六進位的單個位元組。為避免混淆,我們創建了一個 「原始字符串」,並用反引號將其括起來,因此它只能包含文字文本。 (在上面的例子中我們已經見過,用雙引號括起來的常規字符串可以包含轉義序列。)
func main() {
const placeOfInterest = ``
fmt.Printf("plain string: ")
fmt.Printf("%s", placeOfInterest)
fmt.Printf(".")
fmt.Printf("quoted string: ")
fmt.Printf("%+q", placeOfInterest)
fmt.Printf(".")
fmt.Printf("hex bytes: ")
for i := 0; i < len(placeOfInterest); i++ {
fmt.Printf("%x ", placeOfInterest[i])
}
fmt.Printf(".")
}
輸出為:
plain string:
quoted string: ".2318"
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 文本。
有人認為 Go 字符串始終是 UTF-8 編碼格式的,但不是:只有字符串直接量才始終是 UTF-8 的。如上一節所示,字符串 值 可以包含任意位元組;就像我們在本文中所展示的那樣,字符串 literal 只要不包含位元組級轉義符,就始終包含 UTF-8 文本。
總而言之,字符串可以包含任意位元組,但是從字符串直接量構造字符串時,這些位元組 (幾乎總是) 是 UTF-8 的。
到目前為止,我們在使用 「位元組」 和 「字符」 這兩個詞時都非常小心。部分原因是字符串包含位元組,部分原因是 「字符」 的概念很難定義。 Unicode 標準使用術語 「碼點」 來指代由單個 Unicode 值表示的個體。具有十六進位值 2318 的碼點 U + 2318 表示符號。 (有關該碼點的更多信息,請參見 其 Unicode 頁面 。)
譯者註:是一個 Unicode 碼點,其 Unicode 值是 U2318
舉一個比較平淡的例子,Unicode 代碼點 U + 0061 是小寫拉丁字母 'A':
但是小寫的帶有重音符號的字母 'A' 怎麼辦?這是一個字符,它也是一個代碼點 (U + 00E0),但是它還有其他表示形式。例如,我們可以使用 「組合」 重音符號代碼點 U + 0300,並將其附加到小寫字母 a,U + 0061,以創建相同的字符 à。通常,字符可以由許多不同的代碼點序列表示,因此也可以由 UTF-8 位元組的不同序列表示。
因此,計算中的字符概念是模稜兩可的,或者至少是令人困惑的,因此我們謹慎使用它。為了使事情變得可靠,有 標準化 技術保證給定字符始終由相同的代碼點表示,但該主題目前離我們這篇博客的主題太遠了。稍後的博客文章將解釋 Go 庫如何解決規範化。
「碼點」 有點冗長,因此 Go 為該概念引入了一個較短的術語: rune 。該術語出現在庫和原始碼中,其含義與 「碼點」 完全相同。
Go 語言將單詞 rune 定義為類型 int32 的別名,因此當整數值表示碼點時,程序會很清晰。此外,你可能會認為是字符常量的常量在 Go 中稱為 rune 常量 。下面表達式的類型和值
''
是 rune ,它的整數值為 0x2318 。
總結一下,這是要點:
rune
除了關於 Go 原始碼為 UTF-8 的細節外,Go 確實有且只有一種特別對待 UTF-8 的方式,那就是在字符串上使用 for range 循環時。
我們已經看到了常規 for 循環會發生什麼。相比之下, range 循環在每次疊代中會解碼一個 UTF-8 編碼 rune。每次循環時,循環的索引都是當前 rune 的起始位置 (以位元組為單位),碼點是其值。這是使用另一個方便的 Printf 格式化占位符 %#U 格式化字符串的示例,該格式話輸出顯示了碼點的 Unicode 值及其列印表示形式:
const nihongo = "日本語"
for index, runeValue := range nihongo {
fmt.Printf("%#U starts at byte position %d.", 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 文本提供了強大的支持。如果用於 range 循環的 ` 不足以滿足您的目的,則庫中的軟體包可能會提供您需要的功能。
最重要的軟體包是 unicode / utf8 ,其中包含用於驗證,插解和重新組裝 UTF-8 字符串的幫助程序。這是一個相當於上面 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.", runeValue, i)
w = width
}
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.", runeValue, i)
w = width
}
運行它以查看其執行相同的操作。 range 循環和普通循環中使用 DecodeRuneInString 會產生完全相同的疊代序列。
請查看 文檔 中的 unicode / utf8 軟體包,以了解它提供了哪些其他功能。
現在回答開始時提出的問題:字符串是由位元組構建的,因此對它們進行索引將生成位元組,而不是字符。字符串甚至可能不包含字符。實際上,「字符」 的定義是模稜兩可的,試圖通過定義字符串是由字符組成這種說法來解決歧義是錯誤的。
關於 Unicode,UTF-8 和多語言文本處理還有很多話要說,但是它可以等待下一篇文章。現在,我們希望你對 Go 字符串的行為有更好的了解,儘管它們可能包含任意位元組,但 UTF-8 是其設計的核心部分。