# 第四章 復合數據類型
在第三章我們討論了基本數據類型,它們可以用于構建程序中數據結構,是Go語言的世界的原子。在本章,我們將討論復合數據類型,它是以不同的方式組合基本類型可以構造出來的復合數據類型。我們主要討論四種類型——數組、slice、map和結構體——同時在本章的最后,我們將演示如何使用結構體來解碼和編碼到對應JSON格式的數據,并且通過結合使用模板來生成HTML頁面。
數組和結構體是聚合類型;它們的值由許多元素或成員字段的值組成。數組是由同構的元素組成——每個數組元素都是完全相同的類型——結構體則是由異構的元素組成的。數組和結構體都是有固定內存大小的數據結構。相比之下,slice和map則是動態的數據結構,它們將根據需要動態增長。
### 4.1. 數組
數組是一個由固定長度的特定類型元素組成的序列,一個數組可以由零個或多個元素組成。因為數組的長度是固定的,因此在Go語言中很少直接使用數組。和數組對應的類型是Slice(切片),它是可以增長和收縮動態序列,slice功能也更靈活,但是要理解slice工作原理的話需要先理解數組。
數組的每個元素可以通過索引下標來訪問,索引下標的范圍是從0開始到數組長度減1的位置。內置的len函數將返回數組中元素的個數。
~~~
var a [3]int // array of 3 integers
fmt.Println(a[0]) // print the first element
fmt.Println(a[len(a)-1]) // print the last element, a[2]
// Print the indices and elements.
for i, v := range a {
fmt.Printf("%d %d\n", i, v)
}
// Print the elements only.
for _, v := range a {
fmt.Printf("%d\n", v)
}
~~~
默認情況下,數組的每個元素都被初始化為元素類型對應的零值,對于數字類型來說就是0。我們也可以使用數組字面值語法用一組值來初始化數組:
~~~
var q [3]int = [3]int{1, 2, 3}
var r [3]int = [3]int{1, 2}
fmt.Println(r[2]) // "0"
~~~
在數組字面值中,如果在數組的長度位置出現的是“...”省略號,則表示數組的長度是根據初始化值的個數來計算。因此,上面q數組的定義可以簡化為
~~~
q := [...]int{1, 2, 3}
fmt.Printf("%T\n", q) // "[3]int"
~~~
數組的長度是數組類型的一個組成部分,因此[3]int和[4]int是兩種不同的數組類型。數組的長度必須是常量表達式,因為數組的長度需要在編譯階段確定。
~~~
q := [3]int{1, 2, 3}
q = [4]int{1, 2, 3, 4} // compile error: cannot assign [4]int to [3]int
~~~
我們將會發現,數組、slice、map和結構體字面值的寫法都很相似。上面的形式是直接提供順序初始化值序列,但是也可以指定一個索引和對應值列表的方式初始化,就像下面這樣:
~~~
type Currency int
const (
USD Currency = iota // 美元
EUR // 歐元
GBP // 英鎊
RMB // 人民幣
)
symbol := [...]string{USD: "$", EUR: "€", GBP: "£", RMB: "¥"}
fmt.Println(RMB, symbol[RMB]) // "3 ¥"
~~~
在這種形式的數組字面值形式中,初始化索引的順序是無關緊要的,而且沒用到的索引可以省略,和前面提到的規則一樣,未指定初始值的元素將用零值初始化。例如,
~~~
r := [...]int{99: -1}
~~~
定義了一個含有100個元素的數組r,最后一個元素被初始化為-1,其它元素都是用0初始化。
如果一個數組的元素類型是可以相互比較的,那么數組類型也是可以相互比較的,這時候我們可以直接通過==比較運算符來比較兩個數組,只有當兩個數組的所有元素都是相等的時候數組才是相等的。不相等比較運算符!=遵循同樣的規則。
~~~
a := [2]int{1, 2}
b := [...]int{1, 2}
c := [2]int{1, 3}
fmt.Println(a == b, a == c, b == c) // "true false false"
d := [3]int{1, 2}
fmt.Println(a == d) // compile error: cannot compare [2]int == [3]int
~~~
作為一個真實的例子,crypto/sha256包的Sum256函數對一個任意的字節slice類型的數據生成一個對應的消息摘要。消息摘要有256bit大小,因此對應[32]byte數組類型。如果兩個消息摘要是相同的,那么可以認為兩個消息本身也是相同(譯注:理論上有HASH碼碰撞的情況,但是實際應用可以基本忽略);如果消息摘要不同,那么消息本身必然也是不同的。下面的例子用SHA256算法分別生成“x”和“X”兩個信息的摘要:
*gopl.io/ch4/sha256*
~~~
import "crypto/sha256"
func main() {
c1 := sha256.Sum256([]byte("x"))
c2 := sha256.Sum256([]byte("X"))
fmt.Printf("%x\n%x\n%t\n%T\n", c1, c2, c1 == c2, c1)
// Output:
// 2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881
// 4b68ab3847feda7d6c62c1fbcbeebfa35eab7351ed5e78f4ddadea5df64b8015
// false
// [32]uint8
}
~~~
上面例子中,兩個消息雖然只有一個字符的差異,但是生成的消息摘要則幾乎有一半的bit位是不相同的。需要注意Printf函數的%x副詞參數,它用于指定以十六進制的格式打印數組或slice全部的元素,%t副詞參數是用于打印布爾型數據,%T副詞參數是用于顯示一個值對應的數據類型。
當調用一個函數的時候,函數的每個調用參數將會被賦值給函數內部的參數變量,所以函數參數變量接收的是一個復制的副本,并不是原始調用的變量。因為函數參數傳遞的機制導致傳遞大的數組類型將是低效的,并且對數組參數的任何的修改都是發生在復制的數組上,并不能直接修改調用時原始的數組變量。在這個方面,Go語言對待數組的方式和其它很多編程語言不同,其它編程語言可能會隱式地將數組作為引用或指針對象傳入被調用的函數。
當然,我們可以顯式地傳入一個數組指針,那樣的話函數通過指針對數組的任何修改都可以直接反饋到調用者。下面的函數用于給[32]byte類型的數組清零:
~~~
func zero(ptr *[32]byte) {
for i := range ptr {
ptr[i] = 0
}
}
~~~
其實數組字面值[32]byte{}就可以生成一個32字節的數組。而且每個數組的元素都是零值初始化,也就是0。因此,我們可以將上面的zero函數寫的更簡潔一點:
~~~
func zero(ptr *[32]byte) {
*ptr = [32]byte{}
}
~~~
雖然通過指針來傳遞數組參數是高效的,而且也允許在函數內部修改數組的值,但是數組依然是僵化的類型,因為數組的類型包含了僵化的長度信息。上面的zero函數并不能接收指向[16]byte類型數組的指針,而且也沒有任何添加或刪除數組元素的方法。由于這些原因,除了像SHA256這類需要處理特定大小數組的特例外,數組依然很少用作函數參數;相反,我們一般使用slice來替代數組。
**練習 4.1:** 編寫一個函數,計算兩個SHA256哈希碼中不同bit的數目。(參考2.6.2節的PopCount函數。)
**練習 4.2:** 編寫一個程序,默認打印標準輸入的以SHA256哈希碼,也可以通過命令行標準參數選擇SHA384或SHA512哈希算法。
### 4.2. Slice
Slice(切片)代表變長的序列,序列中每個元素都有相同的類型。一個slice類型一般寫作[]T,其中T代表slice中元素的類型;slice的語法和數組很像,只是沒有固定長度而已。
數組和slice之間有著緊密的聯系。一個slice是一個輕量級的數據結構,提供了訪問數組子序列(或者全部)元素的功能,而且slice的底層確實引用一個數組對象。一個slice由三個部分構成:指針、長度和容量。指針指向第一個slice元素對應的底層數組元素的地址,要注意的是slice的第一個元素并不一定就是數組的第一個元素。長度對應slice中元素的數目;長度不能超過容量,容量一般是從slice的開始位置到底層數據的結尾位置。內置的len和cap函數分別返回slice的長度和容量。
多個slice之間可以共享底層的數據,并且引用的數組部分區間可能重疊。圖4.1顯示了表示一年中每個月份名字的字符串數組,還有重疊引用了該數組的兩個slice。數組這樣定義
~~~
months := [...]string{1: "January", /* ... */, 12: "December"}
~~~
因此一月份是months[1],十二月份是months[12]。通常,數組的第一個元素從索引0開始,但是月份一般是從1開始的,因此我們聲明數組時直接跳過第0個元素,第0個元素會被自動初始化為空字符串。
slice的切片操作s[i:j],其中0 ≤ i≤ j≤ cap(s),用于創建一個新的slice,引用s的從第i個元素開始到第j-1個元素的子序列。新的slice將只有j-i個元素。如果i位置的索引被省略的話將使用0代替,如果j位置的索引被省略的話將使用len(s)代替。因此,months[1:13]切片操作將引用全部有效的月份,和months[1:]操作等價;months[:]切片操作則是引用整個數組。讓我們分別定義表示第二季度和北方夏天月份的slice,它們有重疊部分:

