參數(shù)行為解析)
剛接觸 Go 語言的朋友幾乎都會(huì)遇到一個(gè)經(jīng)典困惑在函數(shù)里改了參數(shù)的值為什么回到調(diào)用處一看原變量紋絲不動(dòng)有時(shí)候又發(fā)現(xiàn)明明傳入的是一個(gè)切片或者字典函數(shù)里改了外面卻神奇地跟著變了。這背后就是 Go 語言的值傳遞與指針傳遞機(jī)制在起作用。很多人學(xué)完 C 語言的指針再看 Go 會(huì)覺得似懂非懂也有不少?gòu)?Java、Python 轉(zhuǎn)過來的開發(fā)者會(huì)用「引用類型」的慣性思維去理解 Go 的切片和字典結(jié)果在項(xiàng)目里埋下隱藏 bug。這篇文章會(huì)從一個(gè)最常見的 Go 代碼問題出發(fā)把 Go 函數(shù)的參數(shù)傳遞機(jī)制講清楚。內(nèi)容包括Go 參數(shù)傳遞的本質(zhì)是什么值類型與指針類型的區(qū)別Slice、Map、Channel 這些類型在函數(shù)傳參時(shí)到底怎么表現(xiàn)為什么有時(shí)候函數(shù)里改了外面沒變有時(shí)候又變了哪些場(chǎng)景推薦用指針哪些場(chǎng)景別亂用指針常見誤區(qū)和排查思路。全文代碼基于 Go 語言編寫示例盡可能短小完整方便你在本地直接運(yùn)行驗(yàn)證。如果你正處于 Go 入門階段或者已經(jīng)寫過一些 Go 代碼但對(duì)這些行為還有些模糊這篇文章很適合你。1. 先看一個(gè)讓人困惑的例子我們先不急著背概念先用一段真實(shí)可運(yùn)行的 Go 代碼來還原你可能會(huì)遇到的場(chǎng)景。package main import fmt func changeValue(a int) { a 100 } func main() { x : 10 changeValue(x) fmt.Println(外部 x 的值是:, x) }你覺得這段代碼輸出什么運(yùn)行結(jié)果外部 x 的值是: 10函數(shù)changeValue里明明把a(bǔ)改成 100 了外面x卻還是 10。這不是 Go 的 bug而是 Go 語言的默認(rèn)參數(shù)傳遞方式——值傳遞——決定的。再看一個(gè)稍微不同的例子package main import fmt type Person struct { Name string Age int } func updateAge(p Person) { p.Age 30 } func main() { person : Person{Name: 小明, Age: 18} updateAge(person) fmt.Println(外面的 person.Age:, person.Age) }運(yùn)行結(jié)果外面的 person.Age: 18同樣結(jié)構(gòu)體傳入函數(shù)后函數(shù)內(nèi)部修改了字段但外部結(jié)構(gòu)體沒有改變。這兩個(gè)例子是同一個(gè)問題也是很多 Go 新手第一次寫業(yè)務(wù)代碼時(shí)最容易踩的坑把變量傳給函數(shù)然后在函數(shù)里修改它但函數(shù)結(jié)束后這個(gè)修改不生效。那怎么樣才能讓修改生效呢最直接的辦法是使用指針。package main import fmt func updateAge(p *Person) { p.Age 30 } func main() { person : Person{Name: 小明, Age: 18} updateAge(person) fmt.Println(外面的 person.Age:, person.Age) }運(yùn)行結(jié)果外面的 person.Age: 30這次修改生效了。核心區(qū)別就是函數(shù)接收的參數(shù)類型從Person變成了*Person調(diào)用時(shí)傳入的不是person本身而是person也就是指向person的內(nèi)存地址。理解了現(xiàn)象之后我們需要深入一層為什么Person值傳遞時(shí)修改不生效而*Person指針傳遞時(shí)修改就生效了答案在于 Go 的參數(shù)復(fù)制邏輯。2. 值傳遞函數(shù)拿到的是「復(fù)印件」Go 語言的函數(shù)參數(shù)傳遞永遠(yuǎn)都是對(duì)原變量的拷貝。也就是說當(dāng)你調(diào)用updateAge(person)時(shí)Go 會(huì)復(fù)制一個(gè)person的副本把這個(gè)副本交給函數(shù)內(nèi)部使用。函數(shù)內(nèi)部對(duì)副本做的所有修改都不會(huì)影響原來的變量。為了更直觀地理解可以把這段代碼看作// 調(diào)用時(shí)Go 悄悄執(zhí)行了這一步 var 副本 Person person // 函數(shù)內(nèi)部操作的是 副本而不是 person fun(副本)副本和原變量是兩個(gè)獨(dú)立的內(nèi)存區(qū)域只是值一樣。你改了副本原變量當(dāng)然不變。這是 Go 語言設(shè)計(jì)上刻意保持的簡(jiǎn)單性Go 的所有參數(shù)傳遞都是值傳遞。沒有 C 里的左值引用也沒有 Python 里那么復(fù)雜的“對(duì)象引用 可變性”的規(guī)則。但這里需要特別說明指針本身也是變量指針也有自己的內(nèi)存地址。當(dāng)你把一個(gè)指針傳給函數(shù)時(shí)Go 同樣會(huì)復(fù)制一份指針。復(fù)制的指針和原指針指向同一個(gè)地址所以函數(shù)內(nèi)部通過指針副本去修改目標(biāo)內(nèi)存時(shí)修改會(huì)反映到原變量上。package main import fmt func changeAge(p *int) { *p 99 } func main() { age : 20 changeAge(age) fmt.Println(外面的 age:, age) // 輸出 99 }運(yùn)行結(jié)果外面的 age: 99在changeAge(age)這一行傳入的并不是age變量本身而是age的內(nèi)存地址。函數(shù)參數(shù)p *int接收的是一個(gè)地址值*p表示通過這個(gè)地址找到內(nèi)存位置并操作它。修改的是地址所指的內(nèi)存而不是 p 本身。所以更準(zhǔn)確的說法是Go 都是值傳遞但如果你傳遞的是指針值那么函數(shù)可以通過指針去修改外部變量的內(nèi)容。指針傳遞是值傳遞的一種特殊情形它沒有違反 Go 的傳遞規(guī)則。3. Go 中常見類型的傳遞行為不同數(shù)據(jù)類型在函數(shù)傳參時(shí)的表現(xiàn)差異很大。下面把 Go 開發(fā)中常見類型分成兩類來分析。3.1 值類型Go 中的基本類型、數(shù)組、結(jié)構(gòu)體默認(rèn)都是值語義。基本類型int、float64、bool、string等數(shù)組[3]int這種固定長(zhǎng)度的數(shù)組結(jié)構(gòu)體struct類型。這些類型在傳給函數(shù)時(shí)Go 會(huì)完整復(fù)制整個(gè)變量。對(duì)于大型結(jié)構(gòu)體這種復(fù)制會(huì)有不小的性能開銷因?yàn)閮?nèi)存被完整拷貝了一份。以數(shù)組為例package main import fmt func modifyArray(arr [3]int) { arr[0] 100 } func main() { nums : [3]int{1, 2, 3} modifyArray(nums) fmt.Println(外部數(shù)組:, nums) // 輸出 [1 2 3] }運(yùn)行結(jié)果外部數(shù)組: [1 2 3]如果想讓數(shù)組修改生效需要傳入數(shù)組指針func modifyArray(arr *[3]int) { arr[0] 100 } func main() { nums : [3]int{1, 2, 3} modifyArray(nums) fmt.Println(外部數(shù)組:, nums) // 輸出 [100 2 3] }運(yùn)行結(jié)果外部數(shù)組: [100 2 3]不過在實(shí)際開發(fā)中我們很少直接使用數(shù)組指針因?yàn)?Go 中更推薦使用切片Slice來處理連續(xù)數(shù)據(jù)。3.2 引用類型Slice、Map、ChannelGo 中有一類類型被稱作引用類型典型代表是Slice切片Map字典Channel通道指針函數(shù)、接口這些類型在變量賦值或傳參時(shí)復(fù)制的不是整個(gè)數(shù)據(jù)結(jié)構(gòu)而是數(shù)據(jù)結(jié)構(gòu)的描述信息或底層指針。因此函數(shù)里可能可以修改到外部可見的內(nèi)容。但這里有一個(gè)關(guān)鍵點(diǎn)引用類型傳參并不意味著你在函數(shù)內(nèi)部重新賦值后外部也會(huì)跟著變。以 Slice 為例它是最容易迷惑 Go 新手的類型。Slice 的結(jié)構(gòu)在 Go 內(nèi)部實(shí)際上是一個(gè)包含三個(gè)字段的結(jié)構(gòu)體type SliceHeader struct { Data uintptr // 指向底層數(shù)組的指針 Len int // 切片長(zhǎng)度 Cap int // 切片容量 }當(dāng)你把一個(gè)切片傳給函數(shù)時(shí)Go 會(huì)復(fù)制這個(gè)SliceHeader。新切片和原切片共享同一個(gè)底層數(shù)組所以通過索引修改元素外部能看到變化。但如果函數(shù)內(nèi)部執(zhí)行了append導(dǎo)致底層數(shù)組擴(kuò)容那么新切片指向的可能是新的數(shù)組原切片仍然指向舊數(shù)組外部就看不到了。來看這一段典型的“困惑代碼”package main import fmt func addItem(s []int) { s append(s, 100) } func modifyItem(s []int) { s[0] 999 } func main() { nums : []int{1, 2, 3} modifyItem(nums) fmt.Println(調(diào)用 modifyItem 之后:, nums) // [999 2 3] addItem(nums) fmt.Println(調(diào)用 addItem 之后:, nums) // [999 2 3]length 沒有變成 4 }運(yùn)行結(jié)果調(diào)用 modifyItem 之后: [999 2 3] 調(diào)用 addItem 之后: [999 2 3]modifyItem修改下標(biāo)元素外部可見addItem執(zhí)行 append外部看不到長(zhǎng)度變化。因?yàn)?append 之后切片頭被修改了但函數(shù)參數(shù)是副本這個(gè)副本的Len和Data變化不會(huì)同步回外部變量。如果想要 append 的結(jié)果同步到外部需要返回新切片或者使用切片指針func addItem(s *[]int) { *s append(*s, 100) } func main() { nums : []int{1, 2, 3} addItem(nums) fmt.Println(nums) // [1 2 3 100] }運(yùn)行結(jié)果[1 2 3 100]Map 的行為和 Slice 不太一樣。Map 變量本身就是一個(gè)指向 hmap 結(jié)構(gòu)體的指針當(dāng)你把 map 傳給函數(shù)時(shí)復(fù)制的是這個(gè)指針兩個(gè)變量指向同一個(gè)底層結(jié)構(gòu)。所以函數(shù)內(nèi)修改 map 的元素值外部一定可見。package main import fmt func setKey(m map[string]int) { m[go] 1 } func main() { scores : make(map[string]int) setKey(scores) fmt.Println(scores) // map[go:1] }運(yùn)行結(jié)果map[go:1]但如果你在函數(shù)內(nèi)部重新給 map 變量賦值比如m make(map[string]int)外部不會(huì)變化因?yàn)閰?shù)是 map 指針的副本兩個(gè)變量不再指向同一個(gè)底層結(jié)構(gòu)。Channel 與 Map 類似變量本身是一個(gè)指向 channel 內(nèi)部結(jié)構(gòu)的指針函數(shù)內(nèi)可以往通道發(fā)送數(shù)據(jù)也可以關(guān)閉通道外部可見。但如果你修改 channel 變量本身也不會(huì)同步到外部。通過這一節(jié)的對(duì)比可以看出一個(gè)事實(shí)Go 沒有真正意義上的引用傳遞所有參數(shù)都是值拷貝只是有些類型拷貝的是結(jié)構(gòu)體本身有些類型拷貝的是指針。4. 實(shí)戰(zhàn)案例用戶信息更新為了把上面的知識(shí)點(diǎn)串起來我們寫一個(gè)完整的實(shí)戰(zhàn)示例。這個(gè)示例模擬了日常業(yè)務(wù)中的常見操作更新用戶的昵稱和年齡。4.1 需求與目標(biāo)定義User結(jié)構(gòu)體包含 ID、Name、Age實(shí)現(xiàn)一個(gè)函數(shù)通過用戶 ID 查詢用戶并修改其年齡實(shí)現(xiàn)一個(gè)函數(shù)在傳入的用戶上直接改名演示值傳遞和指針傳遞的差異演示切片傳參時(shí)的常見問題。4.2 創(chuàng)建項(xiàng)目結(jié)構(gòu)新建一個(gè)目錄例如go-value-pointer-demo在里面創(chuàng)建main.gopackage main import fmt type User struct { ID int Name string Age int } // updateUserByValue 使用值傳遞函數(shù)內(nèi)修改字段不會(huì)影響外部 func updateUserByValue(u User) { u.Age 100 } // updateUserByPointer 使用指針傳遞函數(shù)內(nèi)修改字段會(huì)影響外部 func updateUserByPointer(u *User) { u.Age 100 } // renameUser 演示指針參數(shù)下重新賦名字段 func renameUser(u *User, newName string) { u.Name newName } // addUserToSlice 演示切片傳參時(shí) append 的威力 func addUserToSlice(users []User, user User) []User { users append(users, user) return users } func main() { u : User{ID: 1, Name: 小明, Age: 18} // 值傳遞 updateUserByValue(u) fmt.Println(值傳遞后:, u) // 指針傳遞 updateUserByPointer(u) fmt.Println(指針傳遞后:, u) // 指針傳遞修改姓名 renameUser(u, 小紅) fmt.Println(修改姓名后:, u) // 切片傳參演示 userList : []User{User{ID: 1, Name: 第一個(gè), Age: 20}} userList addUserToSlice(userList, User{ID: 2, Name: 第二個(gè), Age: 22}) fmt.Println(切片長(zhǎng)度:, len(userList)) }運(yùn)行結(jié)果值傳遞后: {1 小明 18} 指針傳遞后: {1 小明 100} 修改姓名后: {1 小紅 100} 切片長(zhǎng)度: 24.3 代碼解讀updateUserByValue(u User)函數(shù)接收的是結(jié)構(gòu)體副本修改 Age 不影響外部變量updateUserByPointer(u)函數(shù)接收的是結(jié)構(gòu)體指針u.Age 100等同于(*u).Age 100修改生效renameUser(u, 小紅)同樣修改了外部結(jié)構(gòu)體addUserToSlice返回值方式接收 append 后的新切片這是 Go 中最常見的切片追加模式因?yàn)橹粋髑衅瑹o法把 append 后的長(zhǎng)度同步到外部。這里的核心啟示是在 Go 中如果你想讓函數(shù)修改某個(gè)值類型的變量請(qǐng)傳入指針如果你想讓函數(shù)修改切片長(zhǎng)度請(qǐng)返回新切片或者傳入切片指針。5. 方法接收者指針接收者和值接收者的區(qū)別當(dāng)你給結(jié)構(gòu)體定義方法時(shí)同樣會(huì)遇到值接收者和指針接收者的問題。package main import fmt type Counter struct { Value int } // 值接收者修改不影響外部 func (c Counter) AddByValue() { c.Value } // 指針接收者修改影響外部 func (c *Counter) AddByPointer() { c.Value } func main() { c : Counter{Value: 0} c.AddByValue() fmt.Println(調(diào)用值接收者方法后:, c.Value) // 0 c.AddByPointer() fmt.Println(調(diào)用指針接收者方法后:, c.Value) // 1 }運(yùn)行結(jié)果調(diào)用值接收者方法后: 0 調(diào)用指針接收者方法后: 1在 Go 中方法接收者使用值類型還是指針類型規(guī)則與函數(shù)參數(shù)類似。值接收者方法內(nèi)部操作的是接收者的副本不會(huì)修改原對(duì)象指針接收者方法內(nèi)部操作的是原對(duì)象修改會(huì)影響外部。實(shí)際工程中的一般建議是如果結(jié)構(gòu)體字段需要被方法修改或者結(jié)構(gòu)體比較大復(fù)制代價(jià)高優(yōu)先使用指針接收者如果結(jié)構(gòu)體只是用來讀數(shù)據(jù)可以使用值接收者。另外如果你定義了一個(gè)類型并且不想在方法中修改它值接收者也符合不可變語義。有一點(diǎn)需要特別提醒在一個(gè)類型的方法集合里不要混用值接收者和指針接收者。雖然 Go 編譯器在某些場(chǎng)景下會(huì)自動(dòng)取地址但混用會(huì)讓方法集合的語義變得難以預(yù)測(cè)尤其在接口實(shí)現(xiàn)判斷時(shí)容易出問題。6. 常見問題與排查思路下面整理幾個(gè) Go 開發(fā)者經(jīng)常遇到的關(guān)于值傳遞與指針傳遞的問題。問題現(xiàn)象常見原因解決思路函數(shù)里改了 int 變量外面沒變值類型參數(shù)只是拷貝傳入*int指針或讓函數(shù)返回新值函數(shù)里改了結(jié)構(gòu)體字段外面沒變結(jié)構(gòu)體默認(rèn)值傳遞拷貝了完整副本傳入*Struct指針Slice 傳參后 append外部長(zhǎng)度沒變切片頭被拷貝append 修改的是副本的 Len返回新切片或傳入*[]TSlice 傳參后修改下標(biāo)元素外部變了切片共享底層數(shù)組下標(biāo)修改影響同一數(shù)組正常行為注意是否需要避免修改原數(shù)據(jù)Map 傳參后改值外部變了map 本身是指針包裝正常行為無需額外處理想避免函數(shù)修改外部切片的內(nèi)容切片共享底層數(shù)組使用copy復(fù)制一份再傳入方法里改了字段外部沒變使用了值接收者改用指針接收者排查這種問題有一個(gè)通用思路。先看參數(shù)類型是值類型還是引用類型再看函數(shù)內(nèi)是修改了參數(shù)的字段、元素還是對(duì)整個(gè)參數(shù)重新賦值最后結(jié)合代碼邏輯判斷外部是否應(yīng)該看到變化。對(duì)于 Slice還有一個(gè)常見誤區(qū)很多人以為“Slice 是引用類型所以函數(shù)里 append 后外部也會(huì)變”。實(shí)際上append 是否影響外部取決于底層數(shù)組是否擴(kuò)容。如果容量足夠append 會(huì)寫入原底層數(shù)組外部看到長(zhǎng)度和內(nèi)容都變化如果容量不足append 會(huì)申請(qǐng)新數(shù)組外部看到原數(shù)組不變。這種不確定性正是 append 時(shí)必須接收返回值的根本原因。7. 最佳實(shí)踐與工程建議結(jié)論已經(jīng)清楚了但在真正的項(xiàng)目里還需要有一些工程層面的規(guī)范來避免踩坑。7.1 能用返回值就不要濫用指針修改如果函數(shù)只是基于輸入計(jì)算出一個(gè)新值推薦返回新值而不是通過指針參數(shù)修改外部變量。返回值的方式更清晰更容易測(cè)試也方便做不可變?cè)O(shè)計(jì)。// 推薦返回新值 func increment(i int) int { return i 1 } // 不推薦為了省一個(gè)返回值而使用指針 func incrementPointer(i *int) { *i }7.2 大型結(jié)構(gòu)體優(yōu)先使用指針當(dāng)結(jié)構(gòu)體字段很多、內(nèi)存占用較大時(shí)值傳遞會(huì)完整復(fù)制結(jié)構(gòu)體帶來性能開銷。這種情況建議使用指針接收者或指針參數(shù)。但需要注意小結(jié)構(gòu)體比如只有一兩個(gè) int使用指針反而可能更慢因?yàn)橹羔槺旧硪灿虚_銷。性能優(yōu)化應(yīng)基于 profiling 數(shù)據(jù)而不是過早猜測(cè)。7.3 切片傳參時(shí)明確是否要修改長(zhǎng)度只修改已有元素直接傳切片即可需要追加元素并且希望外部看到新長(zhǎng)度傳*[]T或返回新切片不希望函數(shù)修改原數(shù)據(jù)使用copy復(fù)制底層數(shù)組。一個(gè)常見的 copy 示例func protectOriginal(src []int) []int { tmp : make([]int, len(src)) copy(tmp, src) // 后續(xù)修改 tmp 不影響外部 return tmp }7.4 避免結(jié)構(gòu)體包含 Slice 或 Map 時(shí)直接值拷貝造成的坑有些結(jié)構(gòu)體本身包含切片或字典字段。即使結(jié)構(gòu)體本身是值類型函數(shù)內(nèi)通過值拷本修改結(jié)構(gòu)體的 Slice 元素仍然會(huì)影響外部結(jié)構(gòu)體因?yàn)榍衅蚕淼讓訑?shù)組。這種情況下需要仔細(xì)考慮結(jié)構(gòu)體的賦值語義。一個(gè)更安全的做法是使用深拷貝工具或者在結(jié)構(gòu)體設(shè)計(jì)時(shí)盡量避免嵌套可變引用類型或者讓這些字段在結(jié)構(gòu)體賦值后立即重新分配。7.5 指針判空使用指針參數(shù)時(shí)函數(shù)內(nèi)部要習(xí)慣先判斷nil避免空指針解引用導(dǎo)致 panic。func safeUpdate(u *User) { if u nil { return } u.Age 30 }尤其當(dāng)指針來自外部調(diào)用時(shí)調(diào)用方可能會(huì)傳入 nil這時(shí)函數(shù)內(nèi)部必須有保護(hù)邏輯。7.6 方法接收者統(tǒng)一風(fēng)格一個(gè)類型的所有方法盡量統(tǒng)一使用值接收者或指針接收者不要一會(huì)值一會(huì)指針。這樣在實(shí)現(xiàn)接口、嵌套組合時(shí)不容易產(chǎn)生歧義。Go 官方有一個(gè)實(shí)用的經(jīng)驗(yàn)法則如果類型包含Slice、Map字段或者方法需要修改接收者或者類型較大或者該類型被并發(fā)使用就使用指針接收者。反之可以使用值接收者。7.7 理解 nil 切片和空切片nil 切片和空切片都可以調(diào)用append但它們的內(nèi)存表現(xiàn)不同。nil 切片底層沒有分配數(shù)組append會(huì)分配新數(shù)組空切片分配了底層數(shù)組但沒有元素。這種差異在 JSON 序列化時(shí)也可能體現(xiàn)出來nil 切片序列化為null空切片序列化為[]。如果不想看到這個(gè)差異可以統(tǒng)一初始化方式。8. 總結(jié)與后續(xù)學(xué)習(xí)建議到這兒Go 指針傳遞與值傳遞的核心內(nèi)容已經(jīng)梳理完了。把關(guān)鍵結(jié)論再濃縮一遍Go 語言函數(shù)參數(shù)全部是值傳遞不存在 C 那樣的引用傳遞值類型int、string、數(shù)組、結(jié)構(gòu)體傳參時(shí)復(fù)制整個(gè)變量函數(shù)內(nèi)修改不影響外部指針傳參時(shí)復(fù)制的是指針本身但通過指針可以直接修改原變量Slice 傳參時(shí)復(fù)制的是切片頭修改元素外部可見append 導(dǎo)致擴(kuò)容后外部不可見Map 和 Channel 本身是引用類型包裝傳參后修改元素外部可見需要函數(shù)修改外部值類型的變量時(shí)傳指針或返回新值方法接收者同樣區(qū)分值接收者和指針接收者。下一步可以繼續(xù)掌握幾個(gè)相關(guān)的 Go 知識(shí)點(diǎn)defer與指針的關(guān)系、結(jié)構(gòu)體組合與內(nèi)存布局、接口interface內(nèi)部的值和指針存儲(chǔ)方式、垃圾回收對(duì)指針的影響。這些內(nèi)容都會(huì)頻繁用到本文中的基礎(chǔ)概念。如果你在寫 Go 代碼時(shí)遇到“函數(shù)里改了外面沒變”的困惑最好的驗(yàn)證方式是把問題代碼改成一個(gè)最小示例在本地輸出每一步的值。很多時(shí)候跑一遍就能看清到底是值復(fù)制還是指針共享的問題。希望這篇 Go 指針傳遞教程能幫你把這個(gè)坑填上。