云原生系列Go語言篇-復合類型

本文來自正在規(guī)劃的Go語言&云原生自我提升系列,歡迎關注后續(xù)文章。

在上一篇文章中,我們學習了一些簡單類型:數值、布爾值和字符串。本文中我們會學習 Go 中的復合類型、它們所支持的內置函數以及使用的最佳實踐。

數組-古板不宜直接使用

和大部分編程語言一樣,Go語言也有數組。但在 Go 中很少直接使用數組。一會我們就知道個中緣由了,但我們首先來快速講解數組的聲明語法及使用。

數組中的所有元素都必須是指定的類型(這并不表示一定是同一類型)。有一些聲明方式。第一種是指定數組大小及數組中元素的類型:

var x [3]int

這會創(chuàng)建包含3個int類型元素的數組。因為我們并未指定值,所有位置(x[0]、x[1]x[2]) 都使用int的零值初始化,當然也就是0了。如果數組有初始化值,可以通過數組字面量進行指定:

var x = [3]int{10, 20, 30}

如果有一個稀疏數組(數組中大部分元素都設為零值),可以在數組字面量中僅指定有值元素的索引:

var x = [12]int{1, 5: 4, 6, 10: 100, 15}

這會創(chuàng)建一個包含以下12個int值的數組:[1, 0, 0, 0, 0, 4, 6, 0, 0, 0, 100, 15]。

在使用數組字面量初始化數組時,可以不寫數量,直接使用

var x = [...]int{10, 20, 30}

可使用==!=比較數組:

var x = [...]int{1, 2, 3}
var y = [3]int{1, 2, 3}
fmt.Println(x == y) // 打印true

Go僅包含一維數組,但可以模擬出多維數組:

var x [2][3]int

以上聲明了一個長度為2的數組,數組類型為長度是3的int數組。聽起來有些繞,但有些語言是支持矩陣的,只是 Go 并不是這種語言。

和大部分語言一樣,Go中的數組通過中括號語法進行讀寫:

x[0] = 10
fmt.Println(x[2])

無法讀寫到數組以外或是使用負數索引。如果使用常量或字面量這么做的話,會報編譯時錯誤。使用變量越界讀寫編譯可通過但在運行時會失敗報panic(我們會在錯誤處理一文的panic和recover一節(jié)中深入討論 panic)。

最后,內置函數len接收數組并返回其長度:

fmt.Println(len(x))

之前說過在 Go 中很少顯式使用數組。這是因為它存在著不一般的限制:Go 認為數組的大小是數組類型的一部分。這樣聲明為[3]int的數組與聲明為[4]int的數組屬于不同類型。也就是說不能使用變量來指定數組大小,因為類型需要在編譯期就解析出來,不能等到運行時。

還有就是不同對同類型不同大小的數組進行類型轉換。因為無法轉換不同大小的數組,也就無法編寫函數操作各種大小的數組或是對同一變量賦不同大小的數組。

注:我們會在指針那一篇文章中討論數組背后的內存布局。

由于有這么多限制,除非提前知道具體長度否則請不要使用數組。比如標準庫中的某些加密函數會返回數組,那是因為校驗和的大小也在算法中做了定義。這是例外,而不是規(guī)則。

這就拋出了那個問題:為什么在 Go要有這么多限制呢?主要原因是在 Go 中數組主要是為切片提供背后的存儲,切片可以說是 Go中最重要的特性之一。

切片

大部分想用到存儲值序列的數據結構時,都應當使用切片。切片好用之處在于大小并不是類型的一部分。這樣就沒有了數組的限制。我們可以編寫一個函數處理任意大小的切片(在函數一文中會講到函數),切片可按需擴容。在講解完Go中切片的基本用法之后,我們會聊到使用它的最佳方式。

操作切片和數組有些像,但有一些細微差別。首先要注意我們在聲明時沒有指定切片的大?。?/p>

var x = []int{10, 20, 30}

小貼士:使用[…]會產生數組。使用[]會產生切片。

以上創(chuàng)建的切片使用了包含3個int的切片字面量。類似數組,我們也可以在切片字面量中僅指定索引和值:

var x = []int{1, 5: 4, 6, 10: 100, 15}

以上創(chuàng)建的是12個int的切片,值為[1, 0, 0, 0, 0, 4, 6, 0, 0, 0, 100, 15]。

可以模擬出多維切片創(chuàng)建切片的切片:

var x [][]int

我們可以使用中括號語法讀取和寫入切片,同時和數組一樣,不能越界讀寫也不能使用負值索引:

x[0] = 10
fmt.Println(x[2])

到這里切片似乎和數組并不沒有什么差別。我們先通過不使用字面量聲明切片來看數組和切片的差別:

var x []int

它會創(chuàng)建一個int切片。因為沒有賦值,x被賦了切片的零值nil,這個我們前面不沒有講過。我們會在指針這一章討論nil,它與其它語言中的null有些微不同。在Go中,nil是在一些類型缺少值時用于表示的標識符。和前面文章中學習無類型數值常量不同,nil不存豐類型,因此可以將其賦值給其它類型或與其它類型比較。nil切片中什么都沒有。

