說說不知道的Golang中引數傳遞
> 本文由雲 + 社群發表
導言
幾乎每一個 C++ 開發人員,都被面試過有關於函式引數是值傳遞還是引用傳遞的問題,其實不止於 C++,任何一個語言中,我們都需要關心函式在引數傳遞時的行為。在 golang 中存在著 map、channel 和 slice 這三種內建資料型別,它們極大的方便著我們的日常 coding。然而,當這三種資料結構作為引數傳遞的時的行為是如何呢?本文將從這三個內建結構展開,來介紹 golang 中引數傳遞的一些細節問題。
背景
首先,我們直接的來看一個簡短的示例,下面幾段程式碼的輸出是什麼呢?
//demo1
package main
import "fmt"
func test_string(s string){
fmt.Printf("inner: %v, %v\n",s, &s)
s = "b"
fmt.Printf("inner: %v, %v\n",s, &s)
}
func main() {
s := "a"
fmt.Printf("outer: %v, %v\n",s, &s)
test_string(s)
fmt.Printf("outer: %v, %v\n",s, &s)
}
上文的程式碼段,嘗試在函式 test_string() 內部修改一個字串的數值,通過執行結果,我們可以清楚的看到函式 test_string() 中入參的指標地址發生了變化,且函式外部變數未被內部的修改所影響。因此,很直接的一個結論呼之欲出:golang 中函式的引數傳遞採用的是:值傳遞。
//output
outer: a, 0x40e128
inner: a, 0x40e140
inner: b, 0x40e140
outer: a, 0x40e128
那麼是不是到這兒就回答完,本文就結束了呢?當然不是,請再請看看下面的例子:當我們使用的引數不再是 string,而改為 map 型別傳入時,輸出結果又是什麼呢?
//demo2
package main
import "fmt"
func test_map(m map[string]string){
fmt.Printf("inner: %v, %p\n",m, m)
m["a"]="11"
fmt.Printf("inner: %v, %p\n",m, m)
}
func main() {
m := map[string]string{
"a":"1",
"b":"2",
"c":"3",
}
fmt.Printf("outer: %v, %p\n",m, m)
test_map(m)
fmt.Printf("outer: %v, %p\n",m, m)
}
根據我們前文得出的結論,按照值傳遞的特性,我們毫無疑問的猜想:函式外兩次輸出的結果應該是相同的,同時地址應該不同。然而,事實卻正是相反:
//output
outer: map[a:1 b:2 c:3], 0x442260
inner: map[a:1 b:2 c:3], 0x442260
inner: map[a:11 b:2 c:3], 0x442260
outer: map[b:2 c:3 a:11], 0x442260
沒錯,在函式 test_map() 中對 map 的修改再函式外部生效了,而且函式內外列印的 map 變數地址竟然一樣。做技術開發的人都知道,在原始碼世界中,如果地址一樣,那就必然是同一個東西,也就是說:這儼然成為了一個引用傳遞的特性了。
兩個示例程式碼的結果竟然截然相反,如果上述的內容讓你產生了疑惑,並且你希望徹底的瞭解這過程中發生了什麼。那麼請閱讀完下面的內容,跟隨作者一起從原始碼透過現象看本質。本文接下來的內容,將對 golang 中的 map、channel 和 slice 三種內建資料結構在作為函式引數傳遞時的行為進行分析,從而完整的解析 golang 中函式傳遞的行為。
迷惑人心的 Map
Golang 中的 map,實際上就是一個 hashtable,在這兒我們不需要了解其詳細的實現結構。回顧一下上文的例子我們首先通過 make() 函式(運算子:=是 make() 的語法糖,相同的作用)初始化了一個 map 變數,然後將變數傳遞到 test_map() 中操作。
眾所周知,在任何語言中,傳遞指標型別的引數才可以實現在函式內部直接修改內容,如果傳遞的是值本身的,會有一次拷貝發生(此時函式內外,該變數的地址會發生變化,通過第一個示例可以看出),因此,在函式內部的修改對原外部變數是無效的。但是,demo2 示例中的變數卻完全沒有拷貝發生的跡象,那麼,我們是否可以大膽的猜測,通過 make() 函式建立出來的 map 變數會不會實際上是一個指標型別呢?這時候,我們便需要來看一下原始碼了:
// makemap implements Go map creation for make(map[k]v, hint).
// If the compiler has determined that the map or the first bucket
// can be created on the stack, h and/or bucket may be non-nil.
// If h != nil, the map can be created directly in h.
// If h.buckets != nil, bucket pointed to can be used as the first bucket.
func makemap(t *maptype, hint int, h *hmap) *hmap {
if hint < 0 || hint > int(maxSliceCap(t.bucket.size)) {
hint = 0
}
...
上面是 golang 中的 make() 函式在 map 中通過 makemap() 函式來實現的程式碼段,可以看到,與我們猜測一致的是:makemap() 返回的是一個 hmap 型別的指標*hmap。也就是說:test_map(map) 實際上等同於 test_map(*hmap)。因此,在 golang 中,當 map 作為形參時,雖然是值傳遞,但是由於 make() 返回的是一個指標型別,所以我們可以在函式哪修改 map 的數值並影響到函式外。
我們也可以通過一個不是很恰當的反例來證明這點:
//demo3
package main
import "fmt"
func test_map2(m map[string]string){
fmt.Printf("inner: %v, %p\n",m, m)
m = make(map[string]string, 0)
m["a"]="11"
fmt.Printf("inner: %v, %p\n",m, m)
}
func main() {
var m map[string]string//未初始化
fmt.Printf("outer: %v, %p\n",m, m)
test_map2(m)
fmt.Printf("outer: %v, %p\n",m, m)
}
由於在函式 test_map2() 外僅僅對 map 變數 m 進行了宣告而未初始化,在函式 test_map2() 中才對 map 進行了初始化和賦值操縱,這時候,我們看到對於 map 的更改便無法反饋到函式外了。
//output
outer: map[], 0x0
inner: map[], 0x0
inner: map[a:11], 0x442260
outer: map[], 0x0
跟風的 Channel
在介紹完 map 型別作為引數傳遞時的行為後,我們再來看看 golang 的特殊型別:channel 的行為。還是通過一段程式碼來來入手:
//demo4
package main
import "fmt"
func test_chan2(ch chan string){
fmt.Printf("inner: %v, %v\n",ch, len(ch))
ch<-"b"
fmt.Printf("inner: %v, %v\n",ch, len(ch))
}
func main() {
ch := make(chan string, 10)
ch<- "a"
fmt.Printf("outer: %v, %v\n",ch, len(ch))
test_chan2(ch)
fmt.Printf("outer: %v, %v\n",ch, len(ch))
}
結果如下,我們看到,在函式內往 channel 中塞入數值,在函式外可以看到 channel 的 size 發生了變化:
//output
outer: 0x436100, 1
inner: 0x436100, 1
inner: 0x436100, 2
outer: 0x436100, 2
在 golang 中,對於 channel 有著與 map 類似的結果,其 make() 函式實現原始碼如下:
func makechan(t *chantype, size int) *hchan {
elem := t.elem
...
也就是 make() chan 的返回值為一個 hchan 型別的指標,因此當我們的業務程式碼在函式內對 channel 操作的同時,也會影響到函式外的數值。
與眾不同的 Slice
對於 golang 中 slice 的行為,可以總結一句話:與眾不同。首先,我們來看下 golang 中對於 slice 的 make實現程式碼:
func makeslice(et *_type, len, cap int) slice {
...
我們發現,與 map 和 channel 不同的是,sclie 的 make 函式返回的是一個內建結構體型別 slice 的物件,而並非一個指標型別,其中內建 slice 的資料結構如下:
type slice struct {
array unsafe.Pointer
len int
cap int
}
也就是說,如果採用 slice 在 golang 中傳遞引數,在函式內對 slice 的操作是不應該影響到函式外的。那麼,對於下面的這段示例程式碼,執行的結果又是什麼呢?
//demo5
package main
import "fmt"
func main() {
sl := []string{
"a",
"b",
"c",
}
fmt.Printf("%v, %p\n",sl, sl)
test_slice(sl)
fmt.Printf("%v, %p\n",sl, sl)
}
func test_slice(sl []string){
fmt.Printf("%v, %p\n",sl, sl)
sl[0] = "aa"
//sl = append(sl, "d")
fmt.Printf("%v, %p\n",sl, sl)
}
通過執行結果,我們看到,在函式內部對 slice 中的第一個元素的數值修改成功的返回到了 test_slice() 函式外層!與此同時,通過列印地址,我們發現也顯示了是同一個地址。到了這兒,似乎又一個奇怪的現象出現了:makeslice() 返回的是值型別,但是當該數值作為引數傳遞時,在函式內外的地址卻未發生變化,儼然一副指標型別。
//output
[a b c], 0x442260
[a b c], 0x442260
[aa b c], 0x442260
[aa b c], 0x442260
這時候,我們還是迴歸原始碼,回顧一下上面列出的 golang 內部 slice 結構體的特點。沒錯,細心地讀者可能已經發現,內部 slice 中的第一個元素用來存放資料的結構是個指標型別,一個指向了真正的存放資料的指標!因此,雖然指標拷貝了,但是指標所指向的地址卻未更改,而我們在函式內部修改了指標所指向的地方的內容,從而實現了對元素修改的目的了。
讓我們再進階一下上面的示例,將註釋的那行程式碼開啟:
sl = append(sl, "d")
再重新執行上面的程式碼,得到的結果又有了新的變化:
//output
[a b c], 0x442280
[a b c], 0x442280
[aa b c d], 0x442280
[aa b c], 0x442280
函式內我們修改了 slice 中一個已有元素,同時向 slice 中 append 了另一個元素,結果在函式外部:
- 修改的元素生效了;
- append 的元素卻消失了。
其實這就是由於 slice 的結構引起的了。我們都知道 slice 型別在 make() 的時候有個 len 和 cap 的可選引數,在上面的內部 slice 結構中第二和第三個成員變數就是代表著這倆個引數的含義。我們已知原因,資料部分由於是指標型別,這就決定了在函式內部對 slice 資料的修改是可以生效的,因為值傳遞進去的是指向資料的指標。而同一時刻,表示長度的 len 和容量的 cap 均為 int 型別,那麼在傳遞到函式內部的就僅僅只是一個副本,因此在函式內部通過 append 修改了 len 的數值,但卻影響不到函式外部 slice 的 len 變數,從而,append 的影響便無法在函式外部看到了。
解釋到這兒,基本說清了 golang 中 map、channel 和 slice 在函式傳遞時的行為和原因了,但是,喜歡提問的讀者可能一直覺得有哪兒是怪怪的,這個時候我們來完整的整理一下已經的關於 slice 的資訊和行為:
- makeslice() 出來的一定是個結構體物件,而不是指標;
- 函式內外列印的 slice 地址一致;
- 函式體內對 slice 中元素的修改在函式外部生效了;
- 函式體內對 slice 進行 append 操作在外部沒有生效;
沒錯了,對於問題 1、3 和 4 我們應該都已經解釋清楚了,但是,關於第 2 點為什麼函式內外對於這三個內建型別變數的地址列印卻是一致的?我們已經更加確定了 golang 中的引數傳遞的確是值型別,那麼,造成這一現象的唯一可能就是出在列印函式 fmt.Printf() 中有些小操作了。因為我們是通過%p 來列印地址資訊的,為此,我們需要關注的是 fmt 包中 fmtPointer():
func (p *pp) fmtPointer(value reflect.Value, verb rune) {
var u uintptr
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Map, reflect.Ptr, reflect.Slice, reflect.UnsafePointer:
u = value.Pointer()
default:
p.badVerb(verb)
return
}
...
}
我們發現在 fmtPointer() 中,對於 map、channel 和 slice,都被當成了指標來處理,通過 Pointer() 函式獲取對應的值的指標。我們知道 channel 和 map 是因為 make 函式返回的就已經是指標了,無可厚非,但是對於 slice 這個非指標,在 value.Pointer() 是如何處理的呢?
// If v's Kind is Slice, the returned pointer is to the first
// element of the slice. If the slice is nil the returned value
// is 0. If the slice is empty but non-nil the return value is non-zero.
func (v Value) Pointer() uintptr {
// TODO: deprecate
k := v.kind()
switch k {
case Chan, Map, Ptr, UnsafePointer:
return uintptr(v.pointer())
case Func:
...
case Slice:
return (*SliceHeader)(v.ptr).Data
}
...
}
果不其然,在 Pointer() 函式中,對於 Slice 型別的資料,返回的一直是指向第一個元素的地址,所以我們通過 fmt.Printf() 中%p 來列印 Slice 的地址,其實列印的結果是內部儲存陣列元素的首地址,這也就解釋了問題 2 中為什麼地址會一致的原因了。
總結
通過上述的一系列總結,我們可以很高興的確定的是:在 golang 中的傳參一定是值傳遞了!
然而 golang 隱藏了一些實現細節,在處理 map,channel 和 slice 等這些內建結構的資料時,其實處理的是一個指標型別的資料,也是因此,在函式內部可以修改(部分修改)資料的內容。
但是,這些修改得以實現的原因,是因為資料本身是個指標型別,而不是因為 golang 採用了引用傳遞,注意二者的區別哦~
此文已由作者授權騰訊雲 + 社群在各渠道釋出
獲取更多新鮮技術乾貨,可以關注我們騰訊雲技術社群-雲加社群官方號及知乎機構號
- 加微信實戰群請加微信(註明:實戰群):gocnio
相關文章
- 說說在 Python 中如何傳遞任意數量的實參Python
- linux中main引數傳遞LinuxAI
- python中函式的引數傳遞Python函式
- 引數傳遞
- 為什麼說Java中只有值傳遞。Java
- Golang 切片作為函式引數傳遞的陷阱與解答Golang函式
- JS的方法引數傳遞(按值傳遞)JS
- 面試官:兄弟,說說Java到底是值傳遞還是引用傳遞面試Java
- 引數的定義和引數的傳遞
- Mybatis引數傳遞MyBatis
- 面試官問:Go 中的引數傳遞是值傳遞還是引用傳遞?面試Go
- 請求引數的傳遞
- 函式的引數傳遞函式
- makefile中的一些引數說明
- 獲取url中?後面傳遞的引數
- 深入探討Spring Boot中的引數傳遞Spring Boot
- 傳說中的WindowManager
- mysqldump引數說明MySql
- TOP引數說明
- React事件傳遞引數React事件
- 路由元件傳遞引數路由元件
- C++引數的傳遞方式C++
- SSM框架中Mybatis傳遞引數的幾種方法SSM框架MyBatis
- Java中的引數傳遞有哪些?通俗易懂Java
- 從request中傳遞過來的引數資訊
- 如何計算PHP函式中傳遞的引數數量PHP函式
- 引數傳遞方式必須是const引用傳遞
- Nginx的gzip配置引數說明Nginx
- Python的函式引數傳遞:傳值?引用?Python函式
- Shell學習【引數傳遞】
- 利用閉包傳遞引數
- JavaScript函式傳遞引數JavaScript函式
- out,ref,params引數傳遞
- t-on-click 傳遞引數
- GridView傳遞兩個引數的方法View
- java 傳遞引數的兩種方式Java
- ABAP 方法呼叫的引數傳遞裡,透過引用傳遞的方式,能修改原始引數值嗎?
- 在 `el-upload` 的事件中傳遞更多引數的方法事件