Go 部落格

Go 中的字串、位元組、符文和字元

Rob Pike
2013 年 10 月 23 日

引言

上一篇部落格文章說明了切片在 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"

在偵錯字串內容時,了解這些列印技術很有用,並且在隨後的討論中會很方便。值得指出的是,所有這些方法對位元組分區的行為與對字串的行為完全相同。

這裡是我們列出的完整列印選項集,會以您可以直接在瀏覽器中執行(和編輯)的完整程式呈現


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

[練習:修改以上範例,以使用位元組分區代替字串。提示:使用轉換來建立分區。]

[練習:使用每個位元組的 %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。

碼點、字元和符文

到目前為止,我們在使用「位元組」和「字元」字詞時都很謹慎小心。這部分是因為字串包含位元組,部分也是因為「字元」的概念有點難以定義。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 值及它的列印代表


// 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 函式來執行工作。函式的回傳值是符文及其在 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 的四年時光
上一篇文章: 陣列、切片(和字串):附加的機制
網誌索引