Go結(jié)構(gòu)體指針引發(fā)的值傳遞思考分析
引言
這篇筆記的思考開始于一篇帖子中提的問題:下面這段代碼中,都是從 map 中取一個元素并調(diào)用其方法,為什么最后一行無法編譯通過
import "testing" type S struct { Name string } func (s *S) Write() { s.Name = "name" } func TestX(t *testing.T) { m := map[int]S{1: {"A"}} // 這能編譯通過: s := sVals[1] s.Write() // 這里不能編譯通過 sVals[1].Write() // 報錯 cannot call pointer method Write on S }
要回答這個問題,涉及到 Go 中的幾個概念,隱式引用轉(zhuǎn)換和可尋址 Addressable
隱式引用轉(zhuǎn)換
先看第一次調(diào)用 Write
的地方,首先 sVals[1]
返回的是一個 S
類型的值賦值給變量 s
,而之所以能夠在 S
類型的變量 s
上調(diào)用 *S
類型的 Write
,是因為 Go 支持隱式引用轉(zhuǎn)換,這個調(diào)用的完整寫法應該是:
s := sVals[1] (&s).Write()
Go 隱式引用轉(zhuǎn)換后可以簡寫成
s := sVals[1] s.Write()
那么為什么第二個 Write
調(diào)用無法編譯通過呢?這涉及到另一個概念:可尋址與臨時值。
可尋址和臨時值
可尋址 Addressable 指的是能夠通過內(nèi)存地址來訪問變量的特性。如果一個變量是可尋址的,那么你可以使用取地址操作符 &
來獲取它的內(nèi)存地址。
而臨時值都是不可尋址的,臨時值一句話概括就是表達式的中間狀態(tài),它們的生命周期很短,只在表達式計算過程中存在。臨時值只有在賦值給某個變量后臨時值才算完成了使命,這個過程相當于一個值被創(chuàng)建出來最終安家落戶,有了自己的地址,之后才能詢問這個值的地址是多少。
下面是幾個可尋址例子
// **局部變量**:函數(shù)內(nèi)的局部變量是可尋址的。 func main() { x := 5 p := &x // x 是可尋址的 } // **全局變量**:全局變量也是可尋址的。 var globalVar int func main() { p := &globalVar // globalVar 是可尋址的 } // **數(shù)組的元素**:數(shù)組或切片的元素是可尋址的。 func main() { arr := [3]int{1, 2, 3} p := &arr[1] // arr[1] 是可尋址的 } // **結(jié)構(gòu)體的字段**:如果你有一個結(jié)構(gòu)體變量,那么它的字段是可尋址的。 type MyStruct struct { Field int } func main() { s := MyStruct{Field: 5} p := &s.Field // s.Field 是可尋址的 }
下面是幾個不可尋址的例子
// **直接從函數(shù)調(diào)用返回的值**:不能對函數(shù)調(diào)用的結(jié)果直接取地址。 func myFunc() int { return 5 } func main() { // p := &myFunc() // 這是錯誤的,因為 myFunc() 的結(jié)果不可尋址 } // **基本類型字面量**:如直接對 **5** 取地址是不允許的。 func main() { // p := &5 // 錯誤,字面量不可尋址 } // **臨時結(jié)果**:如表達式的中間結(jié)果。 func main() { x := &MyStruct{5} // 正確,因為這是一個變量 // y := &MyStruct{5}.Field // 錯誤,.Field 是一個臨時值 }
再回到剛才的問題,當調(diào)用
sVals[1].Write()
時,如果 Go 可以進行隱式引用轉(zhuǎn)換,那么就應該轉(zhuǎn)換成下面這種形式:
(&sVals[1]).Write
但實際上卻報了下面的錯誤
cannot call pointer method Write on S
這個錯誤是說不能在類型 S
上調(diào)用指針方法 Write
,這說明 Go 沒有將 sVals[1]
進行引用轉(zhuǎn)換。為什么沒有進行引用轉(zhuǎn)換呢?
這里可以做一個假設,按理說 sVals[1]
的元素已經(jīng)存在于內(nèi)存了,也就是說應該可以被尋址的,所以應該進行隱式引用轉(zhuǎn)換成功。如果沒有進行引用轉(zhuǎn)換,是不是說取出來的對象是一個不能被尋址的對象呢?
事實上確實是就是這樣,sVals[1]
取出來的并不是原始的對象,而是原對象的一個重新生成的副本,這就涉及到另一個概念:值傳遞。
map 的值傳遞
在 Go 中,所有的函數(shù)參數(shù)和返回值都是通過值傳遞的,這意味著它們都是原始數(shù)據(jù)的副本,而不是引用或指針。
這個原則在 map 中也成立,從 map 中取出一個元素返回的也是該元素的副本,而并不是該元素本身。所以上述代碼中
sVals[1]
返回的是一個副本,也就是說這是一個臨時值,而對于臨時值是不可尋址的。所以引用轉(zhuǎn)換是不可能的,最后無法編譯通過報出錯誤。
回答最初的問題
到這里就已經(jīng)可以回答前面的問題了,由于 sVals[1]
是一個臨時值所以不可尋址,所以無法進行引用轉(zhuǎn)換,無法將 S
類型的變量 s
轉(zhuǎn)換成 *S
類型,最后導致編譯錯誤,報出不能在 S
類型上調(diào)用 Write
方法。
為什么要這樣設計
為什么 map 要返回一個副本回來,而不是返回原始對象的地址?這種設計選擇是出于安全性和一致性的考慮。由于 map 可能在運行時進行重新哈希以調(diào)整大小,重哈希后元素的地址可能發(fā)生變化,所以如果支持返回地址,那么可能會在程序運行中出現(xiàn)錯誤。例如一開始持有了一個元素的地址,之后 map 發(fā)生重哈希,地址都變了,再用之前獲取的地址做操作,肯定會出問題。
既然返回的是一個副本,那么想要做出修改的話就需要注意了。例如下面這段代碼
m := map[int]S{} m[1] = S{Name: "11"} s := m[1] s.Name = "22" fmt.Println(s) fmt.Println(m) // 輸出 // {22} // map[1:{11}]
可以看到在 map 中取一個元素并修改其內(nèi)容并不會影響 map 中原有元素。
那么應該如何修改 map 中的元素呢?
第一種是先修改,再回寫:
m := map[int]S{} m[1] = S{Name: "11"} s := m[1] s.Name = "22" m[1] = s // 回寫 fmt.Println(s) fmt.Println(m) // 輸出 // {22} // map[1:{22}]
第二種就是 map 中存放指針類型
m := map[int]*S{} m[1] = &S{Name: "11"} s := m[1] s.Name = "22" fmt.Println(s) fmt.Println(m[1]) // 輸出 // &{22} // &{22}
用指針操作賦值是完整寫法應該是
(*s).Name
,而 *s
是從指責中取出對象操作,自然可以賦值。
容易混淆的值傳遞、引用傳遞與值類型、引用類型
前面一直在討論值傳遞,與之相對應的是引用傳遞。這兩種傳遞方式?jīng)Q定了函數(shù)調(diào)用時參數(shù)是如何傳遞的:
- 值傳遞:值傳遞復制數(shù)據(jù)
- 引用傳遞:引用傳遞復制的是數(shù)據(jù)的地址
Go 采用的就是值傳遞,當調(diào)用一個需要參數(shù)的函數(shù)時,函數(shù)參數(shù)會復制一份,如果參數(shù)是一個指針,也會復制出來一個新的指針對象,但注意復制的是指針對象,即新舊兩個指針對象已經(jīng)完全獨立,有各自的內(nèi)存地址,但是兩個指針對象內(nèi)部指向的目標對象地址沒有改變,如下面代碼和圖示:
s := &S{Name: "s"} fmt.Printf("函數(shù)外,s指針本身的地址:%p\n", &s) fmt.Printf("函數(shù)外,s指向?qū)ο蟮牡刂?%p\n", s) fmt.Println("---") updateObj(s) func updateObj(s *S) { fmt.Printf("函數(shù)內(nèi),s指針本身的地址:%p\n", &s) fmt.Printf("函數(shù)內(nèi),s指向?qū)ο蟮牡刂?%p\n", s) s.Name = "updated" } // 輸出 // 函數(shù)外,s指針本身的地址:0x1400000e058 // 函數(shù)外,s指向?qū)ο蟮牡刂?0x1400005e6d0 // --- // 函數(shù)內(nèi),s指針本身的地址:0x1400000e060 // 函數(shù)內(nèi),s指向?qū)ο蟮牡刂?0x1400005e6d0 // &{updated}
這也證明了有種說法稱 Go 支持引用傳遞的說法是不嚴謹?shù)模@種說法認為,通過傳遞指針,可以實現(xiàn)在函數(shù)內(nèi)部修改對象的效果,所以 Go 支持引用傳遞,而事實上這里面依舊是值傳遞,只不過復制的是指針本身。
除此之外 Go 中數(shù)據(jù)類型還分為值類型和引用類型,這兩種類型決定了數(shù)據(jù)是如何在內(nèi)存中存儲的:
- 值類型:值類型直接存儲數(shù)據(jù),如基本數(shù)據(jù)類型(如 int、float、bool)、結(jié)構(gòu)體(struct)和數(shù)組都是值類型。
- 引用類型:而引用類型存儲的是數(shù)據(jù)的引用,如切片(slice)、映射(map)、通道(channel)等都是引用類型。
可以在 runtime/map.go 中看到通過 makemap
函數(shù)創(chuàng)建一個 map 對象,實際上返回的是一個 *hmap
的指針類型;
在 runtime/chan.go 中可以看到通過 makechan
創(chuàng)建 channel 時返回的是一個 *hchan
指針類型;
在 runtime/slice.go 的 makeslice
返回的直接就是一個指針 unsafe.Pointer
這些都證明了上述幾個類型都是引用類型,也就意味著這些類型作為函數(shù)參數(shù)傳遞時復制的都是指針。
無論是值類型還是引用類型(如指針),在作為參數(shù)傳遞給函數(shù)時都是通過值傳遞的方式。對于指針,雖然函數(shù)接收的是指針的副本,但由于這個副本指向原始數(shù)據(jù)的相同內(nèi)存地址,所以函數(shù)內(nèi)部對該地址的數(shù)據(jù)所做的修改會影響到原始數(shù)據(jù)。
可能得性能問題
最后一個問題,既然函數(shù)傳遞和容器類結(jié)構(gòu)維護存取的都是副本,那么如果反復傳遞一些大對象,就會頻繁復制對象,導致性能下降,所以傳遞對象時,應該盡量傳遞對象的指針,因為即使復制指針,指針類型長度也在可控范圍內(nèi),如在 32 位機上占用 4 字節(jié),在 64 位機上占用 8 字節(jié)。
以上就是Go結(jié)構(gòu)體指針引發(fā)的值傳遞思考分析的詳細內(nèi)容,更多關(guān)于Go結(jié)構(gòu)體指針值傳遞的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
golang踩坑實戰(zhàn)之channel的正確使用方式
Golang?channel是Go語言中一個非常重要的特性,除了用來處理并發(fā)編程的任務中,它還可以用來進行消息傳遞和事件通知,這篇文章主要給大家介紹了關(guān)于golang踩坑實戰(zhàn)之channel的正確使用方式,需要的朋友可以參考下2023-06-06詳解golang避免循環(huán)import問題(“import cycle not allowed”)
這篇文章主要給大家介紹了關(guān)于golang中不允許循環(huán)import問題("import cycle not allowed")的相關(guān)資料,文中通過示例代碼介紹的非常詳細,需要的朋友可以參考借鑒,下面隨著小編來一起學習學習吧2018-08-08Go語言高效I/O并發(fā)處理雙緩沖和Exchanger模式實例探索
這篇文章主要介紹了Go語言高效I/O并發(fā)處理雙緩沖和Exchanger模式實例探索,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2024-01-01