~~~
Q2 := months[4:7]
summer := months[6:9]
fmt.Println(Q2) // ["April" "May" "June"]
fmt.Println(summer) // ["June" "July" "August"]
~~~
兩個slice都包含了六月份,下面的代碼是一個包含相同月份的測試(性能較低):
~~~
for _, s := range summer {
for _, q := range Q2 {
if s == q {
fmt.Printf("%s appears in both\n", s)
}
}
}
~~~
如果切片操作超出cap(s)的上限將導致一個panic異常,但是超出len(s)則是意味著擴展了slice,因為新slice的長度會變大:
~~~
fmt.Println(summer[:20]) // panic: out of range
endlessSummer := summer[:5] // extend a slice (within capacity)
fmt.Println(endlessSummer) // "[June July August September October]"
~~~
另外,字符串的切片操作和[]byte字節類型切片的切片操作是類似的。它們都寫作x[m:n],并且都是返回一個原始字節系列的子序列,底層都是共享之前的底層數組,因此切片操作對應常量時間復雜度。x[m:n]切片操作對于字符串則生成一個新字符串,如果x是[]byte的話則生成一個新的[]byte。
因為slice值包含指向第一個slice元素的指針,因此向函數傳遞slice將允許在函數內部修改底層數組的元素。換句話說,復制一個slice只是對底層的數組創建了一個新的slice別名(§2.3.2)。下面的reverse函數在原內存空間將[]int類型的slice反轉,而且它可以用于任意長度的slice。
*gopl.io/ch4/rev*
~~~
// reverse reverses a slice of ints in place.
func reverse(s []int) {
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
s[i], s[j] = s[j], s[i]
}
}
~~~
這里我們反轉數組的應用:
~~~
a := [...]int{0, 1, 2, 3, 4, 5}
reverse(a[:])
fmt.Println(a) // "[5 4 3 2 1 0]"
~~~
一種將slice元素循環向左旋轉n個元素的方法是三次調用reverse反轉函數,第一次是反轉開頭的n個元素,然后是反轉剩下的元素,最后是反轉整個slice的元素。(如果是向右循環旋轉,則將第三個函數調用移到第一個調用位置就可以了。)
~~~
s := []int{0, 1, 2, 3, 4, 5}
// Rotate s left by two positions.
reverse(s[:2])
reverse(s[2:])
reverse(s)
fmt.Println(s) // "[2 3 4 5 0 1]"
~~~
要注意的是slice類型的變量s和數組類型的變量a的初始化語法的差異。slice和數組的字面值語法很類似,它們都是用花括弧包含一系列的初始化元素,但是對于slice并沒有指明序列的長度。這會隱式地創建一個合適大小的數組,然后slice的指針指向底層的數組。就像數組字面值一樣,slice的字面值也可以按順序指定初始化值序列,或者是通過索引和元素值指定,或者的兩種風格的混合語法初始化。
和數組不同的是,slice之間不能比較,因此我們不能使用==操作符來判斷兩個slice是否含有全部相等元素。不過標準庫提供了高度優化的bytes.Equal函數來判斷兩個字節型slice是否相等([]byte),但是對于其他類型的slice,我們必須自己展開每個元素進行比較:
~~~
func equal(x, y []string) bool {
if len(x) != len(y) {
return false
}
for i := range x {
if x[i] != y[i] {
return false
}
}
return true
}
~~~
上面關于兩個slice的深度相等測試,運行的時間并不比支持==操作的數組或字符串更多,但是為何slice不直接支持比較運算符呢?這方面有兩個原因。第一個原因,一個slice的元素是間接引用的,一個slice甚至可以包含自身。雖然有很多辦法處理這種情形,但是沒有一個是簡單有效的。
第二個原因,因為slice的元素是間接引用的,一個固定值的slice在不同的時間可能包含不同的元素,因為底層數組的元素可能會被修改。并且Go語言中map等哈希表之類的數據結構的key只做簡單的淺拷貝,它要求在整個聲明周期中相等的key必須對相同的元素。對于像指針或chan之類的引用類型,==相等測試可以判斷兩個是否是引用相同的對象。一個針對slice的淺相等測試的==操作符可能是有一定用處的,也能臨時解決map類型的key問題,但是slice和數組不同的相等測試行為會讓人困惑。因此,安全的做法是直接禁止slice之間的比較操作。
slice唯一合法的比較操作是和nil比較,例如:
~~~
if summer == nil { /* ... */ }
~~~
一個零值的slice等于nil。一個nil值的slice并沒有底層數組。一個nil值的slice的長度和容量都是0,但是也有非nil值的slice的長度和容量也是0的,例如[]int{}或make([]int, 3)[3:]。與任意類型的nil值一樣,我們可以用[]int(nil)類型轉換表達式來生成一個對應類型slice的nil值。
~~~
var s []int // len(s) == 0, s == nil
s = nil // len(s) == 0, s == nil
s = []int(nil) // len(s) == 0, s == nil
s = []int{} // len(s) == 0, s != nil
~~~
如果你需要測試一個slice是否是空的,使用len(s) == 0來判斷,而不應該用s == nil來判斷。除了和nil相等比較外,一個nil值的slice的行為和其它任意0長度的slice一樣;例如reverse(nil)也是安全的。除了文檔已經明確說明的地方,所有的Go語言函數應該以相同的方式對待nil值的slice和0長度的slice。
內置的make函數創建一個指定元素類型、長度和容量的slice。容量部分可以省略,在這種情況下,容量將等于長度。
~~~
make([]T, len)
make([]T, len, cap) // same as make([]T, cap)[:len]
~~~
在底層,make創建了一個匿名的數組變量,然后返回一個slice;只有通過返回的slice才能引用底層匿名的數組變量。在第一種語句中,slice是整個數組的view。在第二個語句中,slice只引用了底層數組的前len個元素,但是容量將包含整個的數組。額外的元素是留給未來的增長用的。
### 4.2.1. append函數
內置的append函數用于向slice追加元素:
~~~
var runes []rune
for _, r := range "Hello, 世界" {
runes = append(runes, r)
}
fmt.Printf("%q\n", runes) // "['H' 'e' 'l' 'l' 'o' ',' ' ' '世' '界']"
~~~
在循環中使用append函數構建一個由九個rune字符構成的slice,當然對應這個特殊的問題我們可以通過Go語言內置的[]rune("Hello, 世界")轉換操作完成。
append函數對于理解slice底層是如何工作的非常重要,所以讓我們仔細查看究竟是發生了什么。下面是第一個版本的appendInt函數,專門用于處理[]int類型的slice:
*gopl.io/ch4/append*
~~~
func appendInt(x []int, y int) []int {
var z []int
zlen := len(x) + 1
if zlen <= cap(x) {
// There is room to grow. Extend the slice.
z = x[:zlen]
} else {
// There is insufficient space. Allocate a new array.
// Grow by doubling, for amortized linear complexity.
zcap := zlen
if zcap < 2*len(x) {
zcap = 2 * len(x)
}
z = make([]int, zlen, zcap)
copy(z, x) // a built-in function; see text
}
z[len(x)] = y
return z
}
~~~
每次調用appendInt函數,必須先檢測slice底層數組是否有足夠的容量來保存新添加的元素。如果有足夠空間的話,直接擴展slice(依然在原有的底層數組之上),將新添加的y元素復制到新擴展的空間,并返回slice。因此,輸入的x和輸出的z共享相同的底層數組。
如果沒有足夠的增長空間的話,appendInt函數則會先分配一個足夠大的slice用于保存新的結果,先將輸入的x復制到新的空間,然后添加y元素。結果z和輸入的x引用的將是不同的底層數組。
雖然通過循環復制元素更直接,不過內置的copy函數可以方便地將一個slice復制另一個相同類型的slice。copy函數的第一個參數是要復制的目標slice,第二個參數是源slice,目標和源的位置順序和`dst = src`賦值語句是一致的。兩個slice可以共享同一個底層數組,甚至有重疊也沒有問題。copy函數將返回成功復制的元素的個數(我們這里沒有用到),等于兩個slice中較小的長度,所以我們不用擔心覆蓋會超出目標slice的范圍。
為了提高內存使用效率,新分配的數組一般略大于保存x和y所需要的最低大小。通過在每次擴展數組時直接將長度翻倍從而避免了多次內存分配,也確保了添加單個元素操的平均時間是一個常數時間。這個程序演示了效果:
~~~
func main() {
var x, y []int
for i := 0; i < 10; i++ {
y = appendInt(x, i)
fmt.Printf("%d cap=%d\t%v\n", i, cap(y), y)
x = y
}
}
~~~
每一次容量的變化都會導致重新分配內存和copy操作:
~~~
0 cap=1 [0]
1 cap=2 [0 1]
2 cap=4 [0 1 2]
3 cap=4 [0 1 2 3]
4 cap=8 [0 1 2 3 4]
5 cap=8 [0 1 2 3 4 5]
6 cap=8 [0 1 2 3 4 5 6]
7 cap=8 [0 1 2 3 4 5 6 7]
8 cap=16 [0 1 2 3 4 5 6 7 8]
9 cap=16 [0 1 2 3 4 5 6 7 8 9]
~~~
讓我們仔細查看i=3次的迭代。當時x包含了[0 1 2]三個元素,但是容量是4,因此可以簡單將新的元素添加到末尾,不需要新的內存分配。然后新的y的長度和容量都是4,并且和x引用著相同的底層數組,如圖4.2所示。

