Go語言核心36講(Go語言進階技術三)--學習筆記

MingsonZheng發表於2021-10-20

09 | 字典的操作和約束

至今為止,我們講過的集合類的高階資料型別都屬於針對單一元素的容器。

它們或用連續儲存,或用互存指標的方式收納元素,這裡的每個元素都代表了一個從屬某一型別的獨立值。

我們今天要講的字典(map)卻不同,它能儲存的不是單一值的集合,而是鍵值對的集合。

在 Go 語言規範中,應該是為了避免歧義,他們將鍵值對換了一種稱呼,叫做:“鍵 - 元素對”。我們也沿用這個看起來更加清晰的詞來講解。

知識前導:為什麼字典的鍵型別會受到約束?

Go 語言的字典型別其實是一個雜湊表(hash table)的特定實現,在這個實現中,鍵和元素的最大不同在於,鍵的型別是受限的,而元素卻可以是任意型別的。

如果要探究限制的原因,我們就先要了解雜湊表中最重要的一個過程:對映。

你可以把鍵理解為元素的一個索引,我們可以在雜湊表中通過鍵查詢與它成對的那個元素。

鍵和元素的這種對應關係,在數學裡就被稱為“對映”,這也是“map”這個詞的本意,雜湊表的對映過程就存在於對鍵 - 元素對的增、刪、改、查的操作之中。

aMap := map[string]int{
  "one":    1,
  "two":    2,
  "three": 3,
}
k := "two"
v, ok := aMap[k]
if ok {
  fmt.Printf("The element of key %q: %d\n", k, v)
} else {
  fmt.Println("Not found!")
}

比如,我們要在雜湊表中查詢與某個鍵值對應的那個元素值,那麼我們需要先把鍵值作為引數傳給這個雜湊表。

雜湊表會先用雜湊函式(hash function)把鍵值轉換為雜湊值。雜湊值通常是一個無符號的整數。一個雜湊表會持有一定數量的桶(bucket),我們也可以叫它雜湊桶,這些雜湊桶會均勻地儲存其所屬雜湊表收納的鍵 - 元素對。

因此,雜湊表會先用這個鍵雜湊值的低幾位去定位到一個雜湊桶,然後再去這個雜湊桶中,查詢這個鍵。

由於鍵 - 元素對總是被捆綁在一起儲存的,所以一旦找到了鍵,就一定能找到對應的元素值。隨後,雜湊表就會把相應的元素值作為結果返回。

只要這個鍵 - 元素對存在雜湊表中就一定會被查詢到,因為雜湊表增、改、刪鍵 - 元素對時的對映過程,與前文所述如出一轍。

現在我們知道了,對映過程的第一步就是:把鍵值轉換為雜湊值。

在 Go 語言的字典中,每一個鍵值都是由它的雜湊值代表的。也就是說,字典不會獨立儲存任何鍵的值,但會獨立儲存它們的雜湊值。

你是不是隱約感覺到了什麼?我們接著往下看。

我們今天的問題是:字典的鍵型別不能是哪些型別?

這個問題你可以在 Go 語言規範中找到答案,但卻沒那麼簡單。它的典型回答是:Go 語言字典的鍵型別不可以是函式型別、字典型別和切片型別。

問題解析

Go 語言規範規定,在鍵型別的值之間必須可以施加操作符==和!=。換句話說,鍵型別的值必須要支援判等操作。由於函式型別、字典型別和切片型別的值並不支援判等操作,所以字典的鍵型別不能是這些型別。

另外,如果鍵的型別是介面型別的,那麼鍵值的實際型別也不能是上述三種型別,否則在程式執行過程中會引發 panic(即執行時恐慌)。

我們舉個例子:

var badMap2 = map[interface{}]int{
  "1":   1,
  []int{2}: 2, // 這裡會引發panic。
  3:    3,
}

這裡的變數badMap2的型別是鍵型別為interface{}、值型別為int的字典型別。這樣宣告並不會引起什麼錯誤。或者說,我通過這樣的宣告躲過了 Go 語言編譯器的檢查。

