Go 複合型別之字典型別介紹

賈維斯Echo發表於2023-10-10

Go 複合型別之字典型別介紹

一、map型別介紹

1.1 什麼是 map 型別?

map 是 Go 語言提供的一種抽象資料型別,它表示一組無序的鍵值對。用 key 和 value 分別代表 map 的鍵和值。而且,map 集合中每個 key 都是唯一的:

WechatIMG200

和切片類似,作為複合型別的 map,它在 Go 中的型別表示也是由 key 型別與 value 型別組成的,就像下面程式碼:

map[key_type]value_type

key 與 value 的型別可以相同,也可以不同:

map[string]string // key與value元素的型別相同
map[int]string    // key與value元素的型別不同

如果兩個 map 型別的 key 元素型別相同,value 元素型別也相同,那麼我們可以說它們是同一個 map 型別,否則就是不同的 map 型別。

這裡,我們要注意,map 型別對 value 的型別沒有限制,但是對 key 的型別卻有嚴格要求,因為 map 型別要保證 key 的唯一性因此在這裡,你一定要注意:函式型別、map 型別自身,以及切片型別是不能作為 map 的 key 型別的。比如下面這段程式碼:

// 函式型別不能作為key,因為函式型別是不可比較的
func keyFunc() {}

m := make(map[string]int)
m[keyFunc] = 1 // 編譯錯誤

// map型別不能作為key
m1 := make(map[string]int)
m[m1] = 1 // 編譯錯誤

// 切片型別不能作為key,因為切片是可變長度的,它們的內容可能會在執行時更改
s1 := []int{1,2,3}  
m[s1] = 1 // 編譯錯誤

上面程式碼中,試圖使用函式型別、map型別和切片型別作為key都會導致編譯錯誤。

這是因為Go語言在實現map時,需要比較key是否相等,因此key需要支援比較。但函式、map和切片型別的相等性比較涉及記憶體地址,無法簡單判斷,所以不能作為key。**所以,key 的型別必須支援“”和“!=”兩種比較運算子**。

還需要注意的是,在 Go 語言中,函式型別、map 型別自身,以及切片只支援與 nil 的比較,而不支援同型別兩個變數的比較。如果像下面程式碼這樣,進行這些型別的比較,Go 編譯器將會報錯:

s1 := make([]int, 1)
s2 := make([]int, 2)
f1 := func() {}
f2 := func() {}
m1 := make(map[int]string)
m2 := make(map[int]string)
println(s1 == s2) // 錯誤:invalid operation: s1 == s2 (slice can only be compared to nil)
println(f1 == f2) // 錯誤:invalid operation: f1 == f2 (func can only be compared to nil)
println(m1 == m2) // 錯誤:invalid operation: m1 == m2 (map can only be compared to nil)

1.2 map 型別特性

在Go中,map具有以下特性:

  • 無序性: map中的鍵值對沒有固定的順序,遍歷時可能不按照新增的順序返回鍵值對。
  • 動態增長: map是動態的,它會根據需要自動增長以容納更多的鍵值對,不需要預先指定大小。
  • 零值: 如果未初始化一個map,它將是nil,並且不能儲存鍵值對。需要使用make函式來初始化一個map
  • 鍵的唯一性: 在同一個map中,每個鍵只能出現一次。如果嘗試使用相同的鍵插入多次,新值將覆蓋舊值。
  • 查詢效率高: map的查詢操作通常非常快,因為它使用雜湊表來儲存資料,這使得透過鍵查詢值的時間複雜度接近常數。
  • 引用型別: map是一種引用型別,多個變數可以引用並共享同一個map例項。

二.map 變數的宣告和初始化

和切片一樣,為 map 型別變數顯式賦值有兩種方式:一種是使用複合字面值;另外一種是使用 make 這個預宣告的內建函式。

2.1 方法一:使用 make 函式宣告和初始化(推薦)

這是最常見和推薦的方式,特別是在需要在map中新增鍵值對之前初始化map的情況下。使用make函式可以為map分配記憶體並進行初始化。

// 使用 make 函式宣告和初始化 map
myMap := make(map[keyType]valueType,capacity)

其中:

  • keyType 是鍵的型別。

  • valueType 是值的型別。

  • capacity表示map的初始容量,它是可選的,可以省略不寫。

例如:和切片透過 make 進行初始化一樣,透過 make 的初始化方式,我們可以為 map 型別變數指定鍵值對的初始容量,但無法進行具體的鍵值對賦值,就像下面程式碼這樣:

	// 建立一個儲存整數到字串的對映
	m1 := make(map[int]string) // 未指定初始容量
	m1[1] = "key"
	fmt.Println(m1)

map 型別的容量不會受限於它的初始容量值,當其中的鍵值對數量超過初始容量後,Go 執行時會自動增加 map 型別的容量,保證後續鍵值對的正常插入,比如下面這段程式碼:

	m2 := make(map[int]string, 2) // 指定初始容量為2
	m2[1] = "One"
	m2[2] = "Two"
	m2[3] = "Three"
	fmt.Println(m2) // 輸出:map[1:One 2:Two 3:Three] ,並不會報錯
	fmt.Println(len(m2)) // 此時,map容量已經變為3

總結:使用make函式初始化的map是空的,需要在後續程式碼中新增鍵值對。

	mm := make(map[int]string)
	fmt.Println(mm) // 輸出 map[]

2.2 方法二:使用複合字面值宣告初始化 map 型別變數

和切片型別變數一樣,如果我們沒有顯式地賦予 map 變數初值,map 型別變數的預設值為 nil,比如,我們來看下面這段程式碼:

var m map[string]int

if m == nil {
    fmt.Println("Map is nil")
} else {
    fmt.Println("Map is not nil")
}

不過切片變數和 map 變數在這裡也有些不同。初值為零值 nil 的切片型別變數,可以藉助內建的 append 的函式進行操作,這種在 Go 語言中被稱為“零值可用”。定義“零值可用”的型別,可以提升我們開發者的使用體驗,我們不用再擔心變數的初始狀態是否有效。比如,建立一個儲存字串到整數的對映,但 map 型別,因為它內部實現的複雜性,無法“零值可用”。所以,如果我們對處於零值狀態的 map 變數直接進行操作,就會導致執行時異常(panic),從而導致程式程式異常退出:

var m map[string]int // m = nil
m["key"] = 1         // 發生執行時異常:panic: assignment to entry in nil map

所以,我們必須對 map 型別變數進行顯式初始化後才能使用。我們先來看這句程式碼:

m := map[int]string{}

這裡,我們顯式初始化了 map 型別變數 m。不過,你要注意,雖然此時 map 型別變數 m 中沒有任何鍵值對,但變數 m 也不等同於初值為 nil 的 map 變數。這個時候,我們對 m 進行鍵值對的插入操作,不會引發執行時異常。

這裡我們再看看怎麼透過稍微複雜一些的複合字面值,對 map 型別變數進行初始化:

m1 := map[int][]string{
    1: []string{"val1_1", "val1_2"},
    3: []string{"val3_1", "val3_2", "val3_3"},
    7: []string{"val7_1"},
}

type Position struct { 
    x float64 
    y float64
}

m2 := map[Position]string{
    Position{29.935523, 52.568915}: "school",
    Position{25.352594, 113.304361}: "shopping-mall",
    Position{73.224455, 111.804306}: "hospital",
}

我們看到,上面程式碼雖然完成了對兩個 map 型別變數 m1 和 m2 的顯式初始化,但不知道你有沒有發現一個問題,作為初值的字面值似乎有些“臃腫”。你看,作為初值的字面值採用了複合型別的元素型別,而且在編寫字面值時還帶上了各自的元素型別,比如作為 map[int] []string 值型別的[]string,以及作為 map[Position]string 的 key 型別的 Position。

別急!針對這種情況,Go 提供了“語法糖”。這種情況下,Go 允許省略字面值中的元素型別。因為 map 型別表示中包含了 key 和 value 的元素型別,Go 編譯器已經有足夠的資訊,來推匯出字面值中各個值的型別了。我們以 m2 為例,這裡的顯式初始化程式碼和上面變數 m2 的初始化程式碼是等價的:

m2 := map[Position]string{
    {29.935523, 52.568915}: "school",
    {25.352594, 113.304361}: "shopping-mall",
    {73.224455, 111.804306}: "hospital",
}

綜上,這種方式通常用於建立具有初始值的map。在這種情況下,不需要使用make函式。map的宣告方式如下:

// 使用字面量宣告和初始化 map
myMap := map[keyType]valueType{
    key1: value1,
    key2: value2,
    // ...
}

其中:

  • keyType 是鍵的型別
  • valueType 是值的型別
  • 然後使用大括號 {} 包圍鍵值對

三.map 變數的傳遞開銷(map是引用傳遞)

和切片型別一樣,map 也是引用型別。這就意味著 map 型別變數作為引數被傳遞給函式或方法的時候,實質上傳遞的只是一個“描述符”,而不是整個 map 的資料複製,所以這個傳遞的開銷是固定的,而且也很小。

並且,當 map 變數被傳遞到函式或方法內部後,我們在函式內部對 map 型別引數的修改在函式外部也是可見的。比如你從這個示例中就可以看到,函式 foo 中對 map 型別變數 m 進行了修改,而這些修改在 foo 函式外也可見。

package main
  
import "fmt"

func foo(m map[string]int) {
    m["key1"] = 11
    m["key2"] = 12
}

func main() {
    m := map[string]int{
        "key1": 1,
        "key2": 2,
    }

    fmt.Println(m) // map[key1:1 key2:2]  
    foo(m)
    fmt.Println(m) // map[key1:11 key2:12] 
}

所以,map 引用型別當 map 被賦值為一個新變數的時候,它們指向同一個內部資料結構。因此,當改變其中一個變數,就會影響到另一變數。

四.map 的內部實現

4.1 map 型別在 Go 執行時層實現的示意圖

和切片相比,map 型別的內部實現要更加複雜。Go 執行時使用一張雜湊表來實現抽象的 map 型別。執行時實現了 map 型別操作的所有功能,包括查詢、插入、刪除等。在編譯階段,Go 編譯器會將 Go 語法層面的 map 操作,重寫成執行時對應的函式呼叫。大致的對應關係是這樣的:

// 建立map型別變數例項
m := make(map[keyType]valType, capacityhint) → m := runtime.makemap(maptype, capacityhint, m)

// 插入新鍵值對或給鍵重新賦值
m["key"] = "value" → v := runtime.mapassign(maptype, m, "key") v是用於後續儲存value的空間的地址

// 獲取某鍵的值 
v := m["key"]      → v := runtime.mapaccess1(maptype, m, "key")
v, ok := m["key"]  → v, ok := runtime.mapaccess2(maptype, m, "key")

// 刪除某鍵
delete(m, "key")   → runtime.mapdelete(maptype, m, “key”)

這是 map 型別在 Go 執行時層實現的示意圖:

img

我們可以看到,和切片的執行時表示圖相比,map 的實現示意圖顯然要複雜得多。接下來,我們結合這張圖來簡要描述一下 map 在執行時層的實現原理。接下來我們來看一下一個 map 變數在初始狀態、進行鍵值對操作後,以及在併發場景下的 Go 執行時層的實現原理。

4.2 初始狀態

從圖中我們可以看到,與語法層面 map 型別變數(m)一一對應的是 *runtime.hmap 的例項,即 runtime.hmap 型別的指標,也就是我們前面在講解 map 型別變數傳遞開銷時提到的 map 型別的描述符。hmap 型別是 map 型別的頭部結構(header),它儲存了後續 map 型別操作所需的所有資訊,包括:

WechatIMG17

真正用來儲存鍵值對資料的是桶,也就是 bucket,每個 bucket 中儲存的是 Hash 值低 bit 位數值相同的元素,預設的元素個數為 BUCKETSIZE(值為 8,Go 1.17 版本中在 $GOROOT/src/cmd/compile/internal/reflectdata/reflect.go 中定義,與 runtime/map.go 中常量 bucketCnt 保持一致)。

當某個 bucket(比如 buckets[0]) 的 8 個空槽 slot)都填滿了,且 map 尚未達到擴容的條件的情況下,執行時會建立 overflow bucket,並將這個 overflow bucket 掛在上面 bucket(如 buckets[0])末尾的 overflow 指標上,這樣兩個 buckets 形成了一個連結串列結構,直到下一次 map 擴容之前,這個結構都會一直存在。

從圖中我們可以看到,每個 bucket 由三部分組成,從上到下分別是 tophash 區域、key 儲存區域和 value 儲存區域。

4.3 tophash 區域

當我們向 map 插入一條資料,或者是從 mapkey 查詢資料的時候,執行時都會使用雜湊函式對 key 做雜湊運算,並獲得一個雜湊值(hashcode)。這個 hashcode 非常關鍵,執行時會把 hashcode“一分為二”來看待,其中低位區的值用於選定 bucket,高位區的值用於在某個 bucket 中確定 key 的位置。我把這一過程整理成了下面這張示意圖,你理解起來可以更直觀:

WechatIMG18

因此,每個 bucket 的 tophash 區域其實是用來快速定位 key 位置的,這樣就避免了逐個 key 進行比較這種代價較大的操作。尤其是當 key 是 size 較大的字串型別時,好處就更突出了。這是一種以空間換時間的思路。

4.4 key 儲存區域

接著,我們看 tophash 區域下面是一塊連續的記憶體區域,儲存的是這個 bucket 承載的所有 key 資料。執行時在分配 bucket 的時候需要知道 key 的 Size。那麼執行時是如何知道 key 的 size 的呢?

當我們宣告一個 map 型別變數,比如 var m map[string]int 時,Go 執行時就會為這個變數對應的特定 map 型別,生成一個 runtime.maptype 例項。如果這個例項已經存在,就會直接複用。maptype 例項的結構是這樣的:

type maptype struct {
    typ        _type
    key        *_type
    elem       *_type
    bucket     *_type // internal type representing a hash bucket
    keysize    uint8  // size of key slot
    elemsize   uint8  // size of elem slot
    bucketsize uint16 // size of bucket
    flags      uint32
} 

我們可以看到,這個例項包含了我們需要的 map 型別中的所有"元資訊"。我們前面提到過,編譯器會把語法層面的 map 操作重寫成執行時對應的函式呼叫,這些執行時函式都有一個共同的特點,那就是第一個引數都是 maptype 指標型別的引數。

Go 執行時就是利用 maptype 引數中的資訊確定 key 的型別和大小的map 所用的 hash 函式也存放在 maptype.key.alg.hash(key, hmap.hash0) 中。同時 maptype 的存在也讓 Go 中所有 map 型別都共享一套執行時 map 操作函式,而不是像 C++ 那樣為每種 map 型別建立一套 map 操作函式,這樣就節省了對最終二進位制檔案空間的佔用。

4.5 value 儲存區域

我們再接著看 key 儲存區域下方的另外一塊連續的記憶體區域,這個區域儲存的是 key 對應的 value。和 key 一樣,這個區域的建立也是得到了 maptype 中資訊的幫助。Go 執行時採用了把 keyvalue 分開儲存的方式,而不是採用一個 kv 接著一個 kvkv 緊鄰方式儲存,這帶來的其實是演算法上的複雜性,但卻減少了因記憶體對齊帶來的記憶體浪費。

我們以 map[int8]int64 為例,看看下面的儲存空間利用率對比圖:

img

你會看到,當前 Go 執行時使用的方案記憶體利用效率很高,而 kv 緊鄰儲存的方案在 map[int8]int64 這樣的例子中記憶體浪費十分嚴重,它的記憶體利用率是 72/128=56.25%,有近一半的空間都浪費掉了。

另外,還有一點我要跟你強調一下,如果 key 或 value 的資料長度大於一定數值,那麼執行時不會在 bucket 中直接儲存資料,而是會儲存 key 或 value 資料的指標。目前 Go 執行時定義的最大 key 和 value 的長度是這樣的:

// $GOROOT/src/runtime/map.go
const (
    maxKeySize  = 128
    maxElemSize = 128
)

五.map 擴容

我們前面提到過,map 會對底層使用的記憶體進行自動管理。因此,在使用過程中,當插入元素個數超出一定數值後,map 一定會存在自動擴容的問題,也就是怎麼擴充 bucket 的數量,並重新在 bucket 間均衡分配資料的問題。

那麼 map 在什麼情況下會進行擴容呢?Go 執行時的 map 實現中引入了一個 LoadFactor(負載因子),當 count > LoadFactor * 2^Boverflow bucket 過多時,執行時會自動對 map 進行擴容。目前 Go 1.17 版本 LoadFactor 設定為 6.5(loadFactorNum/loadFactorDen)。這裡是 Go 中與 map 擴容相關的部分原始碼:

// $GOROOT/src/runtime/map.go
const (
  ... ...

  loadFactorNum = 13
  loadFactorDen = 2
  ... ...
)

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
  ... ...
  if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
    hashGrow(t, h)
    goto again // Growing the table invalidates everything, so try again
  }
  ... ...
}

這兩方面原因導致的擴容,在執行時的操作其實是不一樣的。如果是因為 overflow bucket 過多導致的“擴容”,實際上執行時會新建一個和現有規模一樣的 bucket 陣列,然後在 assign 和 delete 時做排空和遷移。

如果是因為當前資料數量超出 LoadFactor 指定水位而進行的擴容,那麼執行時會建立一個兩倍於現有規模的 bucket 陣列,但真正的排空和遷移工作也是在 assign 和 delete 時逐步進行的。原 bucket 陣列會掛在 hmap 的 oldbuckets 指標下面,直到原 buckets 陣列中所有資料都遷移到新陣列後,原 buckets 陣列才會被釋放。你可以結合下面的 map 擴容示意圖來理解這個過程,這會讓你理解得更深刻一些:

WechatIMG20

六.map 與併發

接著我們來看一下 map 和併發。從上面的實現原理來看,充當 map 描述符角色的 hmap 例項自身是有狀態的(hmap.flags),而且對狀態的讀寫是沒有併發保護的。所以說 map 例項不是併發寫安全的,也不支援併發讀寫。如果我們對 map 例項進行併發讀寫,程式執行時就會丟擲異常。你可以看看下面這個併發讀寫 map 的例子:

package main

import (
    "fmt"
    "time"
)

func doIteration(m map[int]int) {
    for k, v := range m {
        _ = fmt.Sprintf("[%d, %d] ", k, v)
    }
}

func doWrite(m map[int]int) {
    for k, v := range m {
        m[k] = v + 1
    }
}

func main() {
    m := map[int]int{
        1: 11,
        2: 12,
        3: 13,
    }

    go func() {
        for i := 0; i < 1000; i++ {
            doIteration(m)
        }
    }()

    go func() {
        for i := 0; i < 1000; i++ {
            doWrite(m)
        }
    }()

    time.Sleep(5 * time.Second)
}

執行這個示例程式,我們會得到下面的執行錯誤結果:

fatal error: concurrent map iteration and map write

不過,如果我們僅僅是進行併發讀,map 是沒有問題的。而且,Go 1.9 版本中引入了支援併發寫安全的 sync.Map 型別,可以在併發讀寫的場景下替換掉 map。如果你有這方面的需求,可以檢視一下sync.Map 的手冊

另外,你要注意,考慮到 map 可以自動擴容,map 中資料元素的 value 位置可能在這一過程中發生變化,所以 Go 不允許獲取 map 中 value 的地址,這個約束是在編譯期間就生效的。下面這段程式碼就展示了 Go 編譯器識別出獲取 map 中 value 地址的語句後,給出的編譯錯誤:

p := &m[key]  // cannot take the address of m[key]
fmt.Println(p)

七、map 的基本操作

7.1 修改和更新鍵值對

首先 nil 的 map 型別變數,我們可以在其中插入符合 map 型別定義的任意新鍵值對。插入新鍵值對只需要把 value 賦值給 map 中對應的 key 就可以了:

// 建立並初始化一個 map
myMap := make(map[string]int)
myMap["apple"] = 1
myMap["banana"] = 2

不需要自己判斷資料有沒有插入成功,因為 Go 會保證插入總是成功的。不過,如果我們插入新鍵值對的時候,某個 key 已經存在於 map 中了,那我們的插入操作就會用新值覆蓋舊值:

// 修改鍵 "apple" 對應的值
myMap["apple"] = 3

// 更新鍵 "cherry" 對應的值,如果鍵不存在則建立新鍵值對
myMap["cherry"] = 4

// 列印修改後的 map
fmt.Println(myMap) // 輸出: map[apple:3 banana:2 cherry:4]

從這段程式碼中,您可以看到如何執行以下操作:

  1. 修改鍵 "apple" 對應的值:使用myMap["apple"] = 3這行程式碼,將鍵 "apple" 對應的值從原來的 1 修改為 3。
  2. 更新鍵 "cherry" 對應的值:使用myMap["cherry"] = 4這行程式碼,更新了鍵 "cherry" 對應的值為 4。如果鍵 "cherry" 不存在於map中,這行程式碼會建立一個新的鍵值對。
  3. 列印修改後的 map:最後使用fmt.Println(myMap)列印整個修改後的map,以顯示更新後的鍵值對。

7.2 批次更新和修改(合併同型別map)

在Go中,可以使用迴圈遍歷另一個map,然後使用遍歷的鍵值對來批次更新或修改目標map的鍵值對。以下是一個實現類似於Python字典的update()方法的步驟:

  1. 建立一個目標map,它將被更新或修改。
  2. 建立一個源map,其中包含要合併到目標map的鍵值對。
  3. 遍歷源map的鍵值對。
  4. 對於每個鍵值對,檢查它是否存在於目標map中。
    • 如果存在,將目標map中的值更新為源map中的值。
    • 如果不存在,將源map中的鍵值對新增到目標map中。
  5. 最終,目標map將包含源map中的所有鍵值對以及更新後的值。

以下是具體的Go程式碼示例:

package main

import (
	"fmt"
)
func updateMap(target map[string]int, source map[string]int) {
	for key, value := range source {
		target[key] = value
	}
}
func main() {
	// 建立目標 map
	targetMap := map[string]int{
		"apple":  1,
		"banana": 2,
	}

	// 建立源 map,包含要更新或修改的鍵值對
	sourceMap := map[string]int{
		"apple":  3, // 更新 "apple" 的值為 3
		"cherry": 4, // 新增新的鍵值對 "cherry": 4
	}

	// 呼叫 updateMap 函式,將源 map 合併到目標 map 中
	updateMap(targetMap, sourceMap)

	// 列印更新後的目標 map
	fmt.Println(targetMap) // 輸出:map[apple:3 banana:2 cherry:4]
}

7.3 獲取鍵值對數量

要獲取一個map中鍵值對的數量(也稱為長度),可以使用Go語言的len函式。len函式返回map中鍵值對的數量。以下是獲取map中鍵值對數量的示例:

	// 建立並初始化一個 map
	myMap := map[string]int{
		"apple":  1,
		"banana": 2,
		"cherry": 3,
	}

	// 使用 len 函式獲取 map 的鍵值對數量
	count := len(myMap)

	// 列印鍵值對數量
	fmt.Println("鍵值對數量:", count)

不過,這裡要注意的是我們不能對 map 型別變數呼叫 cap,來獲取當前容量,這是 map 型別與切片型別的一個不同點。

7.4 查詢和資料讀取(判斷某個鍵是否存在)

7.4.1 查詢和資料讀取 map 語法格式

Go語言中有個判斷map中鍵是否存在的特殊寫法,格式如下:

value, ok := map[key]

其中:

  • myMap 是目標map,您希望在其中查詢鍵。
  • key 是您要查詢的鍵。
  • value 是一個變數,如果鍵存在,它將儲存鍵對應的值,如果鍵不存在,則會獲得值型別的零值。
  • ok 是一個布林值,用於指示鍵是否存在。如果鍵存在,oktrue;如果鍵不存在,okfalse

map 型別更多用在查詢和資料讀取場合。所謂查詢,就是判斷某個 key 是否存在於某個 map 中。Go 語言的 map 型別支援透過用一種名為“comma ok”的慣用法,進行對某個 key 的查詢。接下來我們就用“comma ok”慣用法改造一下上面的程式碼:

m := make(map[string]int)
v, ok := m["key1"]
if !ok {
    // "key1"不在map中
}

// "key1"在map中,v將被賦予"key1"鍵對應的value

我們看到,這裡我們透過了一個布林型別變數 ok,來判斷鍵“key1”是否存在於 map 中。如果存在,變數 v 就會被正確地賦值為鍵“key1”對應的 value。

不過,如果我們並不關心某個鍵對應的 value,而只關心某個鍵是否在於 map 中,我們可以使用空識別符號替代變數 v,忽略可能返回的 value:

m := make(map[string]int)
_, ok := m["key1"]
... ...

因此,你一定要記住:在 Go 語言中,請使用“comma ok”慣用法對 map 進行鍵查詢和鍵值讀取操作。

7.4.2 實現get 方法查詢map 對應的key

在Go中,要實現類似Python字典的get()方法,可以編寫一個函式,該函式接受一個map、一個鍵以及一個預設值作為引數。函式將嘗試從map中獲取指定鍵的值,如果鍵不存在,則返回預設值。以下是實現類似get()方法的步驟:

  1. 建立一個函式,命名為get,該函式接受三個引數:map、鍵和預設值。
  2. 在函式中,使用鍵來嘗試從map中獲取對應的值。
  3. 如果值存在,返回該值;如果不存在,則返回預設值空字串。
package main

import (
	"fmt"
)

// 實現類似 Python 字典的 get() 方法
func get(myMap map[string]string, key string) string {
	value, ok := myMap[key]
	if !ok {
		return ""
	}
	return value
}

func main() {
	// 建立並初始化一個 map
	myMap := map[string]string{
		"apple":  "red",
		"banana": "yellow",
		"cherry": "red",
	}

	// 使用 get() 方法獲取鍵 "apple" 的值,如果不存在返回空字串
	appleValue := get(myMap, "apple")
	fmt.Println("Color of 'apple':", appleValue)

	// 使用 get() 方法獲取鍵 "tangerine" 的值,如果不存在返回空字串
	grapeValue := get(myMap, "tangerine")
	if grapeValue == "" {
		fmt.Println("沒有獲取到tangerine的對應的值!")
	} else {
		fmt.Println("Color of 'tangerine':", grapeValue)
	}
}