切片是我們所學的第一種無法比較的類型。對兩個切片使用==比較是否相同或!=比較是否不同會報編譯時錯誤。唯一能和切片進行比較的是nil

fmt.Println(x == nil) // 打印true

小貼士:reflect包中包含一個名為DeepEqual的函數,可對任何類型進行比較,包括切片。這主要用于測試,但在需要時也可以使用它比較切片。我們會在惡龍三劍客:反射、Unsafe 和 Cgo一文中討論反射。

len

Go 提供了一些內置函數可操作內置類型。我們已經用到過complexrealimag內置函數對復數進行構建或提取元素。切片也有一些內置函數。我們在學習數組時已經用到過len函數。切片也可以使用它,將nil切片傳遞給len時,會返回0.

注:Go中內置了len這樣的函數,是因為在手寫的代碼中無法實現這一功能。我們已經知道len的參數可為任意數組或切片。很快就會學到它也可用于字符串和map。在并發(fā)一文中,我們會學習如何對通道使用它。將其它類型變量傳遞給len會報編譯時錯誤。我們會在函數一文中學到,Go不允許開發(fā)者編寫這樣的函數。

append

內置的append函數用于對切片追加內容:

var x []int
x = append(x, 10)

append函數至少接收兩個參數,任意類型的切片以及該類型的值。它返回同類型的切片。返回的切片賦值回所傳入的切片。本例中,我們對nil切片進行追加,但也可以對已有元素的切片進行追加:

var x = []int{1, 2, 3}
x = append(x, 4)

可以同時追加多個值:

x = append(x, 5, 6, 7)

將切片追加到另一個切片可使用運算符將源切片展開為各個值(我們會在函數一文中的可變輸入參數和切片會深入講解運算符):

y := []int{20, 30, 40}
x = append(x, y...)

如果忘記對append返回的值賦值會報編譯時錯誤。讀者可能會想為什么看起來有些重復。我們會在函數一文中進下講解,但 Go 是一種值傳遞的語言。每次對函數傳參時,Go會復制一份所傳入的值。將切片傳入append函數實際上是傳遞的是切片的拷貝。該函數將值添加到切片的拷貝中,再返回這個拷貝。然后將返回的切片賦回給調用函數中的變量。

容量

我們已經知道切片就是一個值序列。切片中的每個元素都分配在一段連續(xù)的內存地址上,這樣讀寫值都非常快速。每個切片都有一個容量,這正是所保留的連續(xù)內存地址。容量可大于長度。每次對切片進行追加時,都會在切片的最后添加一個或多個值。每個值都會讓切片的長度增加1。在長度到達容量時,就沒有剩余空間放值了。如果在長度等于容量時再增加值,append函數會使用 Go運行時分配一段容量更大的新切片。原切片中的值會拷貝到新切片中,新增值放在切片其最后,返回新切片。

GO運行時

每種高階編程語言都依賴一組庫來讓這種語言編寫的程序可以運行,Go也不例外。Go運行時提供了像內存分配、垃圾回收、并發(fā)支持、網絡、內置類型和函數實現等服務。

Go運行時編譯成一個個Go二進制。這與使用虛擬機的編程語言不同,那會需要單獨安裝虛擬機才能上編寫的程序運行。在二進制文件中包含運行時讓 Go 程序的分發(fā)更容易,不必擔心運行時和程序之間的兼容性問題。

在使用append追加切片時,Go運行時會花費時間分配新內存,將已有數據從老內存拷貝到新內存上。老內存同樣需要進行垃圾回收。因此,Go 運行時通常會在每次超出容量時新增1個以上。目前Go的規(guī)則是在容量小于1,024時會增至雙倍,之后每次至少新增25%。

就像內置len函數返回的是切片的當前長度,內置cap函數返回的是切片的當前容量。它的使用頻率遠低于len。大多數時候,cap用于檢測切片是否夠存儲新數據,或是在調用make時用于新建切片。

我們也可以對cap函數傳數組,但cap返回的值和len的值總是一致的。這個不要在代碼中使用,炫技時再用吧。

我們來學習對切片添加元素是如何修改其升度和容量的。在The Go Playground 中運行例3-1中的代碼。

例3-1 理解容量

var x []int
fmt.Println(x, len(x), cap(x))
x = append(x, 10)
fmt.Println(x, len(x), cap(x))
x = append(x, 20)
fmt.Println(x, len(x), cap(x))
x = append(x, 30)
fmt.Println(x, len(x), cap(x))
x = append(x, 40)
fmt.Println(x, len(x), cap(x))
x = append(x, 50)
fmt.Println(x, len(x), cap(x))

構建運行以上代碼,就會看到如下的輸出。注意容量是在何時以及如何增加的:

[] 0 0
[10] 1 1
[10 20] 2 2
[10 20 30] 3 4
[10 20 30 40] 4 4
[10 20 30 40 50] 5 8

雖然切片自動擴容很不錯,但只進行一次大小設定會更高效。如果知道要在切片中放多少內容的話,可使用對應的初始容量創(chuàng)建切片。我們通過make函數實現。

make

