Go高階特性 13 | 引數傳遞:值、引用及指標之間的區別?

Swenson1992發表於2021-02-20

在下面的程式碼中,值型別 address 作為接收者實現了介面 fmt.Stringer,那麼它的指標型別 *address 也就實現了介面 fmt.Stringer。

type address struct {
   province string
   city string
}
func (addr address) String()  string{
   return fmt.Sprintf("the addr is %s%s",addr.province,addr.city)
}

在下面的程式碼示例中,定義了值型別的變數 add,然後把它和它的指標 &add 都作為引數傳給函式 printString,發現都是可以的,並且程式碼可以成功執行。這也證明了當值型別作為接收者實現了某介面時,它的指標型別也同樣實現了該介面。

func main() {
   add := address{province: "北京", city: "北京"}
   printString(add)
   printString(&add)
}
func printString(s fmt.Stringer) {
   fmt.Println(s.String())
}

基於以上結論,是否可以定義一個指向介面的指標。如下所示:

var si fmt.Stringer =address{province: "上海",city: "上海"}
printString(si)
sip:=&si
printString(sip)

在這個示例中,因為型別 address 已經實現了介面 fmt.Stringer,所以它的值可以被賦予變數 si,而且 si 也可以作為引數傳遞給函式 printString。

接著可以使用 sip:=&si 這樣的操作獲得一個指向介面的指標,這是沒有問題的。不過最終無法把指向介面的指標 sip 作為引數傳遞給函式 printString,Go 語言的編譯器會提示你如下錯誤資訊:

./main.go:21:13: cannot use sip (type *fmt.Stringer) as type fmt.Stringer in argument to printString:
    *fmt.Stringer is pointer to interface, not interface

於是可以總結為:雖然指向具體型別的指標可以實現一個介面,但是指向介面的指標永遠不可能實現該介面。

幾乎從不需要一個指向介面的指標,把它忘掉吧,不要讓它在你的程式碼中出現。

通過這個思考題,相信對 Go 語言的值型別、引用型別和指標等概念有了一定的瞭解,但可能也存在一些迷惑。

修改引數

假設定義了一個函式,並在函式裡對引數進行修改,想讓呼叫者可以通過引數獲取最新修改的值。以 person 結構體舉例,如下所示:

func main() {
   p:=person{name: "張三",age: 18}
   modifyPerson(p)
   fmt.Println("person name:",p.name,",age:",p.age)
}
func modifyPerson(p person)  {
   p.name = "李四"
   p.age = 20
}
type person struct {
   name string
   age int
}

在這個示例中,期望通過 modifyPerson 函式把引數 p 中的 name 修改為李四,把 age 修改為 20 。程式碼沒有錯誤,但是執行一下,你會看到如下列印輸出:

person name: 張三 ,age: 18

怎麼還是張三與 18 呢?換成指標引數試試,可以通過指標修改指向的物件資料,如下所示:

modifyPerson(&p)
func modifyPerson(p *person)  {
   p.name = "李四"
   p.age = 20
}

這些程式碼用於滿足指標引數的修改,把接收的引數改為指標引數,以及在呼叫 modifyPerson 函式時,通過&取地址符傳遞一個指標。現在再執行程式,就可以看到期望的輸出了,如下所示:

person name: 李四 ,age: 20

值型別

在上面定義的普通變數 p 是 person 型別的。在 Go 語言中,person 是一個值型別,而 &p 獲取的指標是 *person 型別的,即指標型別。那麼為什麼值型別在引數傳遞中無法修改呢?這也要從記憶體講起。

已經知道變數的值是儲存在記憶體中的,而記憶體都有一個編號,稱為記憶體地址。所以要想修改記憶體中的資料,就要找到這個記憶體地址。現在,來對比值型別變數在函式內外的記憶體地址,如下所示:

func main() {
   p:=person{name: "張三",age: 18}
   fmt.Printf("main函式:p的記憶體地址為%p\n",&p)
   modifyPerson(p)
   fmt.Println("person name:",p.name,",age:",p.age)
}
func modifyPerson(p person)  {
   fmt.Printf("modifyPerson函式:p的記憶體地址為%p\n",&p)
   p.name = "李四"
   p.age = 20
}

其中,把原來的示例程式碼做了更改,分別列印出在 main 函式中變數 p 的記憶體地址,以及在 modifyPerson 函式中引數 p 的記憶體地址。執行以上程式,可以看到如下結果:

main函式:p的記憶體地址為0xc0000a6020
modifyPerson函式:p的記憶體地址為0xc0000a6040
person name: 張三 ,age: 18

會發現它們的記憶體地址都不一樣,這就意味著,在 modifyPerson 函式中修改的引數 p 和 main 函式中的變數 p 不是同一個,這也是在 modifyPerson 函式中修改引數 p,但是在 main 函式中列印後發現並沒有修改的原因。

導致這種結果的原因是 Go 語言中的函式傳參都是值傳遞。 值傳遞指的是傳遞原來資料的一份拷貝,而不是原來的資料本身。

Go高階特性 13 | 引數傳遞:值、引用及指標之間的區別?

以 modifyPerson 函式來說,在呼叫 modifyPerson 函式傳遞變數 p 的時候,Go 語言會拷貝一個 p 放在一個新的記憶體中,這樣新的 p 的記憶體地址就和原來不一樣了,但是裡面的 name 和 age 是一樣的,還是張三和 18。這就是副本的意思,變數裡的資料一樣,但是存放的記憶體地址不一樣。

除了 struct 外,還有浮點型、整型、字串、布林、陣列,這些都是值型別。

指標型別

指標型別的變數儲存的值就是資料對應的記憶體地址,所以在函式引數傳遞是傳值的原則下,拷貝的值也是記憶體地址。現在對以上示例稍做修改,修改後的程式碼如下:

func main() {
   p:=person{name: "張三",age: 18}
   fmt.Printf("main函式:p的記憶體地址為%p\n",&p)
   modifyPerson(&p)
   fmt.Println("person name:",p.name,",age:",p.age)
}
func modifyPerson(p *person)  {
   fmt.Printf("modifyPerson函式:p的記憶體地址為%p\n",p)
   p.name = "李四"
   p.age = 20
}

執行這個示例,會發現列印出的記憶體地址一致,並且資料也被修改成功了,如下所示:

main函式:p的記憶體地址為0xc0000a6020
modifyPerson函式:p的記憶體地址為0xc0000a6020
person name: 李四 ,age: 20

所以指標型別的引數是永遠可以修改原資料的,因為在引數傳遞時,傳遞的是記憶體地址。

小提示:值傳遞的是指標,也是記憶體地址。通過記憶體地址可以找到原資料的那塊記憶體,所以修改它也就等於修改了原資料。

引用型別

下面要介紹的是引用型別,包括 map 和 chan。

map

對於上面的例子,假如不使用自定義的 person 結構體和指標,能不能用 map 達到修改的目的呢?如下所示:

func main() {
   m:=make(map[string]int)
   m["Golang"] = 18
   fmt.Println("Golang的年齡為",m["Golang"])
   modifyMap(m)
   fmt.Println("Golang的年齡為",m["Golang"])
}
func modifyMap(p map[string]int)  {
   p["Golang"] =20
}

我定義了一個 map[string]int 型別的變數 m,儲存一個 Key 為Golang、Value 為 18 的鍵值對,然後把這個變數 m 傳遞給函式 modifyMap。modifyMap 函式所做的事情就是把對應的值修改為 20。現在執行這段程式碼,通過列印輸出來看是否修改成功,結果如下所示:

Golang的年齡為 18
Golang的年齡為 20

確實修改成功了。是不是有不少疑惑?沒有使用指標,只是用了 map 型別的引數,按照 Go 語言值傳遞的原則,modifyMap 函式中的 map 是一個副本,怎麼會修改成功呢?