注意,我用字面量在宣告該字典的同時對它進行了初始化,使它包含了三個鍵 - 元素對。其中第二個鍵 - 元素對的鍵值是[]int{2},元素值是2。這樣的鍵值也不會讓 Go 語言編譯器報錯,因為從語法上說,這樣做是可以的。

但是,當我們執行這段程式碼的時候,Go 語言的執行時(runtime)系統就會發現這裡的問題,它會丟擲一個 panic,並把根源指向字面量中定義第二個鍵 - 元素對的那一行。我們越晚發現問題,修正問題的成本就會越高,所以最好不要把字典的鍵型別設定為任何介面型別。如果非要這麼做,請一定確保程式碼在可控的範圍之內。

還要注意,如果鍵的型別是陣列型別,那麼還要確保該型別的元素型別不是函式型別、字典型別或切片型別。

比如,由於型別[1][]string的元素型別是[]string,所以它就不能作為字典型別的鍵型別。另外,如果鍵的型別是結構體型別,那麼還要保證其中欄位的型別的合法性。無論不合法的型別被埋藏得有多深,比如map[[1][2][3][]string]int,Go 語言編譯器都會把它揪出來。

你可能會有疑問,為什麼鍵型別的值必須支援判等操作?我在前面說過,Go 語言一旦定位到了某一個雜湊桶,那麼就會試圖在這個桶中查詢鍵值。具體是怎麼找的呢?

首先,每個雜湊桶都會把自己包含的所有鍵的雜湊值存起來。Go 語言會用被查詢鍵的雜湊值與這些雜湊值逐個對比,看看是否有相等的。如果一個相等的都沒有,那麼就說明這個桶中沒有要查詢的鍵值,這時 Go 語言就會立刻返回結果了。

如果有相等的,那就再用鍵值本身去對比一次。為什麼還要對比?原因是,不同值的雜湊值是可能相同的。這有個術語,叫做“雜湊碰撞”。

所以,即使雜湊值一樣,鍵值也不一定一樣。如果鍵型別的值之間無法判斷相等,那麼此時這個對映的過程就沒辦法繼續下去了。最後,只有鍵的雜湊值和鍵值都相等,才能說明查詢到了匹配的鍵 - 元素對。

以上內容涉及的示例都在 demo18.go 中。

package main

func main() {
	// 示例1。
	//var badMap1 = map[[]int]int{} // 這裡會引發編譯錯誤。
	//_ = badMap1

	// 示例2。
	//var badMap2 = map[interface{}]int{
	//	"1":      1,
	//	[]int{2}: 2, // 這裡會引發panic。
	//	3:        3,
	//}
	//_ = badMap2

	// 示例3。
	//var badMap3 map[[1][]string]int // 這裡會引發編譯錯誤。
	//_ = badMap3

	// 示例4。
	//type BadKey1 struct {
	//	slice []string
	//}
	//var badMap4 map[BadKey1]int // 這裡會引發編譯錯誤。
	//_ = badMap4

	// 示例5。
	//var badMap5 map[[1][2][3][]string]int // 這裡會引發編譯錯誤。
	//_ = badMap5

	// 示例6。
	//type BadKey2Field1 struct {
	//	slice []string
	//}
	//type BadKey2 struct {
	//	field BadKey2Field1
	//}
	//var badMap6 map[BadKey2]int // 這裡會引發編譯錯誤。
	//_ = badMap6

}

知識擴充套件

問題 1:應該優先考慮哪些型別作為字典的鍵型別?

你現在已經清楚了,在 Go 語言中,有些型別的值是支援判等的,有些是不支援的。那麼在這些值支援判等的型別當中,哪些更適合作為字典的鍵型別呢?

這裡先拋開我們使用字典時的上下文,只從效能的角度看。在前文所述的對映過程中,“把鍵值轉換為雜湊值”以及“把要查詢的鍵值與雜湊桶中的鍵值做對比”, 明顯是兩個重要且比較耗時的操作。

因此,可以說,求雜湊和判等操作的速度越快,對應的型別就越適合作為鍵型別。