在下一次迭代時i=4,現在沒有新的空余的空間了,因此appendInt函數分配一個容量為8的底層數組,將x的4個元素[0 1 2 3]復制到新空間的開頭,然后添加新的元素i,新元素的值是4。新的y的長度是5,容量是8;后面有3個空閑的位置,三次迭代都不需要分配新的空間。當前迭代中,y和x是對應不同底層數組的view。這次操作如圖4.3所示。

內置的append函數可能使用比appendInt更復雜的內存擴展策略。因此,通常我們并不知道append調用是否導致了內存的重新分配,因此我們也不能確認新的slice和原始的slice是否引用的是相同的底層數組空間。同樣,我們不能確認在原先的slice上的操作是否會影響到新的slice。因此,通常是將append返回的結果直接賦值給輸入的slice變量:
~~~
runes = append(runes, r)
~~~
更新slice變量不僅對調用append函數是必要的,實際上對應任何可能導致長度、容量或底層數組變化的操作都是必要的。要正確地使用slice,需要記住盡管底層數組的元素是間接訪問的,但是slice對應結構體本身的指針、長度和容量部分是直接訪問的。要更新這些信息需要像上面例子那樣一個顯式的賦值操作。從這個角度看,slice并不是一個純粹的引用類型,它實際上是一個類似下面結構體的聚合類型:
~~~
type IntSlice struct {
ptr *int
len, cap int
}
~~~
我們的appendInt函數每次只能向slice追加一個元素,但是內置的append函數則可以追加多個元素,甚至追加一個slice。
~~~
var x []int
x = append(x, 1)
x = append(x, 2, 3)
x = append(x, 4, 5, 6)
x = append(x, x...) // append the slice x
fmt.Println(x) // "[1 2 3 4 5 6 1 2 3 4 5 6]"
~~~
通過下面的小修改,我們可以可以達到append函數類似的功能。其中在appendInt函數參數中的最后的“...”省略號表示接收變長的參數為slice。我們將在5.7節詳細解釋這個特性。
~~~
func appendInt(x []int, y ...int) []int {
var z []int
zlen := len(x) + len(y)
// ...expand z to at least zlen...
copy(z[len(x):], y)
return z
}
~~~
為了避免重復,和前面相同的代碼并沒有顯示。
### 4.2.2. Slice內存技巧
讓我們看看更多的例子,比如旋轉slice、反轉slice或在slice原有內存空間修改元素。給定一個字符串列表,下面的nonempty函數將在原有slice內存空間之上返回不包含空字符串的列表:
*gopl.io/ch4/nonempty*
~~~
// Nonempty is an example of an in-place slice algorithm.
package main
import "fmt"
// nonempty returns a slice holding only the non-empty strings.
// The underlying array is modified during the call.
func nonempty(strings []string) []string {
i := 0
for _, s := range strings {
if s != "" {
strings[i] = s
i++
}
}
return strings[:i]
}
~~~
比較微妙的地方是,輸入的slice和輸出的slice共享一個底層數組。這可以避免分配另一個數組,不過原來的數據將可能會被覆蓋,正如下面兩個打印語句看到的那樣:
~~~
data := []string{"one", "", "three"}
fmt.Printf("%q\n", nonempty(data)) // `["one" "three"]`
fmt.Printf("%q\n", data) // `["one" "three" "three"]`
~~~
因此我們通常會這樣使用nonempty函數:`data = nonempty(data)`。
nonempty函數也可以使用append函數實現:
~~~
func nonempty2(strings []string) []string {
out := strings[:0] // zero-length slice of original
for _, s := range strings {
if s != "" {
out = append(out, s)
}
}
return out
}
~~~
無論如何實現,以這種方式重用一個slice一般都要求最多為每個輸入值產生一個輸出值,事實上很多這類算法都是用來過濾或合并序列中相鄰的元素。這種slice用法是比較復雜的技巧,雖然使用到了slice的一些技巧,但是對于某些場合是比較清晰和有效的。
一個slice可以用來模擬一個stack。最初給定的空slice對應一個空的stack,然后可以使用append函數將新的值壓入stack:
~~~
stack = append(stack, v) // push v
~~~
stack的頂部位置對應slice的最后一個元素:
~~~
top := stack[len(stack)-1] // top of stack
~~~
通過收縮stack可以彈出棧頂的元素
~~~
stack = stack[:len(stack)-1] // pop
~~~
要刪除slice中間的某個元素并保存原有的元素順序,可以通過內置的copy函數將后面的子slice向前依次移動一位完成:
~~~
func remove(slice []int, i int) []int {
copy(slice[i:], slice[i+1:])
return slice[:len(slice)-1]
}
func main() {
s := []int{5, 6, 7, 8, 9}
fmt.Println(remove(s, 2)) // "[5 6 8 9]"
}
~~~
如果刪除元素后不用保持原來順序的話,我們可以簡單的用最后一個元素覆蓋被刪除的元素:
~~~
func remove(slice []int, i int) []int {
slice[i] = slice[len(slice)-1]
return slice[:len(slice)-1]
}
func main() {
s := []int{5, 6, 7, 8, 9}
fmt.Println(remove(s, 2)) // "[5 6 9 8]
}
~~~
**練習 4.3:** 重寫reverse函數,使用數組指針代替slice。
**練習 4.4:** 編寫一個rotate函數,通過一次循環完成旋轉。
**練習 4.5:** 寫一個函數在原地完成消除[]string中相鄰重復的字符串的操作。
**練習 4.6:** 編寫一個函數,原地將一個UTF-8編碼的[]byte類型的slice中相鄰的空格(參考unicode.IsSpace)替換成一個空格返回
**練習 4.7:** 修改reverse函數用于原地反轉UTF-8編碼的[]byte。是否可以不用分配額外的內存?
### 4.3. Map
哈希表是一種巧妙并且實用的數據結構。它是一個無序的key/value對的集合,其中所有的key都是不同的,然后通過給定的key可以在常數時間復雜度內檢索、更新或刪除對應的value。
在Go語言中,一個map就是一個哈希表的引用,map類型可以寫為map[K]V,其中K和V分別對應key和value。map中所有的key都有相同的類型,所有的value也有著相同的類型,但是key和value之間可以是不同的數據類型。其中K對應的key必須是支持==比較運算符的數據類型,所以map可以通過測試key是否相等來判斷是否已經存在。雖然浮點數類型也是支持相等運算符比較的,但是將浮點數用做key類型則是一個壞的想法,正如第三章提到的,最壞的情況是可能出現的NaN和任何浮點數都不相等。對于V對應的value數據類型則沒有任何的限制。
內置的make函數可以創建一個map:
~~~
ages := make(map[string]int) // mapping from strings to ints
~~~
我們也可以用map字面值的語法創建map,同時還可以指定一些最初的key/value:
~~~
ages := map[string]int{
"alice": 31,
"charlie": 34,
}
~~~
這相當于
~~~
ages := make(map[string]int)
ages["alice"] = 31
ages["charlie"] = 34
~~~
因此,另一種創建空的map的表達式是`map[string]int{}`。
Map中的元素通過key對應的下標語法訪問:
~~~
ages["alice"] = 32
fmt.Println(ages["alice"]) // "32"
~~~
使用內置的delete函數可以刪除元素:
~~~
delete(ages, "alice") // remove element ages["alice"]
~~~
所有這些操作是安全的,即使這些元素不在map中也沒有關系;如果一個查找失敗將返回value類型對應的零值,例如,即使map中不存在“bob”下面的代碼也可以正常工作,因為ages["bob"]失敗時將返回0。
~~~
ages["bob"] = ages["bob"] + 1 // happy birthday!
~~~
而且`x += y`和`x++`等簡短賦值語法也可以用在map上,所以上面的代碼可以改寫成
~~~
ages["bob"] += 1
~~~
更簡單的寫法
~~~
ages["bob"]++
~~~
但是map中的元素并不是一個變量,因此我們不能對map的元素進行取址操作:
~~~
_ = &ages["bob"] // compile error: cannot take address of map element
~~~
禁止對map元素取址的原因是map可能隨著元素數量的增長而重新分配更大的內存空間,從而可能導致之前的地址無效。
要想遍歷map中全部的key/value對的話,可以使用range風格的for循環實現,和之前的slice遍歷語法類似。下面的迭代語句將在每次迭代時設置name和age變量,它們對應下一個鍵/值對:
~~~
for name, age := range ages {
fmt.Printf("%s\t%d\n", name, age)
}
~~~
Map的迭代順序是不確定的,并且不同的哈希函數實現可能導致不同的遍歷順序。在實踐中,遍歷的順序是隨機的,每一次遍歷的順序都不相同。這是故意的,每次都使用隨機的遍歷順序可以強制要求程序不會依賴具體的哈希函數實現。如果要按順序遍歷key/value對,我們必須顯式地對key進行排序,可以使用sort包的Strings函數對字符串slice進行排序。下面是常見的處理方式:
~~~
import "sort"
var names []string
for name := range ages {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
fmt.Printf("%s\t%d\n", name, ages[name])
}
~~~
因為我們一開始就知道names的最終大小,因此給slice分配一個合適的大小將會更有效。下面的代碼創建了一個空的slice,但是slice的容量剛好可以放下map中全部的key:
~~~
names := make([]string, 0, len(ages))
~~~
在上面的第一個range循環中,我們只關心map中的key,所以我們忽略了第二個循環變量。在第二個循環中,我們只關心names中的名字,所以我們使用“_”空白標識符來忽略第一個循環變量,也就是迭代slice時的索引。
map類型的零值是nil,也就是沒有引用任何哈希表。
~~~
var ages map[string]int
fmt.Println(ages == nil) // "true"
fmt.Println(len(ages) == 0) // "true"
~~~
map上的大部分操作,包括查找、刪除、len和range循環都可以安全工作在nil值的map上,它們的行為和一個空的map類似。但是向一個nil值的map存入元素將導致一個panic異常:
~~~
ages["carol"] = 21 // panic: assignment to entry in nil map
~~~
在向map存數據前必須先創建map。
通過key作為索引下標來訪問map將產生一個value。如果key在map中是存在的,那么將得到與key對應的value;如果key不存在,那么將得到value對應類型的零值,正如我們前面看到的ages["bob"]那樣。這個規則很實用,但是有時候可能需要知道對應的元素是否真的是在map之中。例如,如果元素類型是一個數字,你可以需要區分一個已經存在的0,和不存在而返回零值的0,可以像下面這樣測試:
~~~
age, ok := ages["bob"]
if !ok { /* "bob" is not a key in this map; age == 0. */ }
~~~
你會經常看到將這兩個結合起來使用,像這樣:
~~~
if age, ok := ages["bob"]; !ok { /* ... */ }
~~~
在這種場景下,map的下標語法將產生兩個值;第二個是一個布爾值,用于報告元素是否真的存在。布爾變量一般命名為ok,特別適合馬上用于if條件判斷部分。
和slice一樣,map之間也不能進行相等比較;唯一的例外是和nil進行比較。要判斷兩個map是否包含相同的key和value,我們必須通過一個循環實現:
~~~
func equal(x, y map[string]int) bool {
if len(x) != len(y) {
return false
}
for k, xv := range x {
if yv, ok := y[k]; !ok || yv != xv {
return false
}
}
return true
}
~~~
要注意我們是如何用!ok來區分元素缺失和元素不同的。我們不能簡單地用xv != y[k]判斷,那樣會導致在判斷下面兩個map時產生錯誤的結果:
~~~
// True if equal is written incorrectly.
equal(map[string]int{"A": 0}, map[string]int{"B": 42})
~~~
Go語言中并沒有提供一個set類型,但是map中的key也是不相同的,可以用map實現類似set的功能。為了說明這一點,下面的dedup程序讀取多行輸入,但是只打印第一次出現的行。(它是1.3節中出現的dup程序的變體。)dedup程序通過map來表示所有的輸入行所對應的set集合,以確保已經在集合存在的行不會被重復打印。
*gopl.io/ch4/dedup*
~~~
func main() {
seen := make(map[string]bool) // a set of strings
input := bufio.NewScanner(os.Stdin)
for input.Scan() {
line := input.Text()
if !seen[line] {
seen[line] = true
fmt.Println(line)
}
}
if err := input.Err(); err != nil {
fmt.Fprintf(os.Stderr, "dedup: %v\n", err)
os.Exit(1)
}
}
~~~
Go程序員將這種忽略value的map當作一個字符串集合,并非所有`map[string]bool`類型value都是無關緊要的;有一些則可能會同時包含true和false的值。
有時候我們需要一個map或set的key是slice類型,但是map的key必須是可比較的類型,但是slice并不滿足這個條件。不過,我們可以通過兩個步驟繞過這個限制。第一步,定義一個輔助函數k,將slice轉為map對應的string類型的key,確保只有x和y相等時k(x) == k(y)才成立。然后創建一個key為string類型的map,在每次對map操作時先用k輔助函數將slice轉化為string類型。
下面的例子演示了如何使用map來記錄提交相同的字符串列表的次數。它使用了fmt.Sprintf函數將字符串列表轉換為一個字符串以用于map的key,通過%q參數忠實地記錄每個字符串元素的信息:
~~~
var m = make(map[string]int)
func k(list []string) string { return fmt.Sprintf("%q", list) }
func Add(list []string) { m[k(list)]++ }
func Count(list []string) int { return m[k(list)] }
~~~
使用同樣的技術可以處理任何不可比較的key類型,而不僅僅是slice類型。這種技術對于想使用自定義key比較函數的時候也很有用,例如在比較字符串的時候忽略大小寫。同時,輔助函數k(x)也不一定是字符串類型,它可以返回任何可比較的類型,例如整數、數組或結構體等。
這是map的另一個例子,下面的程序用于統計輸入中每個Unicode碼點出現的次數。雖然Unicode全部碼點的數量巨大,但是出現在特定文檔中的字符種類并沒有多少,使用map可以用比較自然的方式來跟蹤那些出現過字符的次數。
*gopl.io/ch4/charcount*
~~~
// Charcount computes counts of Unicode characters.
package main
import (
"bufio"
"fmt"
"io"
"os"
"unicode"
"unicode/utf8"
)
func main() {
counts := make(map[rune]int) // counts of Unicode characters
var utflen [utf8.UTFMax + 1]int // count of lengths of UTF-8 encodings
invalid := 0 // count of invalid UTF-8 characters
in := bufio.NewReader(os.Stdin)
for {
r, n, err := in.ReadRune() // returns rune, nbytes, error
if err == io.EOF {
break
}
if err != nil {
fmt.Fprintf(os.Stderr, "charcount: %v\n", err)
os.Exit(1)
}
if r == unicode.ReplacementChar && n == 1 {
invalid++
continue
}
counts[r]++
utflen[n]++
}
fmt.Printf("rune\tcount\n")
for c, n := range counts {
fmt.Printf("%q\t%d\n", c, n)
}
fmt.Print("\nlen\tcount\n")
for i, n := range utflen {
if i > 0 {
fmt.Printf("%d\t%d\n", i, n)
}
}
if invalid > 0 {
fmt.Printf("\n%d invalid UTF-8 characters\n", invalid)
}
}
~~~
ReadRune方法執行UTF-8解碼并返回三個值:解碼的rune字符的值,字符UTF-8編碼后的長度,和一個錯誤值。我們可預期的錯誤值只有對應文件結尾的io.EOF。如果輸入的是無效的UTF-8編碼的字符,返回的將是unicode.ReplacementChar表示無效字符,并且編碼長度是1。
charcount程序同時打印不同UTF-8編碼長度的字符數目。對此,map并不是一個合適的數據結構;因為UTF-8編碼的長度總是從1到utf8.UTFMax(最大是4個字節),使用數組將更有效。
作為一個實驗,我們用charcount程序對英文版原稿的字符進行了統計。雖然大部分是英語,但是也有一些非ASCII字符。下面是排名前10的非ASCII字符:

