在下面的程式碼中,值型別 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 語言中的函式傳參都是值傳遞。 值傳遞指的是傳遞原來資料的一份拷貝,而不是原來的資料本身。
以 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 協議》,轉載必須註明作者和本文連結