對於所有的基本型別、指標型別,以及陣列型別、結構體型別和介面型別,Go 語言都有一套演算法與之對應。這套演算法中就包含了雜湊和判等。以求雜湊的操作為例,寬度越小的型別速度通常越快。對於布林型別、整數型別、浮點數型別、複數型別和指標型別來說都是如此。對於字串型別,由於它的寬度是不定的,所以要看它的值的具體長度,長度越短求雜湊越快。

型別的寬度是指它的單個值需要佔用的位元組數。比如,bool、int8和uint8型別的一個值需要佔用的位元組數都是1,因此這些型別的寬度就都是1。

以上說的都是基本型別,再來看高階型別。對陣列型別的值求雜湊實際上是依次求得它的每個元素的雜湊值並進行合併,所以速度就取決於它的元素型別以及它的長度。細則同上。

與之類似,對結構體型別的值求雜湊實際上就是對它的所有欄位值求雜湊並進行合併,所以關鍵在於它的各個欄位的型別以及欄位的數量。而對於介面型別,具體的雜湊演算法,則由值的實際型別決定。

我不建議你使用這些高階資料型別作為字典的鍵型別,不僅僅是因為對它們的值求雜湊,以及判等的速度較慢,更是因為在它們的值中存在變數。

比如,對一個陣列來說,我可以任意改變其中的元素值,但在變化前後,它卻代表了兩個不同的鍵值。

對於結構體型別的值情況可能會好一些,因為如果我可以控制其中各欄位的訪問許可權的話,就可以阻止外界修改它了。把介面型別作為字典的鍵型別最危險。

還記得嗎?如果在這種情況下 Go 執行時系統發現某個鍵值不支援判等操作,那麼就會立即丟擲一個 panic。在最壞的情況下,這足以使程式崩潰。

那麼,在那些基本型別中應該優先選擇哪一個?答案是,優先選用數值型別和指標型別,通常情況下型別的寬度越小越好。如果非要選擇字串型別的話,最好對鍵值的長度進行額外的約束。

那什麼是不通常的情況?籠統地說,Go 語言有時會對字典的增、刪、改、查操作做一些優化。

比如,在字典的鍵型別為字串型別的情況下;又比如,在字典的鍵型別為寬度為4或8的整數型別的情況下。

問題 2:在值為nil的字典上執行讀操作會成功嗎,那寫操作呢?

好了,為了避免燒腦太久,我們再來說一個簡單些的問題。由於字典是引用型別,所以當我們僅宣告而不初始化一個字典型別的變數的時候,它的值會是nil。

在這樣一個變數上試圖通過鍵值獲取對應的元素值,或者新增鍵 - 元素對,會成功嗎?這個問題雖然簡單,但卻是我們必須銘記於心的,因為這涉及程式執行時的穩定性。

我來說一下答案。除了新增鍵 - 元素對,我們在一個值為nil的字典上做任何操作都不會引起錯誤。當我們試圖在一個值為nil的字典中新增鍵 - 元素對的時候,Go 語言的執行時系統就會立即丟擲一個 panic。你可以執行一下 demo19.go 檔案試試看。

總結

我們這次主要討論了與字典型別有關的,一些容易讓人困惑的問題。比如,為什麼字典的鍵型別會受到約束?又比如,我們通常應該選取什麼樣的型別作為字典的鍵型別。

我以 Go 語言規範為起始,並以 Go 語言原始碼為依據回答了這些問題。認真看了這篇文章之後,你應該對字典中的對映過程有了一定的理解。

另外,對於 Go 語言在那些合法的鍵型別上所做的求雜湊和判等的操作,你也應該有所瞭解了。

再次強調,永遠要注意那些可能引發 panic 的操作,比如像一個值為nil的字典新增鍵 - 元素對。

思考題

今天的思考題是關於併發安全性的。更具體地說,在同一時間段內但在不同的 goroutine(或者說 go 程)中對同一個值進行操作是否是安全的。這裡的安全是指,該值不會因這些操作而產生混亂,或其它不可預知的問題。

具體的思考題是:字典型別的值是併發安全的嗎?如果不是,那麼在我們只在字典上新增或刪除鍵 - 元素對的情況下,依然不安全嗎?感謝你的收聽,我們下期再見。

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

相關文章