下面是不同UTF-8編碼長度的字符的數目:
~~~
len count
1 765391
2 60
3 70
4 0
~~~
Map的value類型也可以是一個聚合類型,比如是一個map或slice。在下面的代碼中,圖graph的key類型是一個字符串,value類型map[string]bool代表一個字符串集合。從概念上將,graph將一個字符串類型的key映射到一組相關的字符串集合,它們指向新的graph的key。
*gopl.io/ch4/graph*
~~~
var graph = make(map[string]map[string]bool)
func addEdge(from, to string) {
edges := graph[from]
if edges == nil {
edges = make(map[string]bool)
graph[from] = edges
}
edges[to] = true
}
func hasEdge(from, to string) bool {
return graph[from][to]
}
~~~
其中addEdge函數惰性初始化map是一個慣用方式,也就是說在每個值首次作為key時才初始化。addEdge函數顯示了如何讓map的零值也能正常工作;即使from到to的邊不存在,graph[from][to]依然可以返回一個有意義的結果。
**練習 4.8:** 修改charcount程序,使用unicode.IsLetter等相關的函數,統計字母、數字等Unicode中不同的字符類別。
**練習 4.9:** 編寫一個程序wordfreq程序,報告輸入文本中每個單詞出現的頻率。在第一次調用Scan前先調用input.Split(bufio.ScanWords)函數,這樣可以按單詞而不是按行輸入。
### 4.4. 結構體
結構體是一種聚合的數據類型,是由零個或多個任意類型的值聚合成的實體。每個值稱為結構體的成員。用結構體的經典案例處理公司的員工信息,每個員工信息包含一個唯一的員工編號、員工的名字、家庭住址、出生日期、工作崗位、薪資、上級領導等等。所有的這些信息都需要綁定到一個實體中,可以作為一個整體單元被復制,作為函數的參數或返回值,或者是被存儲到數組中,等等。
下面兩個語句聲明了一個叫Employee的命名的結構體類型,并且聲明了一個Employee類型的變量dilbert:
~~~
type Employee struct {
ID int
Name string
Address string
DoB time.Time
Position string
Salary int
ManagerID int
}
var dilbert Employee
~~~
dilbert結構體變量的成員可以通過點操作符訪問,比如dilbert.Name和dilbert.DoB。因為dilbert是一個變量,它所有的成員也同樣是變量,我們可以直接對每個成員賦值:
~~~
dilbert.Salary -= 5000 // demoted, for writing too few lines of code
~~~
或者是對成員取地址,然后通過指針訪問:
~~~
position := &dilbert.Position
*position = "Senior " + *position // promoted, for outsourcing to Elbonia
~~~
點操作符也可以和指向結構體的指針一起工作:
~~~
var employeeOfTheMonth *Employee = &dilbert
employeeOfTheMonth.Position += " (proactive team player)"
~~~
相當于下面語句
~~~
(*employeeOfTheMonth).Position += " (proactive team player)"
~~~
下面的EmployeeByID函數將根據給定的員工ID返回對應的員工信息結構體的指針。我們可以使用點操作符來訪問它里面的成員:
~~~
func EmployeeByID(id int) *Employee { /* ... */ }
fmt.Println(EmployeeByID(dilbert.ManagerID).Position) // "Pointy-haired boss"
id := dilbert.ID
EmployeeByID(id).Salary = 0 // fired for... no real reason
~~~
后面的語句通過EmployeeByID返回的結構體指針更新了Employee結構體的成員。如果將EmployeeByID函數的返回值從`*Employee`指針類型改為Employee值類型,那么更新語句將不能編譯通過,因為在賦值語句的左邊并不確定是一個變量(譯注:調用函數返回的是值,并不是一個可取地址的變量)。
通常一行對應一個結構體成員,成員的名字在前類型在后,不過如果相鄰的成員類型如果相同的話可以被合并到一行,就像下面的Name和Address成員那樣:
~~~
type Employee struct {
ID int
Name, Address string
DoB time.Time
Position string
Salary int
ManagerID int
}
~~~
結構體成員的輸入順序也有重要的意義。我們也可以將Position成員合并(因為也是字符串類型),或者是交換Name和Address出現的先后順序,那樣的話就是定義了不同的結構體類型。通常,我們只是將相關的成員寫到一起。
如果結構體成員名字是以大寫字母開頭的,那么該成員就是導出的;這是Go語言導出規則決定的。一個結構體可能同時包含導出和未導出的成員。
結構體類型往往是冗長的,因為它的每個成員可能都會占一行。雖然我們每次都可以重寫整個結構體成員,但是重復會令人厭煩。因此,完整的結構體寫法通常只在類型聲明語句的地方出現,就像Employee類型聲明語句那樣。
一個命名為S的結構體類型將不能再包含S類型的成員:因為一個聚合的值不能包含它自身。(該限制同樣適應于數組。)但是S類型的結構體可以包含`*S`指針類型的成員,這可以讓我們創建遞歸的數據結構,比如鏈表和樹結構等。在下面的代碼中,我們使用一個二叉樹來實現一個插入排序:
*gopl.io/ch4/treesort*
~~~
type tree struct {
value int
left, right *tree
}
// Sort sorts values in place.
func Sort(values []int) {
var root *tree
for _, v := range values {
root = add(root, v)
}
appendValues(values[:0], root)
}
// appendValues appends the elements of t to values in order
// and returns the resulting slice.
func appendValues(values []int, t *tree) []int {
if t != nil {
values = appendValues(values, t.left)
values = append(values, t.value)
values = appendValues(values, t.right)
}
return values
}
func add(t *tree, value int) *tree {
if t == nil {
// Equivalent to return &tree{value: value}.
t = new(tree)
t.value = value
return t
}
if value < t.value {
t.left = add(t.left, value)
} else {
t.right = add(t.right, value)
}
return t
}
~~~
結構體類型的零值是每個成員都對是零值。通常會將零值作為最合理的默認值。例如,對于bytes.Buffer類型,結構體初始值就是一個隨時可用的空緩存,還有在第9章將會講到的sync.Mutex的零值也是有效的未鎖定狀態。有時候這種零值可用的特性是自然獲得的,但是也有些類型需要一些額外的工作。
如果結構體沒有任何成員的話就是空結構體,寫作struct{}。它的大小為0,也不包含任何信息,但是有時候依然是有價值的。有些Go語言程序員用map帶模擬set數據結構時,用它來代替map中布爾類型的value,只是強調key的重要性,但是因為節約的空間有限,而且語法比較復雜,所有我們通常避免避免這樣的用法。
~~~
seen := make(map[string]struct{}) // set of strings
// ...
if _, ok := seen[s]; !ok {
seen[s] = struct{}{}
// ...first time seeing s...
}
~~~
### 4.4.1. 結構體面值
結構體值也可以用結構體面值表示,結構體面值可以指定每個成員的值。
~~~
type Point struct{ X, Y int }
p := Point{1, 2}
~~~
這里有兩種形式的結構體面值語法,上面的是第一種寫法,要求以結構體成員定義的順序為每個結構體成員指定一個面值。它要求寫代碼和讀代碼的人要記住結構體的每個成員的類型和順序,不過結構體成員有細微的調整就可能導致上述代碼不能編譯。因此,上述的語法一般只在定義結構體的包內部使用,或者是在較小的結構體中使用,這些結構體的成員排列比較規則,比如image.Point{x, y}或color.RGBA{red, green, blue, alpha}。
其實更常用的是第二種寫法,以成員名字和相應的值來初始化,可以包含部分或全部的成員,如1.4節的Lissajous程序的寫法:
~~~
anim := gif.GIF{LoopCount: nframes}
~~~
在這種形式的結構體面值寫法中,如果成員被忽略的話將默認用零值。因為,提供了成員的名字,所有成員出現的順序并不重要。
兩種不同形式的寫法不能混合使用。而且,你不能企圖在外部包中用第一種順序賦值的技巧來偷偷地初始化結構體中未導出的成員。
~~~
package p
type T struct{ a, b int } // a and b are not exported
package q
import "p"
var _ = p.T{a: 1, b: 2} // compile error: can't reference a, b
var _ = p.T{1, 2} // compile error: can't reference a, b
~~~
雖然上面最后一行代碼的編譯錯誤信息中并沒有顯式提到未導出的成員,但是這樣企圖隱式使用未導出成員的行為也是不允許的。
結構體可以作為函數的參數和返回值。例如,這個Scale函數將Point類型的值縮放后返回:
~~~
func Scale(p Point, factor int) Point {
return Point{p.X * factor, p.Y * factor}
}
fmt.Println(Scale(Point{1, 2}, 5)) // "{5 10}"
~~~
如果考慮效率的話,較大的結構體通常會用指針的方式傳入和返回,
~~~
func Bonus(e *Employee, percent int) int {
return e.Salary * percent / 100
}
~~~
如果要在函數內部修改結構體成員的話,用指針傳入是必須的;因為在Go語言中,所有的函數參數都是值拷貝傳入的,函數參數將不再是函數調用時的原始變量。
~~~
func AwardAnnualRaise(e *Employee) {
e.Salary = e.Salary * 105 / 100
}
~~~
因為結構體通常通過指針處理,可以用下面的寫法來創建并初始化一個結構體變量,并返回結構體的地址:
~~~
pp := &Point{1, 2}
~~~
它是下面的語句是等價的
~~~
pp := new(Point)
*pp = Point{1, 2}
~~~
不過&Point{1, 2}寫法可以直接在表達式中使用,比如一個函數調用。
### 4.4.2. 結構體比較
如果結構體的全部成員都是可以比較的,那么結構體也是可以比較的,那樣的話兩個結構體將可以使用==或!=運算符進行比較。相等比較運算符==將比較兩個結構體的每個成員,因此下面兩個比較的表達式是等價的:
~~~
type Point struct{ X, Y int }
p := Point{1, 2}
q := Point{2, 1}
fmt.Println(p.X == q.X && p.Y == q.Y) // "false"
fmt.Println(p == q) // "false"
~~~
可比較的結構體類型和其他可比較的類型一樣,可以用于map的key類型。
~~~
type address struct {
hostname string
port int
}
hits := make(map[address]int)
hits[address{"golang.org", 443}]++
~~~
### 4.4.3. 結構體嵌入和匿名成員
在本節中,我們將看到如何使用Go語言提供的不同尋常的結構體嵌入機制讓一個命名的結構體包含另一個結構體類型的匿名成員,這樣就可以通過簡單的點運算符x.f來訪問匿名成員鏈中嵌套的x.d.e.f成員。
考慮一個二維的繪圖程序,提供了一個各種圖形的庫,例如矩形、橢圓形、星形和輪形等幾何形狀。這里是其中兩個的定義:
~~~
type Circle struct {
X, Y, Radius int
}
type Wheel struct {
X, Y, Radius, Spokes int
}
~~~
一個Circle代表的圓形類型包含了標準圓心的X和Y坐標信息,和一個Radius表示的半徑信息。一個Wheel輪形除了包含Circle類型所有的全部成員外,還增加了Spokes表示徑向輻條的數量。我們可以這樣創建一個wheel變量:
~~~
var w Wheel
w.X = 8
w.Y = 8
w.Radius = 5
w.Spokes = 20
~~~
隨著庫中幾何形狀數量的增多,我們一定會注意到它們之間的相似和重復之處,所以我們可能為了便于維護而將相同的屬性獨立出來:
~~~
type Point struct {
X, Y int
}
type Circle struct {
Center Point
Radius int
}
type Wheel struct {
Circle Circle
Spokes int
}
~~~
這樣改動之后結構體類型變的清晰了,但是這種修改同時也導致了訪問每個成員變得繁瑣:
~~~
var w Wheel
w.Circle.Center.X = 8
w.Circle.Center.Y = 8
w.Circle.Radius = 5
w.Spokes = 20
~~~
Go語言有一個特性讓我們只聲明一個成員對應的數據類型而不指名成員的名字;這類成員就叫匿名成員。匿名成員的數據類型必須是命名的類型或指向一個命名的類型的指針。下面的代碼中,Circle和Wheel各自都有一個匿名成員。我們可以說Point類型被嵌入到了Circle結構體,同時Circle類型被嵌入到了Wheel結構體。
~~~
type Circle struct {
Point
Radius int
}
type Wheel struct {
Circle
Spokes int
}
~~~
得意于匿名嵌入的特性,我們可以直接訪問葉子屬性而不需要給出完整的路徑:
~~~
var w Wheel
w.X = 8 // equivalent to w.Circle.Point.X = 8
w.Y = 8 // equivalent to w.Circle.Point.Y = 8
w.Radius = 5 // equivalent to w.Circle.Radius = 5
w.Spokes = 20
~~~
在右邊的注釋中給出的顯式形式訪問這些葉子成員的語法依然有效,因此匿名成員并不是真的無法訪問了。其中匿名成員Circle和Point都有自己的名字——就是命名的類型名字——但是這些名字在點操作符中是可選的。我們在訪問子成員的時候可以忽略任何匿名成員部分。
不幸的是,結構體字面值并沒有簡短表示匿名成員的語法, 因此下面的語句都不能編譯通過:
~~~
w = Wheel{8, 8, 5, 20} // compile error: unknown fields
w = Wheel{X: 8, Y: 8, Radius: 5, Spokes: 20} // compile error: unknown fields
~~~
結構體字面值必須遵循形狀類型聲明時的結構,所以我們只能用下面的兩種語法,它們彼此是等價的:
*gopl.io/ch4/embed*
~~~
w = Wheel{Circle{Point{8, 8}, 5}, 20}
w = Wheel{
Circle: Circle{
Point: Point{X: 8, Y: 8},
Radius: 5,
},
Spokes: 20, // NOTE: trailing comma necessary here (and at Radius)
}
fmt.Printf("%#v\n", w)
// Output:
// Wheel{Circle:Circle{Point:Point{X:8, Y:8}, Radius:5}, Spokes:20}
w.X = 42
fmt.Printf("%#v\n", w)
// Output:
// Wheel{Circle:Circle{Point:Point{X:42, Y:8}, Radius:5}, Spokes:20}
~~~
需要注意的是Printf函數中%v參數包含的#副詞,它表示用和Go語言類似的語法打印值。對于結構體類型來說,將包含每個成員的名字。
因為匿名成員也有一個隱式的名字,因此不能同時包含兩個類型相同的匿名成員,這會導致名字沖突。同時,因為成員的名字是由其類型隱式地決定的,所有匿名成員也有可見性的規則約束。在上面的例子中,Point和Circle匿名成員都是導出的。即使它們不導出(比如改成小寫字母開頭的point和circle),我們依然可以用簡短形式訪問匿名成員嵌套的成員
~~~
w.X = 8 // equivalent to w.circle.point.X = 8
~~~
但是在包外部,因為circle和point沒有導出不能訪問它們的成員,因此簡短的匿名成員訪問語法也是禁止的。
到目前為止,我們看到匿名成員特性只是對訪問嵌套成員的點運算符提供了簡短的語法糖。稍后,我們將會看到匿名成員并不要求是結構體類型;其實任何命名的類型都可以作為結構體的匿名成員。但是為什么要嵌入一個沒有任何子成員類型的匿名成員類型呢?
答案是匿名類型的方法集。簡短的點運算符語法可以用于選擇匿名成員嵌套的成員,也可以用于訪問它們的方法。實際上,外層的結構體不僅僅是獲得了匿名成員類型的所有成員,而且也獲得了該類型導出的全部的方法。這個機制可以用于將一個有簡單行為的對象組合成有復雜行為的對象。組合是Go語言中面向對象編程的核心,我們將在6.3節中專門討論。
### 4.5. JSON
JavaScript對象表示法(JSON)是一種用于發送和接收結構化信息的標準協議。在類似的協議中,JSON并不是唯一的一個標準協議。 XML(§7.14)、ASN.1和Google的Protocol Buffers都是類似的協議,并且有各自的特色,但是由于簡潔性、可讀性和流行程度等原因,JSON是應用最廣泛的一個。
Go語言對于這些標準格式的編碼和解碼都有良好的支持,由標準庫中的encoding/json、encoding/xml、encoding/asn1等包提供支持(譯注:Protocol Buffers的支持由 github.com/golang/protobuf 包提供),并且這類包都有著相似的API接口。本節,我們將對重要的encoding/json包的用法做個概述。
JSON是對JavaScript中各種類型的值——字符串、數字、布爾值和對象——Unicode本文編碼。它可以用有效可讀的方式表示第三章的基礎數據類型和本章的數組、slice、結構體和map等聚合數據類型。
基本的JSON類型有數字(十進制或科學記數法)、布爾值(true或false)、字符串,其中字符串是以雙引號包含的Unicode字符序列,支持和Go語言類似的反斜杠轉義特性,不過JSON使用的是-16編碼(譯注:UTF-16和UTF-8一樣是一種變長的編碼,有些Unicode碼點較大的字符需要用4個字節表示;而且UTF-16還有大端和小端的問題),而不是Go語言的rune類型。
這些基礎類型可以通過JSON的數組和對象類型進行遞歸組合。一個JSON數組是一個有序的值序列,寫在一個方括號中并以逗號分隔;一個JSON數組可以用于編碼Go語言的數組和slice。一個JSON對象是一個字符串到值的映射,寫成以系列的name:value對形式,用花括號包含并以逗號分隔;JSON的對象類型可以用于編碼Go語言的map類型(key類型是字符串)和結構體。例如:
~~~
boolean true
number -273.15
string "She said \"Hello, BF\""
array ["gold", "silver", "bronze"]
object {"year": 1980,
"event": "archery",
"medals": ["gold", "silver", "bronze"]}
~~~
考慮一個應用程序,該程序負責收集各種電影評論并提供反饋功能。它的Movie數據類型和一個典型的表示電影的值列表如下所示。(在結構體聲明中,Year和Color成員后面的字符串面值是結構體成員Tag;我們稍后會解釋它的作用。)
*gopl.io/ch4/movie*
~~~
type Movie struct {
Title string
Year int `json:"released"`
Color bool `json:"color,omitempty"`
Actors []string
}
var movies = []Movie{
{Title: "Casablanca", Year: 1942, Color: false,
Actors: []string{"Humphrey Bogart", "Ingrid Bergman"}},
{Title: "Cool Hand Luke", Year: 1967, Color: true,
Actors: []string{"Paul Newman"}},
{Title: "Bullitt", Year: 1968, Color: true,
Actors: []string{"Steve McQueen", "Jacqueline Bisset"}},
// ...
}
~~~
這樣的數據結構特別適合JSON格式,并且在兩種之間相互轉換也很容易。將一個Go語言中類似movies的結構體slice轉為JSON的過程叫編組(marshaling)。編組通過調用json.Marshal函數完成:
~~~
data, err := json.Marshal(movies)
if err != nil {
log.Fatalf("JSON marshaling failed: %s", err)
}
fmt.Printf("%s\n", data)
~~~
Marshal函數返還一個編碼后的字節slice,包含很長的字符串,并且沒有空白縮進;我們將它折行以便于顯示:
~~~
[{"Title":"Casablanca","released":1942,"Actors":["Humphrey Bogart","Ingr
id Bergman"]},{"Title":"Cool Hand Luke","released":1967,"color":true,"Ac
tors":["Paul Newman"]},{"Title":"Bullitt","released":1968,"color":true,"
Actors":["Steve McQueen","Jacqueline Bisset"]}]
~~~
這種緊湊的表示形式雖然包含了全部的信息,但是很難閱讀。為了生成便于閱讀的格式,另一個json.MarshalIndent函數將產生整齊縮進的輸出。該函數有兩個額外的字符串參數用于表示每一行輸出的前綴和每一個層級的縮進:
~~~
data, err := json.MarshalIndent(movies, "", " ")
if err != nil {
log.Fatalf("JSON marshaling failed: %s", err)
}
fmt.Printf("%s\n", data)
~~~
上面的代碼將產生這樣的輸出(譯注:在最后一個成員或元素后面并沒有逗號分隔符):
~~~
[
{
"Title": "Casablanca",
"released": 1942,
"Actors": [
"Humphrey Bogart",
"Ingrid Bergman"
]
},
{
"Title": "Cool Hand Luke",
"released": 1967,
"color": true,
"Actors": [
"Paul Newman"
]
},
{
"Title": "Bullitt",
"released": 1968,
"color": true,
"Actors": [
"Steve McQueen",
"Jacqueline Bisset"
]
}
]
~~~
在編碼時,默認使用Go語言結構體的成員名字作為JSON的對象(通過reflect反射技術,我們將在12.6節討論)。只有導出的結構體成員才會被編碼,這也就是我們為什么選擇用大寫字母開頭的成員名稱。
細心的讀者可能已經注意到,其中Year名字的成員在編碼后變成了released,還有Color成員編碼后變成了小寫字母開頭的color。這是因為構體成員Tag所導致的。一個構體成員Tag是和在編譯階段關聯到該成員的元信息字符串:
~~~
Year int `json:"released"`
Color bool `json:"color,omitempty"`
~~~
結構體的成員Tag可以是任意的字符串面值,但是通常是一系列用空格分隔的key:"value"鍵值對序列;因為值中含義雙引號字符,因此成員Tag一般用原生字符串面值的形式書寫。json開頭鍵名對應的值用于控制encoding/json包的編碼和解碼的行為,并且encoding/...下面其它的包也遵循這個約定。成員Tag中json對應值的第一部分用于指定JSON對象的名字,比如將Go語言中的TotalCount成員對應到JSON中的total_count對象。Color成員的Tag還帶了一個額外的omitempty選項,表示當Go語言結構體成員為空或零值時不生成JSON對象(這里false為零值)。果然,Casablanca是一個黑白電影,并沒有輸出Color成員。
編碼的逆操作是解碼,對應將JSON數據解碼為Go語言的數據結構,Go語言中一般叫unmarshaling,通過json.Unmarshal函數完成。下面的代碼將JSON格式的電影數據解碼為一個結構體slice,結構體中只有Title成員。通過定義合適的Go語言數據結構,我們可以選擇性地解碼JSON中感興趣的成員。當Unmarshal函數調用返回,slice將被只含有Title信息值填充,其它JSON成員將被忽略。
~~~
var titles []struct{ Title string }
if err := json.Unmarshal(data, &titles); err != nil {
log.Fatalf("JSON unmarshaling failed: %s", err)
}
fmt.Println(titles) // "[{Casablanca} {Cool Hand Luke} {Bullitt}]"
~~~
許多web服務都提供JSON接口,通過HTTP接口發送JSON格式請求并返回JSON格式的信息。為了說明這一點,我們通過Github的issue查詢服務來演示類似的用法。首先,我們要定義合適的類型和常量:
*gopl.io/ch4/github*
~~~
// Package github provides a Go API for the GitHub issue tracker.
// See https://developer.github.com/v3/search/#search-issues.
package github
import "time"
const IssuesURL = "https://api.github.com/search/issues"
type IssuesSearchResult struct {
TotalCount int `json:"total_count"`
Items []*Issue
}
type Issue struct {
Number int
HTMLURL string `json:"html_url"`
Title string
State string
User *User
CreatedAt time.Time `json:"created_at"`
Body string // in Markdown format
}
type User struct {
Login string
HTMLURL string `json:"html_url"`
}
~~~
和前面一樣,即使對應的JSON對象名是小寫字母,每個結構體的成員名也是聲明為大小字母開頭的。因為有些JSON成員名字和Go結構體成員名字并不相同,因此需要Go語言結構體成員Tag來指定對應的JSON名字。同樣,在解碼的時候也需要做同樣的處理,GitHub服務返回的信息比我們定義的要多很多。
SearchIssues函數發出一個HTTP請求,然后解碼返回的JSON格式的結果。因為用戶提供的查詢條件可能包含類似`?`和`&`之類的特殊字符,為了避免對URL造成沖突,我們用url.QueryEscape來對查詢中的特殊字符進行轉義操作。
*gopl.io/ch4/github*
~~~
package github
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
)
// SearchIssues queries the GitHub issue tracker.
func SearchIssues(terms []string) (*IssuesSearchResult, error) {
q := url.QueryEscape(strings.Join(terms, " "))
resp, err := http.Get(IssuesURL + "?q=" + q)
if err != nil {
return nil, err
}
// We must close resp.Body on all execution paths.
// (Chapter 5 presents 'defer', which makes this simpler.)
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("search query failed: %s", resp.Status)
}
var result IssuesSearchResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
resp.Body.Close()
return nil, err
}
resp.Body.Close()
return &result, nil
}
~~~
在早些的例子中,我們使用了json.Unmarshal函數來將JSON格式的字符串解碼為字節slice。但是這個例子中,我們使用了基于流式的解碼器json.Decoder,它可以從一個輸入流解碼JSON數據,盡管這不是必須的。如您所料,還有一個針對輸出流的json.Encoder編碼對象。
我們調用Decode方法來填充變量。這里有多種方法可以格式化結構。下面是最簡單的一種,以一個固定寬度打印每個issue,但是在下一節我們將看到如果利用模板來輸出復雜的格式。
*gopl.io/ch4/issues*
~~~
// Issues prints a table of GitHub issues matching the search terms.
package main
import (
"fmt"
"log"
"os"
"gopl.io/ch4/github"
)
func main() {
result, err := github.SearchIssues(os.Args[1:])
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d issues:\n", result.TotalCount)
for _, item := range result.Items {
fmt.Printf("#%-5d %9.9s %.55s\n",
item.Number, item.User.Login, item.Title)
}
}
~~~
通過命令行參數指定檢索條件。下面的命令是查詢Go語言項目中和JSON解碼相關的問題,還有查詢返回的結果:
~~~
$ go build gopl.io/ch4/issues
$ ./issues repo:golang/go is:open json decoder
13 issues:
#5680 eaigner encoding/json: set key converter on en/decoder
#6050 gopherbot encoding/json: provide tokenizer
#8658 gopherbot encoding/json: use bufio
#8462 kortschak encoding/json: UnmarshalText confuses json.Unmarshal
#5901 rsc encoding/json: allow override type marshaling
#9812 klauspost encoding/json: string tag not symmetric
#7872 extempora encoding/json: Encoder internally buffers full output
#9650 cespare encoding/json: Decoding gives errPhase when unmarshalin
#6716 gopherbot encoding/json: include field name in unmarshal error me
#6901 lukescott encoding/json, encoding/xml: option to treat unknown fi
#6384 joeshaw encoding/json: encode precise floating point integers u
#6647 btracey x/tools/cmd/godoc: display type kind of each named type
#4237 gjemiller encoding/base64: URLEncoding padding is optional
~~~
GitHub的Web服務接口 https://developer.github.com/v3/ 包含了更多的特性。
**練習 4.10:** 修改issues程序,根據問題的時間進行分類,比如不到一個月的、不到一年的、超過一年。
**練習 4.11:** 編寫一個工具,允許用戶在命令行創建、讀取、更新和關閉GitHub上的issue,當必要的時候自動打開用戶默認的編輯器用于輸入文本信息。
**練習 4.12:** 流行的web漫畫服務xkcd也提供了JSON接口。例如,一個 https://xkcd.com/571/info.0.json 請求將返回一個很多人喜愛的571編號的詳細描述。下載每個鏈接(只下載一次)然后創建一個離線索引。編寫一個xkcd工具,使用這些離線索引,打印和命令行輸入的檢索詞相匹配的漫畫的URL。
**練習 4.13:** 使用開放電影數據庫的JSON服務接口,允許你檢索和下載 https://omdbapi.com/ 上電影的名字和對應的海報圖像。編寫一個poster工具,通過命令行輸入的電影名字,下載對應的海報。
### 4.6. 文本和HTML模板
前面的例子,只是最簡單的格式化,使用Printf是完全足夠的。但是有時候會需要復雜的打印格式,這時候一般需要將格式化代碼分離出來以便更安全地修改。這寫功能是由text/template和html/template等模板包提供的,它們提供了一個將變量值填充到一個文本或HTML格式的模板的機制。
一個模板是一個字符串或一個文件,里面包含了一個或多個由雙花括號包含的`{{action}}`對象。大部分的字符串只是按面值打印,但是對于actions部分將觸發其它的行為。每個actions都包含了一個用模板語言書寫的表達式,一個action雖然簡短但是可以輸出復雜的打印值,模板語言包含通過選擇結構體的成員、調用函數或方法、表達式控制流if-else語句和range循環語句,還有其它實例化模板等諸多特性。下面是一個簡單的模板字符串:
{% raw %}
*gopl.io/ch4/issuesreport*
~~~
const templ = `{{.TotalCount}} issues:
{{range .Items}}----------------------------------------
Number: {{.Number}}
User: {{.User.Login}}
Title: {{.Title | printf "%.64s"}}
Age: {{.CreatedAt | daysAgo}} days
{{end}}`
~~~
{% endraw %}
{% raw %}
這個模板先打印匹配到的issue總數,然后打印每個issue的編號、創建用戶、標題還有存在的時間。對于每一個action,都有一個當前值的概念,對應點操作符,寫作“.”。當前值“.”最初被初始化為調用模板是的參數,在當前例子中對應github.IssuesSearchResult類型的變量。模板中`{{.TotalCount}}`對應action將展開為結構體中TotalCount成員以默認的方式打印的值。模板中`{{range .Items}}`和`{{end}}`對應一個循環action,因此它們直接的內容可能會被展開多次,循環每次迭代的當前值對應當前的Items元素的值。
{% endraw %}
在一個action中,`|`操作符表示將前一個表達式的結果作為后一個函數的輸入,類似于UNIX中管道的概念。在Title這一行的action中,第二個操作是一個printf函數,是一個基于fmt.Sprintf實現的內置函數,所有模板都可以直接使用。對于Age部分,第二個動作是一個叫daysAgo的函數,通過time.Since函數將CreatedAt成員轉換為過去的時間長度:
~~~
func daysAgo(t time.Time) int {
return int(time.Since(t).Hours() / 24)
}
~~~
需要注意的是CreatedAt的參數類型是time.Time,并不是字符串。以同樣的方式,我們可以通過定義一些方法來控制字符串的格式化(§2.5),一個類型同樣可以定制自己的JSON編碼和解碼行為。time.Time類型對應的JSON值是一個標準時間格式的字符串。
生成模板的輸出需要兩個處理步驟。第一步是要分析模板并轉為內部表示,然后基于指定的輸入執行模板。分析模板部分一般只需要執行一次。下面的代碼創建并分析上面定義的模板templ。注意方法調用鏈的順序:template.New先創建并返回一個模板;Funcs方法將daysAgo等自定義函數注冊到模板中,并返回模板;最后調用Parse函數分析模板。
~~~
report, err := template.New("report").
Funcs(template.FuncMap{"daysAgo": daysAgo}).
Parse(templ)
if err != nil {
log.Fatal(err)
}
~~~
因為模板通常在編譯時就測試好了,如果模板解析失敗將是一個致命的錯誤。template.Must輔助函數可以簡化這個致命錯誤的處理:它接受一個模板和一個error類型的參數,檢測error是否為nil(如果不是nil則發出panic異常),然后返回傳入的模板。我們將在5.9節再討論這個話題。
一旦模板已經創建、注冊了daysAgo函數、并通過分析和檢測,我們就可以使用github.IssuesSearchResult作為輸入源、os.Stdout作為輸出源來執行模板:
~~~
var report = template.Must(template.New("issuelist").
Funcs(template.FuncMap{"daysAgo": daysAgo}).
Parse(templ))
func main() {
result, err := github.SearchIssues(os.Args[1:])
if err != nil {
log.Fatal(err)
}
if err := report.Execute(os.Stdout, result); err != nil {
log.Fatal(err)
}
}
~~~
程序輸出一個純文本報告:
~~~
$ go build gopl.io/ch4/issuesreport
$ ./issuesreport repo:golang/go is:open json decoder
13 issues:
----------------------------------------
Number: 5680
User: eaigner
Title: encoding/json: set key converter on en/decoder
Age: 750 days
----------------------------------------
Number: 6050
User: gopherbot
Title: encoding/json: provide tokenizer
Age: 695 days
----------------------------------------
...
~~~
現在讓我們轉到html/template模板包。它使用和text/template包相同的API和模板語言,但是增加了一個將字符串自動轉義特性,這可以避免輸入字符串和HTML、JavaScript、CSS或URL語法產生沖突的問題。這個特性還可以避免一些長期存在的安全問題,比如通過生成HTML注入攻擊,通過構造一個含有惡意代碼的問題標題,這些都可能讓模板輸出錯誤的輸出,從而讓他們控制頁面。
下面的模板以HTML格式輸出issue列表。注意import語句的不同:
{% raw %}
*gopl.io/ch4/issueshtml*
~~~
import "html/template"
var issueList = template.Must(template.New("issuelist").Parse(`
<h1>{{.TotalCount}} issues</h1>
<table>
<tr style='text-align: left'>
<th>#</th>
<th>State</th>
<th>User</th>
<th>Title</th>
</tr>
{{range .Items}}
<tr>
<td><a href='{{.HTMLURL}}'>{{.Number}}</a></td>
<td>{{.State}}</td>
<td><a href='{{.User.HTMLURL}}'>{{.User.Login}}</a></td>
<td><a href='{{.HTMLURL}}'>{{.Title}}</a></td>
</tr>
{{end}}
</table>
`))
~~~
{% endraw %}
下面的命令將在新的模板上執行一個稍微不同的查詢:
~~~
$ go build gopl.io/ch4/issueshtml
$ ./issueshtml repo:golang/go commenter:gopherbot json encoder >issues.html
~~~
圖4.4顯示了在web瀏覽器中的效果圖。每個issue包含到Github對應頁面的鏈接。

