3.5. 字符串
一個字符串是一個不可改變的字節序列。字符串可以包含任意的數據,包括byte值0,但是通常是用來包含人類可讀的文本。文本字符串通常被解釋為採用UTF8編碼的Unicode碼點(rune)序列,我們稍後會詳細討論這個問題。
內置的len函數可以返回一個字符串中的字節數目(不是rune字符數目),索引操作s[i]返回第i個字節的字節值,i必須滿足0 ≤ i< len(s)條件約束。
s := "hello, world"
fmt.Println(len(s)) // "12"
fmt.Println(s[0], s[7]) // "104 119" ('h' and 'w')
如果試圖訪問超出字符串索引範圍的字節將會導致panic異常:
c := s[len(s)] // panic: index out of range
第i個字節並不一定是字符串的第i個字符,因為對於非ASCII字符的UTF8編碼會要兩個或多個字節。我們先簡單說下字符的工作方式。
子字符串操作s[i:j]基於原始的s字符串的第i個字節開始到第j個字節(並不包含j本身)生成一個新字符串。生成的新字符串將包含j-i個字節。
fmt.Println(s[0:5]) // "hello"
同樣,如果索引超出字符串範圍或者j小於i的話將導致panic異常。
不管i還是j都可能被忽略,當它們被忽略時將採用0作為開始位置,採用len(s)作為結束的位置。
fmt.Println(s[:5]) // "hello"
fmt.Println(s[7:]) // "world"
fmt.Println(s[:]) // "hello, world"
其中+操作符將兩個字符串連接構造一個新字符串:
fmt.Println("goodbye" + s[5:]) // "goodbye, world"
字符串可以用==和<進行比較;比較通過逐個字節比較完成的,因此比較的結果是字符串自然編碼的順序。
字符串的值是不可變的:一個字符串包含的字節序列永遠不會被改變,當然我們也可以給一個字符串變量分配一個新字符串值。可以像下面這樣將一個字符串追加到另一個字符串:
s := "left foot"
t := s
s += ", right foot"
這並不會導致原始的字符串值被改變,但是變量s將因為+=語句持有一個新的字符串值,但是t依然是包含原先的字符串值。
fmt.Println(s) // "left foot, right foot"
fmt.Println(t) // "left foot"
因為字符串是不可修改的,因此嘗試修改字符串內部數據的操作也是被禁止的:
s[0] = 'L' // compile error: cannot assign to s[0]
不變性意味著如果兩個字符串共享相同的底層數據的話也是安全的,這使得複製任何長度的字符串代價是低廉的。同樣,一個字符串s和對應的子字符串切片s[7:]的操作也可以安全地共享相同的內存,因此字符串切片操作代價也是低廉的。在這兩種情況下都沒有必要分配新的內存。 圖3.4演示了一個字符串和兩個子串共享相同的底層數據。
3.5.1. 字符串面值
字符串值也可以用字符串面值方式編寫,只要將一系列字節序列包含在雙引號內即可:
"Hello, 世界"
因為Go語言源文件總是用UTF8編碼,並且Go語言的文本字符串也以UTF8編碼的方式處理,因此我們可以將Unicode碼點也寫到字符串面值中。
在一個雙引號包含的字符串面值中,可以用以反斜槓\
開頭的轉義序列插入任意的數據。下面的換行、回車和製表符等是常見的ASCII控制代碼的轉義方式:
\a 響鈴
\b 退格
\f 換頁
\n 換行
\r 回車
\t 製表符
\v 垂直製表符
\' 單引號(只用在 '\'' 形式的rune符號面值中)
\" 雙引號(只用在 "..." 形式的字符串面值中)
\\ 反斜槓
可以通過十六進制或八進制轉義在字符串面值中包含任意的字節。一個十六進制的轉義形式是\xhh
,其中兩個h表示十六進制數字(大寫或小寫都可以)。一個八進制轉義形式是\ooo
,包含三個八進制的o數字(0到7),但是不能超過\377
(譯註:對應一個字節的範圍,十進制為255)。每一個單一的字節表達一個特定的值。稍後我們將看到如何將一個Unicode碼點寫到字符串面值中。
一個原生的字符串面值形式是`...`,使用反引號代替雙引號。在原生的字符串面值中,沒有轉義操作;全部的內容都是字面的意思,包含退格和換行,因此一個程序中的原生字符串面值可能跨越多行(譯註:在原生字符串面值內部是無法直接寫`字符的,可以用八進制或十六進制轉義或+"`"連接字符串常量完成)。唯一的特殊處理是會刪除回車以保證在所有平臺上的值都是一樣的,包括那些把回車也放入文本文件的系統(譯註:Windows系統會把回車和換行一起放入文本文件中)。
原生字符串面值用於編寫正則表達式會很方便,因為正則表達式往往會包含很多反斜槓。原生字符串面值同時被廣泛應用於HTML模板、JSON面值、命令行提示信息以及那些需要擴展到多行的場景。
const GoUsage = `Go is a tool for managing Go source code.
Usage:
go command [arguments]
...`
3.5.2. Unicode
在很久以前,世界還是比較簡單的,起碼計算機世界就只有一個ASCII字符集:美國信息交換標準代碼。ASCII,更準確地說是美國的ASCII,使用7bit來表示128個字符:包含英文字母的大小寫、數字、各種標點符號和設備控制符。對於早期的計算機程序來說,這些就足夠了,但是這也導致了世界上很多其他地區的用戶無法直接使用自己的符號系統。隨著互聯網的發展,混合多種語言的數據變得很常見(譯註:比如本身的英文原文或中文翻譯都包含了ASCII、中文、日文等多種語言字符)。如何有效處理這些包含了各種語言的豐富多樣的文本數據呢?
答案就是使用Unicode( http://unicode.org ),它收集了這個世界上所有的符號系統,包括重音符號和其它變音符號,製表符和回車符,還有很多神祕的符號,每個符號都分配一個唯一的Unicode碼點,Unicode碼點對應Go語言中的rune整數類型(譯註:rune是int32等價類型)。
在第八版本的Unicode標準裡收集了超過120,000個字符,涵蓋超過100多種語言。這些在計算機程序和數據中是如何體現的呢?通用的表示一個Unicode碼點的數據類型是int32,也就是Go語言中rune對應的類型;它的同義詞rune符文正是這個意思。
我們可以將一個符文序列表示為一個int32序列。這種編碼方式叫UTF-32或UCS-4,每個Unicode碼點都使用同樣大小的32bit來表示。這種方式比較簡單統一,但是它會浪費很多存儲空間,因為大多數計算機可讀的文本是ASCII字符,本來每個ASCII字符只需要8bit或1字節就能表示。而且即使是常用的字符也遠少於65,536個,也就是說用16bit編碼方式就能表達常用字符。但是,還有其它更好的編碼方法嗎?
3.5.3. UTF-8
UTF8是一個將Unicode碼點編碼為字節序列的變長編碼。UTF8編碼是由Go語言之父Ken Thompson和Rob Pike共同發明的,現在已經是Unicode的標準。UTF8編碼使用1到4個字節來表示每個Unicode碼點,ASCII部分字符只使用1個字節,常用字符部分使用2或3個字節表示。每個符號編碼後第一個字節的高端bit位用於表示編碼總共有多少個字節。如果第一個字節的高端bit為0,則表示對應7bit的ASCII字符,ASCII字符每個字符依然是一個字節,和傳統的ASCII編碼兼容。如果第一個字節的高端bit是110,則說明需要2個字節;後續的每個高端bit都以10開頭。更大的Unicode碼點也是採用類似的策略處理。
0xxxxxxx runes 0-127 (ASCII)
110xxxxx 10xxxxxx 128-2047 (values <128 unused)
1110xxxx 10xxxxxx 10xxxxxx 2048-65535 (values <2048 unused)
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 65536-0x10ffff (other values unused)
變長的編碼無法直接通過索引來訪問第n個字符,但是UTF8編碼獲得了很多額外的優點。首先UTF8編碼比較緊湊,完全兼容ASCII碼,並且可以自動同步:它可以通過向前回朔最多3個字節就能確定當前字符編碼的開始字節的位置。它也是一個前綴編碼,所以當從左向右解碼時不會有任何歧義也並不需要向前查看(譯註:像GBK之類的編碼,如果不知道起點位置則可能會出現歧義)。沒有任何字符的編碼是其它字符編碼的子串,或是其它編碼序列的字串,因此搜索一個字符時只要搜索它的字節編碼序列即可,不用擔心前後的上下文會對搜索結果產生干擾。同時UTF8編碼的順序和Unicode碼點的順序一致,因此可以直接排序UTF8編碼序列。同時因為沒有嵌入的NUL(0)字節,可以很好地兼容那些使用NUL作為字符串結尾的編程語言。
Go語言的源文件採用UTF8編碼,並且Go語言處理UTF8編碼的文本也很出色。unicode包提供了諸多處理rune字符相關功能的函數(比如區分字母和數字,或者是字母的大寫和小寫轉換等),unicode/utf8包則提供了用於rune字符序列的UTF8編碼和解碼的功能。
有很多Unicode字符很難直接從鍵盤輸入,並且還有很多字符有著相似的結構;有一些甚至是不可見的字符(譯註:中文和日文就有很多相似但不同的字)。Go語言字符串面值中的Unicode轉義字符讓我們可以通過Unicode碼點輸入特殊的字符。有兩種形式:\uhhhh
對應16bit的碼點值,\Uhhhhhhhh
對應32bit的碼點值,其中h是一個十六進制數字;一般很少需要使用32bit的形式。每一個對應碼點的UTF8編碼。例如:下面的字母串面值都表示相同的值:
"世界"
"\xe4\xb8\x96\xe7\x95\x8c"
"\u4e16\u754c"
"\U00004e16\U0000754c"
上面三個轉義序列都為第一個字符串提供替代寫法,但是它們的值都是相同的。
Unicode轉義也可以使用在rune字符中。下面三個字符是等價的:
'世' '\u4e16' '\U00004e16'
對於小於256的碼點值可以寫在一個十六進制轉義字節中,例如\x41
對應字符'A',但是對於更大的碼點則必須使用\u
或\U
轉義形式。因此,\xe4\xb8\x96
並不是一個合法的rune字符,雖然這三個字節對應一個有效的UTF8編碼的碼點。
得益於UTF8編碼優良的設計,諸多字符串操作都不需要解碼操作。我們可以不用解碼直接測試一個字符串是否是另一個字符串的前綴:
func HasPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
或者是後綴測試:
func HasSuffix(s, suffix string) bool {
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}
或者是包含子串測試:
func Contains(s, substr string) bool {
for i := 0; i < len(s); i++ {
if HasPrefix(s[i:], substr) {
return true
}
}
return false
}
對於UTF8編碼後文本的處理和原始的字節處理邏輯是一樣的。但是對應很多其它編碼則並不是這樣的。(上面的函數都來自strings字符串處理包,真實的代碼包含了一個用哈希技術優化的Contains 實現。)
另一方面,如果我們真的關心每個Unicode字符,我們可以使用其它處理方式。考慮前面的第一個例子中的字符串,它混合了中西兩種字符。圖3.5展示了它的內存表示形式。字符串包含13個字節,以UTF8形式編碼,但是隻對應9個Unicode字符:
import "unicode/utf8"
s := "Hello, 世界"
fmt.Println(len(s)) // "13"
fmt.Println(utf8.RuneCountInString(s)) // "9"
為了處理這些真實的字符,我們需要一個UTF8解碼器。unicode/utf8包提供了該功能,我們可以這樣使用:
for i := 0; i < len(s); {
r, size := utf8.DecodeRuneInString(s[i:])
fmt.Printf("%d\t%c\n", i, r)
i += size
}
每一次調用DecodeRuneInString函數都返回一個r和長度,r對應字符本身,長度對應r採用UTF8編碼後的編碼字節數目。長度可以用於更新第i個字符在字符串中的字節索引位置。但是這種編碼方式是笨拙的,我們需要更簡潔的語法。幸運的是,Go語言的range循環在處理字符串的時候,會自動隱式解碼UTF8字符串。下面的循環運行如圖3.5所示;需要注意的是對於非ASCII,索引更新的步長將超過1個字節。
for i, r := range "Hello, 世界" {
fmt.Printf("%d\t%q\t%d\n", i, r, r)
}
我們可以使用一個簡單的循環來統計字符串中字符的數目,像這樣:
n := 0
for _, _ = range s {
n++
}
像其它形式的循環那樣,我們也可以忽略不需要的變量:
n := 0
for range s {
n++
}
或者我們可以直接調用utf8.RuneCountInString(s)函數。
正如我們前面提到的,文本字符串採用UTF8編碼只是一種慣例,但是對於循環的真正字符串並不是一個慣例,這是正確的。如果用於循環的字符串只是一個普通的二進制數據,或者是含有錯誤編碼的UTF8數據,將會發生什麼呢?
每一個UTF8字符解碼,不管是顯式地調用utf8.DecodeRuneInString解碼或是在range循環中隱式地解碼,如果遇到一個錯誤的UTF8編碼輸入,將生成一個特別的Unicode字符\uFFFD
,在印刷中這個符號通常是一個黑色六角或鑽石形狀,裡面包含一個白色的問號"?"。當程序遇到這樣的一個字符,通常是一個危險信號,說明輸入並不是一個完美沒有錯誤的UTF8字符串。
UTF8字符串作為交換格式是非常方便的,但是在程序內部採用rune序列可能更方便,因為rune大小一致,支持數組索引和方便切割。
將[]rune類型轉換應用到UTF8編碼的字符串,將返回字符串編碼的Unicode碼點序列:
// "program" in Japanese katakana
s := "プログラム"
fmt.Printf("% x\n", s) // "e3 83 97 e3 83 ad e3 82 b0 e3 83 a9 e3 83 a0"
r := []rune(s)
fmt.Printf("%x\n", r) // "[30d7 30ed 30b0 30e9 30e0]"
(在第一個Printf中的% x
參數用於在每個十六進制數字前插入一個空格。)
如果是將一個[]rune類型的Unicode字符slice或數組轉為string,則對它們進行UTF8編碼:
fmt.Println(string(r)) // "プログラム"
將一個整數轉型為字符串意思是生成以只包含對應Unicode碼點字符的UTF8字符串:
fmt.Println(string(65)) // "A", not "65"
fmt.Println(string(0x4eac)) // "京"
如果對應碼點的字符是無效的,則用\uFFFD
無效字符作為替換:
fmt.Println(string(1234567)) // "?"
3.5.4. 字符串和Byte切片
標準庫中有四個包對字符串處理尤為重要:bytes、strings、strconv和unicode包。strings包提供了許多如字符串的查詢、替換、比較、截斷、拆分和合並等功能。
bytes包也提供了很多類似功能的函數,但是針對和字符串有著相同結構的[]byte類型。因為字符串是隻讀的,因此逐步構建字符串會導致很多分配和複製。在這種情況下,使用bytes.Buffer類型將會更有效,稍後我們將展示。
strconv包提供了布爾型、整型數、浮點數和對應字符串的相互轉換,還提供了雙引號轉義相關的轉換。
unicode包提供了IsDigit、IsLetter、IsUpper和IsLower等類似功能,它們用於給字符分類。每個函數有一個單一的rune類型的參數,然後返回一個布爾值。而像ToUpper和ToLower之類的轉換函數將用於rune字符的大小寫轉換。所有的這些函數都是遵循Unicode標準定義的字母、數字等分類規範。strings包也有類似的函數,它們是ToUpper和ToLower,將原始字符串的每個字符都做相應的轉換,然後返回新的字符串。
下面例子的basename函數靈感源於Unix shell的同名工具。在我們實現的版本中,basename(s)將看起來像是系統路徑的前綴刪除,同時將看似文件類型的後綴名部分刪除:
fmt.Println(basename("a/b/c.go")) // "c"
fmt.Println(basename("c.d.go")) // "c.d"
fmt.Println(basename("abc")) // "abc"
第一個版本並沒有使用任何庫,全部手工硬編碼實現:
gopl.io/ch3/basename1
// basename removes directory components and a .suffix.
// e.g., a => a, a.go => a, a/b/c.go => c, a/b.c.go => b.c
func basename(s string) string {
// Discard last '/' and everything before.
for i := len(s) - 1; i >= 0; i-- {
if s[i] == '/' {
s = s[i+1:]
break
}
}
// Preserve everything before last '.'.
for i := len(s) - 1; i >= 0; i-- {
if s[i] == '.' {
s = s[:i]
break
}
}
return s
}
這個簡化版本使用了strings.LastIndex庫函數:
gopl.io/ch3/basename2
func basename(s string) string {
slash := strings.LastIndex(s, "/") // -1 if "/" not found
s = s[slash+1:]
if dot := strings.LastIndex(s, "."); dot >= 0 {
s = s[:dot]
}
return s
}
path和path/filepath包提供了關於文件路徑名更一般的函數操作。使用斜槓分隔路徑可以在任何操作系統上工作。斜槓本身不應該用於文件名,但是在其他一些領域可能會用於文件名,例如URL路徑組件。相比之下,path/filepath包則使用操作系統本身的路徑規則,例如POSIX系統使用/foo/bar,而Microsoft Windows使用c:\foo\bar
等。
讓我們繼續另一個字符串的例子。函數的功能是將一個表示整數值的字符串,每隔三個字符插入一個逗號分隔符,例如“12345”處理後成為“12,345”。這個版本只適用於整數類型;支持浮點數類型的留作練習。
gopl.io/ch3/comma
// comma inserts commas in a non-negative decimal integer string.
func comma(s string) string {
n := len(s)
if n <= 3 {
return s
}
return comma(s[:n-3]) + "," + s[n-3:]
}
輸入comma函數的參數是一個字符串。如果輸入字符串的長度小於或等於3的話,則不需要插入逗號分隔符。否則,comma函數將在最後三個字符前的位置將字符串切割為兩個子串並插入逗號分隔符,然後通過遞歸調用自身來得出前面的子串。
一個字符串是包含只讀字節的數組,一旦創建,是不可變的。相比之下,一個字節slice的元素則可以自由地修改。
字符串和字節slice之間可以相互轉換:
s := "abc"
b := []byte(s)
s2 := string(b)
從概念上講,一個[]byte(s)轉換是分配了一個新的字節數組用於保存字符串數據的拷貝,然後引用這個底層的字節數組。編譯器的優化可以避免在一些場景下分配和複製字符串數據,但總的來說需要確保在變量b被修改的情況下,原始的s字符串也不會改變。將一個字節slice轉換到字符串的string(b)操作則是構造一個字符串拷貝,以確保s2字符串是隻讀的。
為了避免轉換中不必要的內存分配,bytes包和strings同時提供了許多實用函數。下面是strings包中的六個函數:
func Contains(s, substr string) bool
func Count(s, sep string) int
func Fields(s string) []string
func HasPrefix(s, prefix string) bool
func Index(s, sep string) int
func Join(a []string, sep string) string
bytes包中也對應的六個函數:
func Contains(b, subslice []byte) bool
func Count(s, sep []byte) int
func Fields(s []byte) [][]byte
func HasPrefix(s, prefix []byte) bool
func Index(s, sep []byte) int
func Join(s [][]byte, sep []byte) []byte
它們之間唯一的區別是字符串類型參數被替換成了字節slice類型的參數。
bytes包還提供了Buffer類型用於字節slice的緩存。一個Buffer開始是空的,但是隨著string、byte或[]byte等類型數據的寫入可以動態增長,一個bytes.Buffer變量並不需要初始化,因為零值也是有效的:
gopl.io/ch3/printints
// intsToString is like fmt.Sprint(values) but adds commas.
func intsToString(values []int) string {
var buf bytes.Buffer
buf.WriteByte('[')
for i, v := range values {
if i > 0 {
buf.WriteString(", ")
}
fmt.Fprintf(&buf, "%d", v)
}
buf.WriteByte(']')
return buf.String()
}
func main() {
fmt.Println(intsToString([]int{1, 2, 3})) // "[1, 2, 3]"
}
當向bytes.Buffer添加任意字符的UTF8編碼時,最好使用bytes.Buffer的WriteRune方法,但是WriteByte方法對於寫入類似'['和']'等ASCII字符則會更加有效。
bytes.Buffer類型有著很多實用的功能,我們在第七章討論接口時將會涉及到,我們將看看如何將它用作一個I/O的輸入和輸出對象,例如當做Fprintf的io.Writer輸出對象,或者當作io.Reader類型的輸入源對象。
練習 3.10: 編寫一個非遞歸版本的comma函數,使用bytes.Buffer代替字符串鏈接操作。
練習 3.11: 完善comma函數,以支持浮點數處理和一個可選的正負號的處理。
練習 3.12: 編寫一個函數,判斷兩個字符串是否是相互打亂的,也就是說它們有著相同的字符,但是對應不同的順序。
3.5.5. 字符串和數字的轉換
除了字符串、字符、字節之間的轉換,字符串和數值之間的轉換也比較常見。由strconv包提供這類轉換功能。
將一個整數轉為字符串,一種方法是用fmt.Sprintf返回一個格式化的字符串;另一個方法是用strconv.Itoa(“整數到ASCII”):
x := 123
y := fmt.Sprintf("%d", x)
fmt.Println(y, strconv.Itoa(x)) // "123 123"
FormatInt和FormatUint函數可以用不同的進制來格式化數字:
fmt.Println(strconv.FormatInt(int64(x), 2)) // "1111011"
fmt.Printf函數的%b、%d、%o和%x等參數提供功能往往比strconv包的Format函數方便很多,特別是在需要包含有附加額外信息的時候:
s := fmt.Sprintf("x=%b", x) // "x=1111011"
如果要將一個字符串解析為整數,可以使用strconv包的Atoi或ParseInt函數,還有用於解析無符號整數的ParseUint函數:
x, err := strconv.Atoi("123") // x is an int
y, err := strconv.ParseInt("123", 10, 64) // base 10, up to 64 bits
ParseInt函數的第三個參數是用於指定整型數的大小;例如16表示int16,0則表示int。在任何情況下,返回的結果y總是int64類型,你可以通過強制類型轉換將它轉為更小的整數類型。
有時候也會使用fmt.Scanf來解析輸入的字符串和數字,特別是當字符串和數字混合在一行的時候,它可以靈活處理不完整或不規則的輸入。