Go語言之父帶你重新認識字符串、字節、rune和字符

以下文章翻譯自羅伯·派克發表在Go Blog的文章,文章中為讀者詳述了Go語言中字符串與我們經常提起的字節、字符還有rune的關係和相互之間的不同。正如派克在文中所說

字符串這個話題對於一篇博客文章來說似乎太簡單了,但是要很好地使用它們,不僅需要了解它們的工作原理,還需要了解字節,字符和 rune 的區別,以及 Unicode 和 UTF- 8,字符串和字符串直接量之間的區別,以及其他甚至更細微的區別。

原文地址: https://blog.golang.org/strings

文章篇幅還是挺長的,大家時間都很寶貴所以我先把文章探究的問題的結論放在前面,有時間的同學還是建議整篇讀一下。

<code>rune
/<code>

原文的語法、句式都很好學習Go 語言的同時還能加強一下英文閱讀推薦去讀英文原文,有翻譯不清楚的歡迎指正。

介紹

上一篇博客文章 使用許多示例說明了切片在其實現背後的機制,從而說明了切片在 Go 中的工作方式。以此為背景,本文會討論 Go 中的字符串。一開始會讓人覺得,字符串這個話題對於一篇博客文章來說似乎太簡單了,但是要很好地使用它們,不僅需要了解它們的工作原理,還需要了解字節,字符和 rune 的區別,以及 Unicode 和 UTF- 8,字符串和字符串直接量之間的區別,以及其他甚至更細微的區別。

展開討論這個話題的一種方法是將其視為對以下常見問題的解答:“當我索引 Go 字符串時,在 n 個位置為什麼沒有得到第 n 個字符?” 如您所見,這個問題將我們引向了許多文本在現實世界中是如何工作的細節中。

獨立於 Go 語言之外,Joel Spolsky 的著名博客文章 絕對絕對是每個軟件開發人員絕對絕對肯定地瞭解 Unicode 和字符集 (無藉口!) 很好地介紹了這些問題的細節。他提出的許多觀點將在這裡進行闡述。

什麼是字符串?

讓我們從一些基礎知識開始。

在 Go 中,字符串實際上是隻讀的字節切片。如果你完全不知道一個字節切片是什麼以及它是如何工作的,請閱讀 上一篇博客文章 ; 我們在這裡假設你已經知道這些。

預先說明字符串可以包含任意字節很重要,字符串沒有規定只能包含 Unicode 文本,UTF-8 文本或任何其他預定義格式。就字符串的內容而言,它完全相當於一個字節切片。

下面一個字符串文字 (稍後將進一步介紹),該文字使用 .NN 表示法定義了一個包含某些特殊字節值的字符串常量。 (當然,一個字節的範圍是十六進制值 00 到 FF)。

<code>const sample =“ .bd.b2.3d.bc.20.e2.8c.98”/<code>

打印字符串

由於字符串常量 sample 中的某些字節不是有效的 ASCII,甚至不是有效的 UTF-8,因此直接打印字符串將產生詭異的輸出。下面使用簡單的打印語句打印 sample

<code>fmt.Println(sample)/<code>

輸出這一堆亂碼(輸出會因運行環境不同而有所不同)

<code>��=� ⌘/<code>

要找出該字符串真正包含了什麼,我們需要將其分解並檢查每一部分。有幾種方法可以做到這一點。最明顯的是遍歷其內容並單獨取出每個字節,如以下 for 循環所示

<code>for i := 0; i < len(sample); i++ {
fmt.Printf("%x ", sample[i])
}/<code>

如前所述,索引字符串訪問的是單個字節,而不是字符。我們將在下面詳細討論該主題。現在,讓我們關注點保持在字節上。下面是逐字節循環的輸出:

<code>bd b2 3d bc 20 e2 8c 98/<code>

注意各個字節與定義字符串的十六進制轉義符匹配是如此地匹配。

為混亂的字符串生成可顯示的輸出的一種較短方法是使用 fmt.Printf 的 %x (十六進制) 格式標記符(或者叫格式動詞)。它只是將字符串的字節按順序轉換為十六進制數字,每個字節兩個。

