Go 部落格

Go 語言中的字串、位元組、rune 和字元

Rob Pike
2013 年 10 月 23 日

引言

上一篇 博文 解釋了 Go 語言中 slice 的工作原理,並透過多個示例說明了其實現機制。基於此背景,本文將討論 Go 語言中的字串。起初,字串可能顯得過於簡單,不至於需要一篇博文來講解,但要用好它們,就需要理解它們的工作原理,以及位元組、字元和 rune 之間的區別,Unicode 和 UTF-8 之間的區別,字串和字串字面量之間的區別,以及其他更細微的差別。

理解這個主題的一種方法,可以將其視為對一個常見問題的回答:“為什麼當我索引 Go 字串的第 n 個位置時,得不到第 n 個字元?” 正如你將看到的,這個問題會引出許多關於現代世界中文字如何工作的細節。

一個極好的介紹這些問題的獨立於 Go 的資源,是 Joel Spolsky 的著名博文 《每個軟體開發人員絕對、肯定必須知道的關於 Unicode 和字元集的最低限度(無藉口!)》。他提出的許多觀點在這裡也會被提及。

什麼是字串?

讓我們從一些基本概念開始。

在 Go 語言中,字串本質上是一個只讀的位元組 slice。如果你對位元組 slice 是什麼或它是如何工作的還不確定,請閱讀 上一篇博文;我們這裡將假設你已閱讀。

需要一開始就明確的是,字串包含任意位元組。它不一定包含 Unicode 文字、UTF-8 文字或任何其他預定義格式。就字串的內容而言,它與位元組 slice 完全等價。

這是一個字串字面量(稍後會詳細介紹),它使用了 `\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,在空格(十六進位制值 `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"

瞭解這些列印技術對於除錯字串內容非常有用,並且在接下來的討論中也會派上用場。值得指出的是,所有這些方法對於位元組 slice 和字串的處理方式完全相同。

以下是我們列出的所有列印選項,以一個完整的程式形式呈現,你可以在瀏覽器中直接執行(並編輯)


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.


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

[練習:修改上面的示例,使用位元組 slice 代替字串。提示:使用轉換來建立 slice。]

[練習:使用 `%q` 格式迴圈遍歷字串中的每個位元組。輸出告訴你什麼?]

UTF-8 和字串字面量

正如我們所見,索引字串會得到其位元組,而不是其字元:字串只是位元組的集合。這意味著當我們把一個字元值儲存在字串中時,我們儲存的是它的逐位元組表示。讓我們看一個更受控的例子,看看它是如何發生的。

這是一個簡單的程式,它以三種不同的方式列印一個包含單個字元的字串常量:一次作為普通字串,一次作為僅 ASCII 的帶引號字串,一次作為十六進位制的單個位元組。為避免任何混淆,我們建立了一個“原始字串”,用反引號括起來,因此它只能包含字面文字。(如上所示,用雙引號括起來的常規字串可以包含轉義序列。)


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import "fmt"


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。

碼點、字元和 rune

到目前為止,我們在使用“位元組”和“字元”這兩個詞時非常謹慎。這部分是因為字串包含位元組,部分是因為“字元”這個概念有點難以定義。Unicode 標準使用“碼點”一詞來指代由單個值表示的項。碼點 U+2318,十六進位制值為 2318,表示符號 ⌘。(有關該碼點的更多資訊,請參閱 其 Unicode 頁面。)

舉一個更平凡的例子,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 碼點,稱為 rune。
  • Go 語言不保證字串中的字元是規範化的。

Range 迴圈

除了 Go 原始碼是 UTF-8 的基本細節之外,Go 語言真正特殊處理 UTF-8 的方式只有一種,那就是在使用字串的 `for range` 迴圈時。

我們已經看到了常規 `for` 迴圈的情況。相比之下,`for range` 迴圈在每次迭代時都會解碼一個 UTF-8 編碼的 rune。每次迴圈時,迴圈的索引是當前 rune 的起始位置(以位元組為單位),而 code point 是其值。這裡有一個例子,使用了另一個方便的 `Printf` 格式 `%#U`,它顯示了碼點的 Unicode 值及其打印表示


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import "fmt"

func main() {

    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 編碼位元組中的寬度。


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
    "unicode/utf8"
)

func main() {

    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 的機制
部落格索引