圖4.4中issue沒有包含會對HTML格式產生沖突的特殊字符,但是我們馬上將看到標題中含有`&`和`<`字符的issue。下面的命令選擇了兩個這樣的issue:
~~~
$ ./issueshtml repo:golang/go 3133 10535 >issues2.html
~~~
圖4.5顯示了該查詢的結果。注意,html/template包已經自動將特殊字符轉義,因此我們依然可以看到正確的字面值。如果我們使用text/template包的話,這2個issue將會產生錯誤,其中“<”四個字符將會被當作小于字符“<”處理,同時“<link>”字符串將會被當作一個鏈接元素處理,它們都會導致HTML文檔結構的改變,從而導致有未知的風險。
我們也可以通過對信任的HTML字符串使用template.HTML類型來抑制這種自動轉義的行為。還有很多采用類型命名的字符串類型分別對應信任的JavaScript、CSS和URL。下面的程序演示了兩個使用不同類型的相同字符串產生的不同結果:A是一個普通字符串,B是一個信任的template.HTML字符串類型。

{% raw %}
*gopl.io/ch4/autoescape*
~~~
func main() {
const templ = `<p>A: {{.A}}</p><p>B: {{.B}}</p>`
t := template.Must(template.New("escape").Parse(templ))
var data struct {
A string // untrusted plain text
B template.HTML // trusted HTML
}
data.A = "<b>Hello!</b>"
data.B = "<b>Hello!</b>"
if err := t.Execute(os.Stdout, data); err != nil {
log.Fatal(err)
}
}
~~~
{% endraw %}
圖4.6顯示了出現在瀏覽器中的模板輸出。我們看到A的黑體標記被轉義失效了,但是B沒有。

我們這里只講述了模板系統中最基本的特性。一如既往,如果想了解更多的信息,請自己查看包文檔:
~~~
$ go doc text/template
$ go doc html/template
~~~
**練習 4.14:** 創建一個web服務器,查詢一次GitHub,然后生成BUG報告、里程碑和對應的用戶信息。
- Go語言程序設計
- Go語言圣經(中文版)
- 譯者序
- 前言
- 第一章 入門
- 第二章 程序結構
- 第三章 基礎數據類型
- 第四章 復合數據類型
- 第五章 函數
- 第六章 方法
- 第七章 接口
- 7.4. flag.Value接口
- 7.6. sort.Interface接口
- 7.7. http.Handler接口
- 第八章 Goroutines和Channels
- 第九章 基于共享變量的并發
- 9.2. sync.Mutex互斥鎖
- 第十章 包和工具
- 第十一章 測試
- 第十二章 反射
- 12.2. reflect.Type和reflect.Value
- 12.5. 通過reflect.Value修改值
- 第13章 底層編程
- 13.1. unsafe.Sizeof, Alignof 和 Offsetof
- 附錄