<code>fmt.Printf("%x.", sample)/<code>

將其輸出與上面的輸出進行比較:

<code>bdb23dbc20e28c98/<code>

一個不錯的技巧是在格式標記符中使用 “空格” 標誌,在 % 和 x 之間放置一個空格。然後將此處使用的格式字符串與上面的格式字符串進行比較,

<code>fmt.Printf("% x.", sample)/<code>

注意字節之間留有的空格,從而使結果不那麼難以理解:

<code>bd b2 3d bc 20 e2 8c 98/<code>

還有一件事。 %q (帶引號) 動詞將轉義字符串中所有不可打印的字節序列,會讓輸出無歧義。

<code>fmt.Printf("%q.", sample)/<code>

當字符串的大部分為可理解文本,但有一些特殊的含義可以根除時,這個技巧很方便。它會輸出:

<code>".bd.b2=.bc ⌘"/<code>

如果斜視一下,我們可以看到噪聲點中隱藏的是一個 ASCII 等號以及一個規則的空格,最後出現了著名的瑞典 “景點” 符號。該符號的 Unicode 值為 U + 2318,由空格後的字節編碼為 UTF-8 (十六進制值 20 ): e2 8c 98 。

如果我們不熟悉字符串或對字符串中奇奇怪怪的值感到困惑,可以在 %q 動詞上使用 “加號” 標誌。此標誌使輸出在解釋 UTF-8 時不僅轉義不可打印的序列,而且還會轉義所有非 ASCII 字節。結果是它輸出了格式正確的 UTF-8 的 Unicode 值,該值表示字符串中的非 ASCII 數據:

<code>fmt.Printf("%+q.", sample)/<code>

使用這種格式時,瑞典符號的 Unicode 值顯示為 . 轉義符:

<code>".bd.b2=.bc .2318"/<code>

在調試字符串的內容時,這些打印技巧會很有用,並且在下面的討論中使用也會很方便。值得指出的是,所有這些方法對於字節切片的行為與對字符串的行為完全相同。

下面是我們已列出的所有打印選項的全集,以完整的程序形式呈現出來,您可以在瀏覽器中直接運行 (和編輯):

譯註:指的是在 go playground 的瀏覽器運行環境中。

<code>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)
}/<code>

[練習:修改上面的示例,以使用一個字節切片代替字符串。提示:使用轉換來創建切片。]

[練習:循環遍歷字符串在每個字節上使用 %q 格式化標記符。看看輸出告訴您什麼?]

UTF-8和字符串直接量

如我們所見,索引字符串會產生其字節,而不是其字符:字符串只是一堆字節。這意味著,當我們將字符存儲在字符串中時,將存儲其字節表示。讓我們通過一個更容易控制的示例,看看這個過程是如何發生。

下面是一個簡單的程序,使用了三種不同的方式打印一個只有一個字符的字符串常量。一次作為普通字符串,一次是用引號括起來的純 ASCII 字符串,一次是十六進制的單個字節。為避免混淆,我們創建了一個 “原始字符串”,並用反引號將其括起來,因此它只能包含文字文本。 (在上面的例子中我們已經見過,用雙引號括起來的常規字符串可以包含轉義序列。)

<code>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(".")
}/<code>

輸出為:

<code>plain string: ⌘
quoted string: ".2318"
hex bytes: e2 8c 98/<code>

這使我們想起 Unicode 字符值 U + 2318,即 ⌘ ,由字節 e2 8c 98 表示,並且這些字節是十六進制值 2318 的 UTF-8 編碼。

根據你對 UTF-8 的熟悉程度,上面的結果對你來說可能很明顯,也可能很微妙,但是這值得花一點時間來解釋字符串的 UTF-8 表示形式是如何被創建。一個簡單的事實是:它是在編寫源代碼時創建的。

Go 中的源代碼被 定義 為 UTF-8 文本;其他字符串表示形式是不被循序的。這意味著當我們在源代碼中編寫文本時