我們已經學習了兩種聲明切片的方法,即使用切片字面量或nil零值。雖然可用,但這兩種方式都無法創(chuàng)建指定長度或容量的空切片。make內置函數就是用來完成這一任務的??赏ㄟ^它指定類型、長度或是容量(可選)。如下:

x := make([]int, 5)

這會創(chuàng)建一個長度為5、容量也為5的int切片。因其長度為5,x[0]x[4]都是有效元素,全部初始化為0.

初學者常見的錯誤是會嘗試使用append來對其添加初始元素:

x := make([]int, 5)
x = append(x, 10)

下面的10會放到切片尾部,在0-4位置的0零值之后,因為append總是會增加切片的長度?,F在x的值為[0 0 0 0 0 10],長度是6,容量是10(在追加第6個元素時容量會加倍)。

我們還可以通過make指定初始容量:

x := make([]int, 5, 10)

這會創(chuàng)建一個長度為5、容量為10的int切片。

還可創(chuàng)建長度為0、容量大于0的切片:

x := make([]int, 0, 10)

這時,我們擁有一個長度為0的非nil切片,容量為10。因其長度為0,不能直接使用索引訪問,但可以對其追加值:

x := make([]int, 0, 10)
x = append(x, 5,6,7,8)

此時x的值是[5 6 7 8],長度為4,容量為10.

警告:不要把容量指定小于長度。使用常量或數值字面量指定時會報編譯時錯誤。而如果使用變量指定小于長度的容量時,程序會在運行時panic。

聲明切片

我們已經學習了所有這些創(chuàng)建切片的方式,那么該選擇哪種切片聲明方式呢?主要目標是讓切片擴容的次數降至最低。如果切片完全不會有新增(可能是因為函數不返回內容),使用var不帶值聲明來創(chuàng)建一個nil切片,就像例3-2中那樣。

例3-2 聲明可能保持為nil的切片

var data []int

注:可以使用空切片字面量來聲明切片:

var x = []int{}

這會創(chuàng)建一個長度為0的切片,它不是nil(與nil進行比對會返回false)。除此之外零長切片與nil切片并沒有不同。零長切片唯一有用的場景是在將切片轉化為JSON時。我們會在標準庫一文中的encoding/json中深入講解。

如果有一些初始值或是切片值不會發(fā)生改變,那么切片字面量會是一個不錯的選擇(見例3-3)。

例3-3 使用默認值聲明切片

data := []int{2, 4, 6, 8} // 用到的值

如果清楚需要的切片有多大,但在編寫程序時尚不知道值是什么,使用make。接下來的問題是是否應在調用make時指定非零長度或指定零長和非零容量。有3種可能:

  • 如果將切片用作緩沖(參見標準庫一文中的io和它的朋友們),則指定非零長度。
  • 如果確定知道具體大小,可指定長度并按索引對切片設置值。通常這用于在一個切片中 轉換值并存儲在第二個切片中。這種方法的問題是如果大小錯誤,最終會在切片末尾得到的是零值或是因訪問不存在的元素而panic。
  • 其它場景使用零長和指定容量的make。這樣我們可以使用append對切片追加內容。如果內容量更少,就不會在末端有大量的零值。如果內容量更多,代碼也不會panic。

在第二種和第三種方法上 Go 社區(qū)存在分歧。我個人偏向于使用append對初始化為零長的切片追加內容。在某些場景下可能會更慢,但通常不會產生bug。

警告:append必會增加切片的長度。如果使用make指定了切片的長度,請確保目的是要對它們追加內容,否則可能會在切片的一開始有一堆預想外的零值。

對切片進行切片

切片表達式會對切片創(chuàng)建切片。寫法是在中括號中包含起始偏移量和結束偏移量,中間用英文冒號(:)分隔。如果沒寫初始偏移量,則默認為0。類似地,如果省掉了結束偏移量,則替換為切片的結尾。可在The Go Playground運行例3-4中的代碼查看。

例3-4 對切片進行切片

x := []int{1, 2, 3, 4}
y := x[:2]
z := x[1:]
d := x[1:3]
e := x[:]
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)
fmt.Println("d:", d)
fmt.Println("e:", e)

輸出的結果為:

x: [1 2 3 4]
y: [1 2]
z: [2 3 4]
d: [2 3]
e: [1 2 3 4]

切片有時會共享內存

對切片進行切片時,并不是拷貝該數據。此時兩個變量會共享內存。也就是說修改一個切片中的元素會影響到共享到該元素的所有切片。我們來試下修改值會發(fā)生什么??梢栽?a target="_blank">The Go Playground運行例3-5中的代碼。

例3-5 存儲有重疊的切片

x := []int{1, 2, 3, 4}
y := x[:2]
z := x[1:]
x[1] = 20
y[0] = 10
z[1] = 30
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)

輸出結果為:

x: [10 20 30 4]
y: [10 20]
z: [20 30 4]

修改x同時會修改和yz,而修改yz也會修改x。

對切片進行切片在配合append使用時又多了一層困擾。在 The Go Playground.中測試例3-6中的代碼。

例3-6 append使重疊的切片變復雜