要想解答這個問題,就要從 make 這個 Go 語言內建的函式說起。在 Go 語言中,任何建立 map 的程式碼(不管是字面量還是 make 函式)最終呼叫的都是 runtime.makemap 函式。

小提示:用字面量或者 make 函式的方式建立 map,並轉換成 makemap 函式的呼叫,這個轉換是 Go 語言編譯器自動完成的。

從下面的程式碼可以看到,makemap 函式返回的是一個 *hmap 型別,也就是說返回的是一個指標,所以建立的 map 其實就是一個 *hmap。

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

因為 Go 語言的 map 型別本質上就是 *hmap,所以根據替換的原則,剛剛定義的 modifyMap(p map) 函式其實就是 modifyMap(p *hmap)。這也是通過 map 型別的引數可以修改原始資料的原因,因為它本質上就是個指標。

為了進一步驗證建立的 map 就是一個指標,修改上述示例,列印 map 型別的變數和引數對應的記憶體地址,如下面的程式碼所示:

func main(){
  //省略其他沒有修改的程式碼
  fmt.Printf("main函式:m的記憶體地址為%p\n",m)
}
func modifyMap(p map[string]int)  {
   fmt.Printf("modifyMap函式:p的記憶體地址為%p\n",p)
   //省略其他沒有修改的程式碼
}

例子中的兩句列印程式碼是新增的,其他程式碼沒有修改,這裡就不再貼出來了。執行修改後的程式,你可以看到如下輸出:

Golang的年齡為 18
main函式:m的記憶體地址為0xc000060180
modifyMap函式:p的記憶體地址為0xc000060180
Golang的年齡為 20

從輸出結果可以看到,它們的記憶體地址一模一樣,所以才可以修改原始資料,得到年齡是 20 的結果。而且在列印指標的時候,直接使用的是變數 m 和 p,並沒有用到取地址符 &,這是因為它們本來就是指標,所以就沒有必要再使用 & 取地址了。

所以在這裡,Go 語言通過 make 函式或字面量的包裝為我們省去了指標的操作,讓我們可以更容易地使用 map。其實就是語法糖,這是程式設計界的老傳統了。

注意:這裡的 map 可以理解為引用型別,但是它本質上是個指標,只是可以叫作引用型別而已。在引數傳遞時,它還是值傳遞,並不是其他程式語言中所謂的引用傳遞。

chan

在 Go 語言併發模組中學的 channel 也可以理解為引用型別,而它本質上也是個指標。

通過下面的原始碼可以看到,所建立的 chan 其實是個 *hchan,所以它在引數傳遞中也和 map 一樣。

func makechan(t *chantype, size int64) *hchan {
    //省略無關程式碼
}

嚴格來說,Go 語言沒有引用型別,但是可以把 map、chan 稱為引用型別,這樣便於理解。除了 map、chan 之外,Go 語言中的函式、介面、slice 切片都可以稱為引用型別。

小提示:指標型別也可以理解為是一種引用型別。

型別的零值

在 Go 語言中,定義變數要麼通過宣告、要麼通過 make 和 new 函式,不一樣的是 make 和 new 函式屬於顯式宣告並初始化。如果宣告的變數沒有顯式宣告初始化,那麼該變數的預設值就是對應型別的零值。

從下面的表格可以看到,可以稱為引用型別的零值都是 nil。

型別 零值
數值型別(int、float等) 0
bool false
string “”(空字串)
struct 內部欄位的零值
slice nil
map nil
指標 nil
函式 nil
chan nil
interface nil

總結

在 Go 語言中,函式的引數傳遞只有值傳遞,而且傳遞的實參都是原始資料的一份拷貝。如果拷貝的內容是值型別的,那麼在函式中就無法修改原始資料;如果拷貝的內容是指標(或者可以理解為引用型別 map、chan 等),那麼就可以在函式中修改原始資料。

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

相關文章