<code>⌘`/<code>

用於創建程序的文本編輯器將符號⌘的 UTF-8 編碼放入源文本中。當我們打印出十六進制字節時,我們只是在輸出了編輯器放置在源碼文件中的數據。

簡而言之,Go 源代碼為 UTF-8 編碼格式的,源代碼中的字符串直接量是 UTF-8 文本。如果字符串直接量不包含轉移字符序列,就像原始字符串一樣,則構造的字符串將精確地保留引號之間的源文本。因此,根據定義和構造,原始字符串將始終包含其內容的有效 UTF-8 表示形式。同樣,除非它包含上一節中提到的轉義符,否則常規字符串文字也將始終包含有效的 UTF-8 文本。

有人認為 Go 字符串始終是 UTF-8 編碼格式的,但不是:只有字符串直接量才始終是 UTF-8 的。如上一節所示,字符串 值 可以包含任意字節;就像我們在本文中所展示的那樣,字符串 literal 只要不包含字節級轉義符,就始終包含 UTF-8 文本。

總而言之,字符串可以包含任意字節,但是從字符串直接量構造字符串時,這些字節 (幾乎總是) 是 UTF-8 的。

碼點,字符和 rune

到目前為止,我們在使用 “字節” 和 “字符” 這兩個詞時都非常小心。部分原因是字符串包含字節,部分原因是 “字符” 的概念很難定義。 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 常量 。下面表達式的類型和值

<code>'⌘'/<code>

是 rune ,它的整數值為 0x2318 。

總結一下,這是要點:

<code>rune
/<code>

Range 循環

除了關於 Go 源代碼為 UTF-8 的細節外,Go 確實有且只有一種特別對待 UTF-8 的方式,那就是在字符串上使用 for range 循環時。

我們已經看到了常規 for 循環會發生什麼。相比之下, range 循環在每次迭代中會解碼一個 UTF-8 編碼 rune。每次循環時,循環的索引都是當前 rune 的起始位置 (以字節為單位),碼點是其值。這是使用另一個方便的 Printf 格式化佔位符 %#U 格式化字符串的示例,該格式話輸出顯示了碼點的 Unicode 值及其打印表示形式:

<code>const nihongo = "日本語"
for index, runeValue := range nihongo {
fmt.Printf("%#U starts at byte position %d.", runeValue, index)
}/<code>

輸出顯示每個碼點會佔用多個字節:

<code>U+65E5 '日' starts at byte position 0
U+672C '本' starts at byte position 3
U+8A9E '語' starts at byte position 6/<code>

[練習:將無效的 UTF-8 字節序列放入字符串中。 循環的迭代會發生什麼?]

Go 的標準庫為解釋 UTF-8 文本提供了強大的支持。如果用於 range 循環的 ` 不足以滿足您的目的,則庫中的軟件包可能會提供您需要的功能。

最重要的軟件包是 unicode / utf8 ,其中包含用於驗證,插解和重新組裝 UTF-8 字符串的幫助程序。這是一個相當於上面 range 示例的程序,但是使用該包中的 DecodeRuneInString 函數進行工作。該函數的返回值是 rune 及其寬度 (以 UTF-8 編碼的字節)。

<code>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
}/<code>
Go語言之父帶你重新認識字符串、字節、rune和字符

<code>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
}/<code>

運行它以查看其執行相同的操作。 range 循環和普通循環中使用 DecodeRuneInString 會產生完全相同的迭代序列。

請查看 文檔 中的 unicode / utf8 軟件包,以瞭解它提供了哪些其他功能。

結論

現在回答開始時提出的問題:字符串是由字節構建的,因此對它們進行索引將生成字節,而不是字符。字符串甚至可能不包含字符。實際上,“字符” 的定義是模稜兩可的,試圖通過定義字符串是由字符組成這種說法來解決歧義是錯誤的。

關於 Unicode,UTF-8 和多語言文本處理還有很多話要說,但是它可以等待下一篇文章。現在,我們希望你對 Go 字符串的行為有更好的瞭解,儘管它們可能包含任意字節,但 UTF-8 是其設計的核心部分。


分享到:


相關文章: