Go高階特性 14 | 記憶體分配:new 和 make 的使用場景

Swenson1992發表於2021-02-20

程式的執行都需要記憶體,比如像變數的建立、函式的呼叫、資料的計算等。所以在需要記憶體的時候就要申請記憶體,進行記憶體分配。在 C/C++ 這類語言中,記憶體是由開發者自己管理的,需要主動申請和釋放,而在 Go 語言中則是由該語言自己管理的,開發者不用做太多幹涉,只需要宣告變數,Go 語言就會根據變數的型別自動分配相應的記憶體。

Go 語言程式所管理的虛擬記憶體空間會被分為兩部分:堆記憶體和棧記憶體。棧記憶體主要由 Go 語言來管理,開發者無法干涉太多,堆記憶體才是我們開發者發揮能力的舞臺,因為程式的資料大部分分配在堆記憶體上,一個程式的大部分記憶體佔用也是在堆記憶體上。

小提示:我們常說的 Go 語言的記憶體垃圾回收是針對堆記憶體的垃圾回收。

變數的宣告、初始化就涉及記憶體的分配,比如宣告變數會用到 var 關鍵字,如果要對變數初始化,就會用到 = 賦值運算子。除此之外還可以使用內建函式 new 和 make,它們的功能非常相似,但可能還是比較迷惑,所以基於記憶體分配,進而引出內建函式 new 和 make,講解他們的不同,以及使用場景。

變數

一個資料型別,在宣告初始化後都會賦值給一個變數,變數儲存了程式執行所需的資料。

變數的宣告

如果要單純宣告一個變數,可以通過 var 關鍵字,如下所示:

var s string

該示例只是宣告瞭一個變數 s,型別為 string,並沒有對它進行初始化,所以它的值為 string 的零值,也就是 “”(空字串)。現在來宣告一個指標型別的變數試試,如下所示:

var sp *string

發現也是可以的,但是它同樣沒有被初始化,所以它的值是 *string 型別的零值,也就是 nil。

變數的賦值

變數可以通過 = 運算子賦值,也就是修改變數的值。如果在宣告一個變數的時候就給這個變數賦值,這種操作就稱為變數的初始化。如果要對一個變數初始化,可以有三種辦法。

  1. 宣告時直接初始化,比如 var s string = “Golang”。
  2. 宣告後再進行初始化,比如 s=”Golang”(假設已經宣告變數 s)。
  3. 使用 := 簡單宣告,比如 s:=”Golang”。

小提示:變數的初始化也是一種賦值,只不過它發生在變數宣告的時候,時機最靠前。也就是說,當你獲得這個變數時,它就已經被賦值了。

現在就對上面示例中的變數 s 進行賦值,示例程式碼如下:

func main() {
   var s string
   s = "張三"
   fmt.Println(s)
}

執行以上程式碼,可以正常列印出張三,說明值型別的變數沒有初始化時,直接賦值是沒有問題的。那麼對於指標型別的變數呢?

在下面的示例程式碼中,宣告瞭一個指標型別的變數 sp,然後把該變數的值修改為“Golang”。

func main() {
   var sp *string
   *sp = "Golang"
   fmt.Println(*sp)
}

執行這些程式碼,會看到如下錯誤資訊:

panic: runtime error: invalid memory address or nil pointer dereference

這是因為指標型別的變數如果沒有分配記憶體,就預設是零值 nil,它沒有指向的記憶體,所以無法使用,強行使用就會得到以上 nil 指標錯誤。

而對於值型別來說,即使只宣告一個變數,沒有對其初始化,該變數也會有分配好的記憶體。

在下面的示例中,宣告瞭一個變數 s,並沒有對其初始化,但是可以通過 &s 獲取它的記憶體地址。這其實是 Go 語言實現的,可以直接使用。

func main() {
   var s string
   fmt.Printf("%p\n",&s)
}

sync.WaitGroup 是一個 struct 結構體,是一個值型別,Go 語言自動分配了記憶體,所以可以直接使用,不會報 nil 異常。

於是可以得到結論:如果要對一個變數賦值,這個變數必須有對應的分配好的記憶體,這樣才可以對這塊記憶體操作,完成賦值的目的。

小提示:其實不止賦值操作,對於指標變數,如果沒有分配記憶體,取值操作一樣會報 nil 異常,因為沒有可以操作的記憶體。

所以一個變數必須要經過宣告、記憶體分配才能賦值,才可以在宣告的時候進行初始化。指標型別在宣告的時候,Go 語言並沒有自動分配記憶體,所以不能對其進行賦值操作,這和值型別不一樣。

小提示:map 和 chan 也一樣,因為它們本質上也是指標型別。

new 函式

宣告的指標變數預設是沒有分配記憶體的,那麼給它分配一塊就可以了。於是就需要今天的主角之一 new 函式出場了,對於上面的例子,可以使用 new 函式進行如下改造:

func main() {
   var sp *string
   sp = new(string)//關鍵點
   *sp = "Golang"
   fmt.Println(*sp)
}

以上程式碼的關鍵點在於通過內建的 new 函式生成了一個 *string,並賦值給了變數 sp。現在再執行程式就正常了。

內建函式 new 的作用是什麼呢?可以通過它的原始碼定義分析,如下所示:

// The new built-in function allocates memory. The first argument is a type,
// not a value, and the value returned is a pointer to a newly
// allocated zero value of that type.
func new(Type) *Type

它的作用就是根據傳入的型別申請一塊記憶體,然後返回指向這塊記憶體的指標,指標指向的資料就是該型別的零值。

比如傳入的型別是 string,那麼返回的就是 string 指標,這個 string 指標指向的資料就是空字串,如下所示:

sp1 = new(string)
fmt.Println(*sp1)//列印空字串,也就是string的零值。

通過 new 函式分配記憶體並返回指向該記憶體的指標後,就可以通過該指標對這塊記憶體進行賦值、取值等操作。

變數初始化

當宣告瞭一些型別的變數時,這些變數的零值並不能滿足要求,這時就需要在變數宣告的同時進行賦值(修改變數的值),這個過程稱為變數的初始化。

下面的示例就是 string 型別的變數初始化,因為它的零值(空字串)不能滿足需要,所以需要在宣告的時候就初始化為“Golang”。

var s string = "Golang"
s1:="Golang"

不止基礎型別可以通過以上這種字面量的方式進行初始化,複合型別也可以,比如示例中的 person 結構體,如下所示:

type person struct {
   name string
   age int
}
func main() {
   //字面量初始化
   p:=person{name: "張三",age: 18}
}

該示例程式碼就是在宣告這個 p 變數的時候,把它的 name 初始化為張三,age 初始化為 18。

指標變數初始化

new 函式可以申請記憶體並返回一個指向該記憶體的指標,但是這塊記憶體中資料的值預設是該型別的零值,在一些情況下並不滿足業務需求。比如我想得到一個 *person 型別的指標,並且它的 name 是Golang、age 是 20,但是 new 函式只有一個型別引數,並沒有初始化值的引數,此時該怎麼辦呢?
要達到這個目的,可以自定義一個函式,對指標變數進行初始化,如下所示:

func NewPerson() *person{
   p:=new(person)
   p.name = "Golang"
   p.age = 20
   return p
}

這個程式碼示例中的 NewPerson 函式就是工廠函式,除了使用 new 函式建立一個 person 指標外,還對它進行了賦值,也就是初始化。這樣 NewPerson 函式的使用者就會得到一個 name 為Golang、age 為 20 的 *person 型別的指標,通過 NewPerson 函式做一層包裝,把記憶體分配(new 函式)和初始化(賦值)都完成了。

下面的程式碼就是使用 NewPerson 函式的示例,它通過列印 *pp 指向的資料值,來驗證 name 是否是Golang,age 是否是 20。

pp:=NewPerson()
fmt.Println("name為",pp.name,",age為",pp.age)

為了讓自定義的工廠函式 NewPerson 更加通用,讓它可以接受 name 和 age 引數,如下所示:

pp:=NewPerson("Golang",20)
func NewPerson(name string,age int) *person{
   p:=new(person)
   p.name = name
   p.age = age
   return p
}

這些程式碼的效果和剛剛的示例一樣,但是 NewPerson 函式更通用,因為你可以傳遞不同的引數,構建出不同的 *person 變數。

make 函式

鋪墊了這麼多,終於到第二個主角 make 函式了。在使用 make 函式建立 map 的時候,其實呼叫的是 makemap 函式,如下所示:

// makemap implements Go map creation for make(map[k]v, hint).
func makemap(t *maptype, hint int, h *hmap) *hmap{
  //省略無關程式碼
}

makemap 函式返回的是 *hmap 型別,而 hmap 是一個結構體,它的定義如下面的程式碼所示:

// A header for a Go map.
type hmap struct {
   // Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go.
   // Make sure this stays in sync with the compiler's definition.
   count     int // # live cells == size of map.  Must be first (used by len() builtin)
   flags     uint8
   B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
   noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
   hash0     uint32 // hash seed
   buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
   oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
   nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)
   extra *mapextra // optional fields
}

可以看到,平時使用的 map 關鍵字其實非常複雜,它包含 map 的大小 count、儲存桶 buckets 等。要想使用這樣的 hmap,不是簡單地通過 new 函式返回一個 *hmap 就可以,還需要對其進行初始化,這就是 make 函式要做的事情,如下所示:

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

是不是發現 make 函式和自定義的 NewPerson 函式很像?其實 make 函式就是 map 型別的工廠函式,它可以根據傳遞它的 K-V 鍵值對型別,建立不同型別的 map,同時可以初始化 map 的大小。

小提示:make 函式不只是 map 型別的工廠函式,還是 chan、slice 的工廠函式。它同時可以用於 slice、chan 和 map 這三種型別的初始化。

總結

通過這節課的講解,相信已經理解了函式 new 和 make 的區別,現在再來總結一下。

new 函式只用於分配記憶體,並且把記憶體清零,也就是返回一個指向對應型別零值的指標。new 函式一般用於需要顯式地返回指標的情況,不是太常用。

make 函式只用於 slice、chan 和 map 這三種內建型別的建立和初始化,因為這三種型別的結構比較複雜,比如 slice 要提前初始化好內部元素的型別,slice 的長度和容量等,這樣才可以更好地使用它們。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
golang

相關文章