x := []int{1, 2, 3, 4}
y := x[:2]
fmt.Println(cap(x), cap(y))
y = append(y, 30)
fmt.Println("x:", x)
fmt.Println("y:", y)

運行以上代碼得到的結果如下:

4 4
x: [1 2 30 4]
y: [1 2 30]

怎么解釋呢?在對切片取切片時,子切片的容量為原切片容量減去子切片在原切片中的偏移量。也就是說原切片中所有未使用的容量都會被其子切片所共享。

在我們從x中取切片y時,長度為2,但容量為4,與x相同。因為容量是4,對y的結尾追加值時會放x的第三個位置上。

這種行為會導致在多切片追加和重寫數據時產生一些非常奇怪的場景。試試能不能猜到例3-7中代碼的打印結果,然后在The Go Playground中運行看猜的是否正確。

例3-7 更迷的切片

x := make([]int, 0, 5)
x = append(x, 1, 2, 3, 4)
y := x[:2]
z := x[2:]
fmt.Println(cap(x), cap(y), cap(z))
y = append(y, 30, 40, 50)
x = append(x, 60)
z = append(z, 70)
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)

為避免出現復雜的切片場景,要么永不對子切片使用append要么保證append使用全切片表達式從而不會導致重寫。這有點奇怪,但它可以讓我們很清楚父切片和子切片共享了多少內存。全切片表達包含一個第三部分,表示父切片容量中子切片可用的最后一個位置。用這個數字減去子切片的起始位置可得子切片的容量。例3-8中展示了使用全切片表達式修改上例中第三、四行后的結果。

例3-8 全切片表達式形式了對append操作的保護

y := x[:2:2]
z := x[2:4:4]

可以在The Go Playground上測試這段代碼。yz的容量都是2。因為我們將子切片的容量限制為其長度,對yz追加元素都會創(chuàng)建新的切片,不與其它切片相交叉。運行該段代碼后,x為[1 2 3 4 60],y為[1 2 30 40 50],z為[3 4 70]。

警告:在從切片中取切片時要相當小心!兩個切片共享內存,對一個修改會體現在另一個切片上。避免修改做過切片的切片或子切片。使用三段切片表達式防止對共享容量的切片的append。

將數組轉為切片

并不是只能對切片做切片。同樣可以使用切片表達式對數組做切片。通過這種方式對只接收切片的函數也可使用數組。但要注意對數組切片同樣存在內存共享的問題。在The Go Playground中運行如下代碼:

x := [4]int{5, 6, 7, 8}
y := x[:2]
z := x[2:]
x[0] = 10
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)

會得到如下輸出:

x: [10 6 7 8]
y: [10 6]
z: [7 8]

copy

如需創(chuàng)建獨立于原切片的切片,可使用內置的copy函數。我們一起來看個簡單的例子,可在The Go Playground:中運行:

x := []int{1, 2, 3, 4}
y := make([]int, 4)
num := copy(y, x)
fmt.Println(y, num)

得到的結果是:

[1 2 3 4] 4

copy函數接收兩個參數。第一個為目標切片,第二個是源切片。它從源切片將盡可能多的值拷貝到目標切片,上限為切片較小的那個,返回所拷貝到元素數。xy的容量并不產生影響,重要的是其長度。

不要求拷貝整個切片。以下代碼將4個元素的切片的前兩個拷貝到了一個兩元素切片中:

x := []int{1, 2, 3, 4}
y := make([]int, 2)
num = copy(y, x)

變量y為[1 2] ,num為2。

也可以從源切片的中間進行拷貝:

x := []int{1, 2, 3, 4}
y := make([]int, 2)
copy(y, x[2:])

我們通過對x取切片拷貝了其第三和第四個元素。同時注意我們并沒有將copy的輸出賦值給變量。如果不需要所拷貝的元素數,就不需要進行賦值。

copy函數允許對底層切片重疊的兩個切片執(zhí)行拷貝:

x := []int{1, 2, 3, 4}
num = copy(x[:3], x[1:])
fmt.Println(x, num)

上例中,我們是將x的后三個值拷貝到x的前三個位置上。打印結果為[2 3 4 4] 3。

可使用對提取了切片的數組使用copy??梢园褦到M作為拷貝的源或目標。在The Go Playground中運行以下代碼看看效果:

x := []int{1, 2, 3, 4}
d := [4]int{5, 6, 7, 8}
y := make([]int, 2)
copy(y, d[:])
fmt.Println(y)
copy(d[:], x)
fmt.Println(d)

第一次copy調用將數組d中的前兩個值拷貝到切片y中。第二次將切片x中的所有值拷貝到數組d中。得到的結果是:

[5 6]
[1 2 3 4]

字符串、符文和字節(jié)

我們已經討論完了切片,那么再回到字符串。讀者可能會覺得Go中的字符串由符文組成,但并非如此。深入下去會發(fā)現Go使用了字節(jié)序列來表示字符串。這些字節(jié)不限定為某一種編碼,但很多Go的庫函數(以及下一篇文章中會討論的for-range)會假定字符串由UTF-8編碼的代碼點序列組成。

注:根據語言規(guī)范,Go的源代碼使用UTF-8編寫。除非使用了十六進制在字符串字面量中進行了轉義,字符串字面量都是使用UTF-8進行編寫。

就像從數組或切片中取某個值一樣,可以通過索引表達式從字符串取一個值:

var s string = "Hello there"
var b byte = s[6]

字符串和數組、切片一樣,索引從0開始,上例中對b所賦的值為s的第7個值,即字母t。

我們在數組和切片中使用的切片表達式標記法同樣可用在字符串中:

var s string = "Hello there"
var s2 string = s[4:7]
var s3 string = s[:5]
var s4 string = s[6:]

這里s2對賦值o t,s3賦值Hello,s4賦值there

雖然Go中允許使用切片標記獲取子字符串以及使用索引標記提取單個值很方便,但在使用時應保持謹慎。因為字符串是不可變的,并不存在像切片那樣的修改問題。不過存在另一個問題。字符串由字節(jié)序列組成,而UTF-8中的代碼點長度可能為1到4個字節(jié)。上例中的字符串是由長度都是1字節(jié)的UTF-8代碼點組成,一切都和設想一樣。但在處理英文以外的語言或是表情符號時,可能會存在多字節(jié)UTF-8代碼點的情況:

var s string = "Hello ??"
var s2 string = s[4:7]
var s3 string = s[:5]
var s4 string = s[6:]

這個例子中,s3還是會等于Hello。變量s4設置為太陽表情符號。但s2并不是o ??,而是o ?。這是因為我們只拷貝了太陽表情的第一個字節(jié),這是一個無效字節(jié)。

Go允許對字符串使用內置的len函數獲取字符串長度。既然字符串的索引和切片表達式是以字節(jié)計算位置的,那么返回的是字節(jié)長度而非代碼點長度也就很自然了:

var s string = "Hello ??"
fmt.Println(len(s))

這段代碼打印的結果是10,而不是7,因為UTF-8中的太陽表情符號占用4個字節(jié)。

警告:雖然Go中允許對字符串使用切片和索引語法,請在知曉其中字符都為1字節(jié)時再使用。

因為符文、字符串和字節(jié)之間存在著復雜的關系,在Go中進行相互類型轉換時會有一些有趣的事情。單個符文或字節(jié)可轉換為字符串:

var a rune    = 'x'
var s string  = string(a)
var b byte    = 'y'
var s2 string = string(b)

警告:Go新手中常見的bug是使用類型轉換將int變成string

var x int = 65
var y = string(x)
fmt.Println(y)

這會導致y的值為A,而不是65。從Go 1.15開始,go vet不再允許runebyte之外的整型轉換為字符串。

字符串可與字節(jié)切片或符文切片之間相互轉換。在The Go Playground中試試例3-9中的代碼。

例3-9 將字符串轉換為切片

var s string = "Hello, ??"
var bs []byte = []byte(s)
var rs []rune = []rune(s)
fmt.Println(bs)
fmt.Println(rs)

運行這段代碼,得到的結果是:

[72 101 108 108 111 44 32 240 159 140 158]
[72 101 108 108 111 44 32 127774]

第一段中是將字符串轉換為UTF-8字節(jié)。第二段中將字符串轉換為符文。

Go中的大部分數據都是按字節(jié)序列進行讀寫,因此大部分常見的字符串類型轉換都是與字節(jié)切片之間的轉換。符文切片不太常見。

UTF-8

UTF-8是最常用的Unicode編碼。Unicode使用4個字節(jié)(32 bits)表示每個代碼點,代碼點為每個字符和修飾符的技術名稱。因此,表示Unicode代碼點最簡單的方式是用4個字節(jié)存儲每個代碼點。這稱為UTF-32。是最不常見的,因為它浪費了大量空間。按照Unicode的實現細節(jié),32比特中的11個保持為0。另一種常見的編碼是UTF-16,使用一個或兩個16比特(2字節(jié))序列來表示每個代碼點。這也有些浪費,大部分的內容(主要指英文)都可以放到只占用一個字節(jié)的代碼點中。這時便有了UTF-8。

UTF-8的做法很聰明,可以使用單個字節(jié)表示值在128以下的Unicode字符(包含所有英文中常用的字母、數字和標點符號),但對更大的值可擴展至最大4個字節(jié)用于表示Unicode代碼點。UTF-8中最差的情況是使用UTF-32相同。UTF-8還有一些其它的優(yōu)秀之處。與UTF-32和UTF-16不同,無需擔心使用的是大端序還是小端序。通過序列中的任意字節(jié)可以知道它是在UTF-8序列的開頭還是中間。也就是說不會在讀取字節(jié)時出錯。

唯一的不足是無法隨機讀取UTF-8編碼的字符串。雖然可以知道是否在字符中間,但無法知道一共有多少個字符。需要從字符串的開頭計算。Go并沒有要求用UTF-8寫入字符串,但強烈建議這么做。我們會在接下來的文章中使用UTF-8字符串。

軼事:UTF-8由Go的兩個創(chuàng)始人Ken Thompson和Rob Pike于1992年發(fā)明。

從字符串中提取子字符串和代碼點一般不使用切片和索引表達式,而是使用標準庫中stringsunicode/utf8包中的函數。下一篇文章中,我們學習如何使用for-range循環(huán)遍歷字符串中的代碼點。