執行此程式碼將輸出:

Color of 'apple': red
沒有獲取到tangerine的對應的值!

7.5 使用delete()函式刪除鍵值對

使用delete()內建函式從map中刪除一組鍵值對,delete()函式的格式如下:

delete(map, key)

其中:

  • map:表示要刪除鍵值對的map
  • key:表示要刪除的鍵值對的鍵

使用 delete 函式的情況下,傳入的第一個引數是我們的 map 型別變數,第二個引數就是我們想要刪除的鍵。我們可以看看這個程式碼示例:

m := map[string]int {
  "key1" : 1,
  "key2" : 2,
}

fmt.Println(m) // map[key1:1 key2:2]
delete(m, "key2") // 刪除"key2"
fmt.Println(m) // map[key1:1]

7.6 遍歷 map 中的鍵值資料

最後,我們來說一下如何遍歷 map 中的鍵值資料。這一點雖然不像查詢和讀取操作那麼常見,但日常開發中我們還是有這個需求的。在 Go 中,遍歷 map 的鍵值對只有一種方法,那就是像對待切片那樣透過 for range 語句對 map 資料進行遍歷。我們看一個例子:

package main
  
import "fmt"

func main() {
    m := map[int]int{
        1: 11,
        2: 12,
        3: 13,
    }

    fmt.Printf("{ ")
    for k, v := range m {
        fmt.Printf("[%d, %d] ", k, v)
    }
    fmt.Printf("}\n")
}

你看,透過 for range 遍歷 map 變數 m,每次迭代都會返回一個鍵值對,其中鍵存在於變數 k 中,它對應的值儲存在變數 v 中。我們可以執行一下這段程式碼,可以得到符合我們預期的結果:

{ [1, 11] [2, 12] [3, 13] }

如果我們只關心每次迭代的鍵,我們可以使用下面的方式對 map 進行遍歷:

for k, _ := range m { 
  // 使用k
}

當然更地道的方式是這樣的:

for k := range m {
  // 使用k
}

如果我們只關心每次迭代返回的鍵所對應的 value,我們同樣可以透過空識別符號替代變數 k,就像下面這樣:

for _, v := range m {
  // 使用v
}

不過,前面 map 遍歷的輸出結果都非常理想,給我們的表象好像是迭代器按照 map 中元素的插入次序逐一遍歷。那事實是不是這樣呢?我們再來試試,多遍歷幾次這個 map 看看。

我們先來改造一下程式碼:

package main
  
import "fmt"

func doIteration(m map[int]int) {
    fmt.Printf("{ ")
    for k, v := range m {
        fmt.Printf("[%d, %d] ", k, v)
    }
    fmt.Printf("}\n")
}

func main() {
    m := map[int]int{
        1: 11,
        2: 12,
        3: 13,
    }

    for i := 0; i < 3; i++ {
        doIteration(m)
    }
}

執行一下上述程式碼,我們可以得到這樣結果:

{ [1, 11] [2, 12] [3, 13] }
{ [2, 12] [3, 13] [1, 11] }
{ [1, 11] [2, 12] [3, 13] }

我們可以看到,對同一 map 做多次遍歷的時候,每次遍歷元素的次序都不相同。這是 Go 語言 map 型別的一個重要特點,也是很容易讓 Go 初學者掉入坑中的一個地方。所以這裡你一定要記住:程式邏輯千萬不要依賴遍歷 map 所得到的的元素次序。

八、Map的相等性

map 之間不能使用 == 運算子判斷,== 只能用來檢查 map 是否為 nil

func main() {
	map1 := map[string]int{
		"one": 1,
		"two": 2,
	}
	map2 := map1
    if map1 ==nil{
    	fmt.Println("map1為空")
	}else {
		fmt.Println("map1不為空")
	}
	if map1 == map2 { // 直接報錯,不能直接比較
	}
	
}

相關文章