字典(map)

切片對于有序數據很有用。同大部分編程言一樣,Go提供了內置數據類型處理一個值與另一個值關聯(lián)的場景。map類型寫為map[keyType]valueType。我們來學習幾種聲明map的方式。首先,可以使用var創(chuàng)建一個字典變量,并將其設為零值:

var nilMap map[string]int

這里聲明nilMap為一個string鍵和int值的字典。map的零值是nil。nil字典的長度為0。嘗試從nil字典中讀取返回的總是字典值類型的零值。但對nil字典變量寫入會報panic。

我們也可以通過通過賦字典字面量來使用:=創(chuàng)建字典變量:

totalWins := map[string]int{}

這里我們使用的是空字典字面量。它與nil字典不同。它的長度為0,但可對空字典字面量的字典讀取及寫入。下面是一個非空字典字面量的示例:

teams := map[string][]string {
    "Orcas": []string{"Fred", "Ralph", "Bijou"},
    "Lions": []string{"Sarah", "Peter", "Billie"},
    "Kittens": []string{"Waldo", "Raul", "Ze"},
}

字典字面量內容體先寫鍵,再接冒號(:),再后是值。每個鍵值對使用逗號分隔,最后一行同樣如此。在本例中,值為字符串切片。字典中的值可為任意類型。關于鍵的類型限制稍后會進行討論。

如果知道準備裝進字典有多少個鍵值對,但不知道具體值,可使用make創(chuàng)建一個帶有默認大小的字典:

ages := make(map[int][]string, 10)

通過make創(chuàng)建的字典長度仍為0,可以增長至所指定大小以上。

字典和切片有幾方面很像:

  • 在不斷添加鍵值對時字典會自增長。
  • 如果事先知道要在字典中插入多少個鍵值對,可以使用make創(chuàng)建指定大小的字典。
  • 對字典使用len可獲取字典中的鍵值對數。
  • 字典的零值是nil
  • 字典不可比較??梢圆榭醋值涫欠駷?code>nil,但無法使用==!=查看兩個字典是否相同或不同。

字典中的鍵可為任意一種可比較類型。這表示字典中的鍵不能使用切片或字典。

那什么時候使用字典、什么時候使用切片呢?切片是數據列表,尤其用于處理有序的數據。字典對于值無需嚴格進行排序的數據比較適用。

小貼士:在元素排序不重要進使用字典。在元素排序比較重要時使用切片。

什么是哈希字典?

在計算機科學領域,map是一種將一個值與另一個值關聯(lián)的數據結構。字典有多種實現方式,各有利弊。Go中內置的字典為hash map。對于不熟悉這一概念的讀者,下面簡述一下。

哈希字典根據鍵可快速查找值。在內部這使用數組實現。在插入一對鍵和值時,鍵使用哈希算法轉為數字。這些數字對每個鍵并不唯一。哈希算法可將不同鍵轉為同一數字。該數字會用做數組的索引。數組中的每個元素稱為一個桶(bucket)。鍵值對會存儲在桶中。如果桶中存在相同的鍵,老值會被新值替換掉。

每個桶也是一個數組,可存儲多個值。在兩個鍵映射到相同的桶時,這稱為碰撞,兩者的鍵值都存儲在這個桶中。

對哈希字典的讀者相同。拿到鍵,運行哈希算法將其轉為數字,找到關聯(lián)桶,對后對桶中的每個鍵進行遍歷,查看其是否與所提供的鍵相同。找到后就返回其值。

我們不希望出現太多碰撞,因為碰撞越多,哈希字典就越慢。智能的哈希算法設計時會讓碰撞保持為最少。如果添加了足夠的元素,哈希字典會重置以使用桶重新平衡,允許添加更多條目。

哈希字典很有用,但要自己正確地構建并不簡單。如果想要了解Go是怎么做的,可以看GopherCon 2016中的演講:Map的內部實現

Go并不要求(也不允許)用戶自己定義哈希算法或做等式定義。而是在編譯為每個Go程序的Go運行時帶上實現鍵所支持的所有類型哈希算法的代碼。

讀取和寫入字典

我們來看一個聲明、寫入和讀取字典的簡短程序??梢栽?a target="_blank">The Go Playground中運行例3-10中的代碼。

例3-10 使用字典

totalWins := map[string]int{}
totalWins["Orcas"] = 1
totalWins["Lions"] = 2
fmt.Println(totalWins["Orcas"])
fmt.Println(totalWins["Kittens"])
totalWins["Kittens"]++
fmt.Println(totalWins["Kittens"])
totalWins["Lions"] = 3
fmt.Println(totalWins["Lions"])

運行這段程序,會輸出:

1
0
1
3

我們通過在字典的方括號中定義鍵,在=后指定值,然后通過在方括號中放入鍵來讀取值。注意不能使用:=對字典的鍵賦值。

在讀取未設置過的字典鍵時,會返回字典值類型對應的零值。本例中值的類型為int,所以得到的結果是0??梢允褂?code>++ 運算符來遞增某個鍵對應的值。因為字典默認返回的是零值,即便在鍵沒有關聯(lián)值時也可以使用。

逗號ok語句

我們已經知道字典中沒有相應鍵的關聯(lián)值時會返回零值。這對于實現前面看到的計數器非常方便。但有時確實需要知道字典中是否存在某個鍵。Go提供了一個逗號ok語句可分辨字典中相應鍵是對應的值為零值還是壓根不存在:

m := map[string]int{
    "hello": 5,
    "world": 0,
}
v, ok := m["hello"]
fmt.Println(v, ok)

v, ok = m["world"]
fmt.Println(v, ok)

v, ok = m["goodbye"]
fmt.Println(v, ok)

逗號ok語句返回的不只是字典的值,而是包含是否存在的結果共可賦給兩個變量。第一個變量獲取的是與鍵相關聯(lián)的值。第二個變量值是一個布爾值,通常使用ok。如果oktrue,表示字典中存在這個鍵。如果okfalse,表示鍵不存在。本例輸出的結果為5 true, 0 true, and 0 false。

注:逗號ok語句在Go中用于區(qū)分讀取值還是返回了零值。我們會在并發(fā)一文中讀取通道以及在類型、方法和接口一文中進行類型斷言時才會再次用到。

從字典中刪除

字典中的鍵值對通過內置的delete函數進行刪除:

m := map[string]int{
    "hello": 5,
    "world": 10,
}
delete(m, "hello")

delete函數接收字典和鍵,然后刪除指定鍵對應的鍵值對。如果字典中不存在該鍵或是字典為nil,什么也不會發(fā)生。delete函數沒有返回值。

將字典用作集合

很多語言的標準庫中都包含集合(set)。集合數據類型可保障一個值最多出現一次,但并不會保持排序。不管集合中有多少個元素,查看元素是否存在很快速。(查看切片中是否存在元素隨著不斷加入元素會越來越慢。)

Go中并沒有集合,但可使用字典模擬它的一些功能。將想要放入集合的類型設為字典鍵的類型,值統(tǒng)一使用bool類型。例3-11中的代碼演示了這一概念??稍?a target="_blank">The Go Playground中運行。

例3-11 將字典用作集合

intSet := map[int]bool{}
vals := []int{5, 10, 2, 5, 8, 7, 3, 9, 1, 2, 10}
for _, v := range vals {
    intSet[v] = true
}
fmt.Println(len(vals), len(intSet))
fmt.Println(intSet[5])
fmt.Println(intSet[500])
if intSet[100] {
    fmt.Println("100 is in the set")
}

我們需要的是一個int類型的集合,所以創(chuàng)建了一個鍵為int類型、值為bool類型的字典。使用for-range循環(huán)對vals中的值進行遍歷,放入intSet,所有的值都設置為布爾類型true

我們在intSet中寫入了11個值,但intSet的長度為8,因為字典中不能有重復的鍵。如果在intSet中查找5,會返回true,因為其中存在為5的鍵。但如若在intSet中查找500或100,返回的是false。這是因為在intSet中這兩個值都不存在,也就會導致字典返回零值,而bool的零值為false。

如果要對集合做并集、交集和或差集運算,要么自己寫,要么使用提供這一功能的第三方庫。(在模塊、包和導入一文中會學習使用第三方庫。)

注:有些人在將字典實現為集合時喜歡使用struct{}作為值。(在下一節(jié)中會講到結構體。)好處是空結構體占零字節(jié),而布爾類型使用1個字節(jié)。

使用struct{}的壞處是代碼有些冗長。賦值不清晰,還要使用逗號ok語句來判斷值在集合中是否存在:

intSet := map[int]struct{}{}
vals := []int{5, 10, 2, 5, 8, 7, 3, 9, 1, 2, 10}
for _, v := range vals {
    intSet[v] = struct{}{}
}
if _, ok := intSet[5]; ok {
    fmt.Println("5 is in the set")
}

除非使用的是非常大的集合,內存占用的細微差別好過上面所述的壞處。

結構體

字典對于存在某類數據很便捷,但存一些限制。不適合定義API,因為無法限制字典允許某些鍵。并且字典中的所有值都必須是同一類型。出于這些原因,在將數據從一個函數傳入另一個函數時字典不是一種理想的方式。在有一些關聯(lián)數據希望進行分組時,應當定義一個結構體。

注:如果讀者已經熟悉面向對象語言,可能會想類和結構體有什么分別。區(qū)別很簡單:Go中沒有類,因為它沒有繼承。這不是說Go中沒有面向對象的某些特性,只是做法不同。我們會在類型、方法和接口一文學習一些面向對象特性。

大部分語言都有類似于結構體的概念,Go中讀寫結構體的語法讀者應該不陌生:

type person struct {
    name string
    age  int
    pet  string
}

結構體定義方式為關鍵字type、結構體類型的名稱、關鍵字struct以及一對花括號({})。在花括號內列出結構體中的字段。就像在var聲明中一樣,結構體字段名在前、類型在后。還要注意不同于字典字面量,結構體聲明的字段之間不用逗號進行分隔??梢栽诤瘮祪然蛲舛x結構體類型。函數內定義的結構體類型只能在函數內使用。(我們會在函數一文學習函數。)

注:從技術上講,可以把結構體定義的作用域放到任意級代碼塊中。我們會在代碼塊,遮蔽和控制結構一文中講解代碼塊。

聲明好結構體類型后,我們就可以定義該類型的變量:

var fred person

此處我們使用var進行聲明。因為沒有對fred賦值,它的值為person結構體類型零值。結構體的零值每個字段都為該字段的零值。

也可將結構體字面量賦值給變量:

bob := person{}

不同于字典,賦空結構體字面量和不賦值沒有差別。兩者都會將結構體中的所有字段初始化為其零值。非空結構體字面量有兩種形式。結構體字面量可指定為花括號內逗號分隔的字段值列表:

julia := person{
    "Julia",
    40,
    "cat",
}

使用這種結構體字面量格式時,必須指定結構體中每個字段的值,賦值的順序需要與結構體中定義字段的順序相同。

第二種結構體字面量格式類似字典字面量:

beth := person{
    age:  30,
    name: "Beth",
}

我們使用結構體中的字段名來指定值。使用這種格式時,可以省去一些鍵,也不用按順序指定字段。沒指定的字段為零值。不能濫用這兩種賦值樣式:要么所有字段都通過鍵指定,要么就不用寫字段名。對于總是指定所有字段的小結構體,使用第一種。其它時候使用鍵名。會更長,但不需查看結構體定義就很清楚哪個字段指定的是什么值。也更易于維護。如果初始化結構體時沒有使用字段名,后來結構體又新增了字段,代碼就無法通過編譯。

結構體中的字段通過點號標記訪問:

bob.name = "Bob"
fmt.Println(beth.name)

就像是使用方括號讀寫字典一樣,我們使用點號標記來讀寫結構體字段。

匿名結構體

我可以先不給結構體類型名稱,直接聲明一個實現某一結構體類型的變量。這稱為匿名結構體:

var person struct {
    name string
    age  int
    pet  string
}

person.name = "bob"
person.age = 50
person.pet = "dog"

pet := struct {
    name string
    kind string
}{
    name: "Fido",
    kind: "dog",
}

本例中,變量personpet的類型都是匿名結構體??梢跃呙Y構體一樣在匿名結構體中賦值(讀?。┳侄?。就像可以使用結構體字面量初始化具名結構體的實例一樣,對匿名結構體也可進行同樣操作。

你可能會想只關聯(lián)了單個實例的數據類型有什么用。有兩個通用場景會使用到匿名結構體。第一個是在將外部數據與結構體相互轉換時(如JSON或Protobuf)。這稱為序列化和反序列化數據。我們會在標準庫一文的encoding/json中講到。

單元測試中也會用到匿名結構體。我們會在編寫測試一文中編寫表格驅動測試時用到匿名結構體切片。

比較和轉換結構體

結構體是否可比較取決于結構體的字段。全部由可比較類型組成的結構體可比較,帶有切片或字典字段的結構體不可比較(在后面的文章中我們會學到函數和通道也會讓結構體不可比較)。

和Python或Ruby不同,Go中沒有可重載的魔術方法來重新定義等式讓==!=用于不可比較的結構體。當然我們可以自己編寫函數來比較結構體。

就像Go不允許對不同基礎類型變量進行比較一樣,它也不允許對不同類型結構體的變量進行比較。但Go中可以對名稱、順序和類型相同的結構體執(zhí)行類型轉換。下面來看是什么意思。假定有結構體:

type firstPerson struct {
    name string
    age  int
}

我們使用類型轉換將firstPerson的實例轉換為secondPerson,但無法使用==比較firstPerson的實例和secondPerson的實例,因為兩者類型不同:

type secondPerson struct {
    name string
    age  int
}

無法將firstPerson實例轉換為thirdPerson,因為其字段順序不同:

type thirdPerson struct {
    age  int
    name string
}

無法將firstPerson的實例轉換為fourthPerson,因為其字段名不匹配:

type fourthPerson struct {
    firstName string
    age       int
}

最后,我們也無法將firstPerson的實例轉換為fifthPerson,因為多一個字段:

type fifthPerson struct {
    name          string
    age           int
    favoriteColor string
}

匿名結構體又有些不同:如果比較兩個結構體變量,而其中至少有一個匿名結構體,如果兩個結構體的名稱、順序和類型都相同,無需進行類型轉換就可以比較。而如果名稱、順序和類型相同可使用具名和匿名結構體為彼此賦值:

type firstPerson struct {
    name string
    age  int
}
f := firstPerson{
    name: "Bob",
    age:  50,
}
var g struct {
    name string
    age  int
}

// 可通過編譯:對相同的具名和匿名結構體可使用 = 和 ==
g = f
fmt.Println(f == g)

小結

我們學習了Go中的容器類型。不僅更深入地學習了字符串,我們會使用了內置的常用容器類型,切片和字典。還可通過結構體構造自己的復合類型。在下一篇文章中,我們會學習Go中的控制結構for、if/elseswitch。我們還會學習Go如何將代碼組織到代碼塊中,以及不同級別的代碼塊會產生哪些意外的行為。

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容