- Go語言高級編程(第2版)
- 柴樹杉 曹春暉
- 2468字
- 2025-08-07 17:56:13
1.4.1 函數
在Go語言中,函數是第一類對象,可以將函數保存到變量中。當然,Go語言中每個類型還可以有自己的方法,方法其實也是函數的一?種。
// 具名函數 func Add(a, b int) int { return a+b } // 匿名函數 var Add = func(a, b int) int { return a+b }
Go語言中的函數可以有多個參數和多個返回值,參數和返回值都是以傳值的方式和被調用者交換數據。在語法上,函數還支持可變數量的參數,可變數量的參數必須是最后出現的參數,可變數量的參數其實是一個切片類型的參?數。
// 多個參數和多個返回值 func Swap(a, b int) (int, int) { return b, a } // 可變數量的參數 // more對應[]int切片類型 func Sum(a int, more ...int) int { for _, v := range more { a += v } return a }
當可變數量的參數是一個空接口類型時,調用者是否解包可變數量的參數會導致不同的結果:
func main() { var a = []interface{}{123, "abc"} Print(a...) // 123 abc Print(a) // [123 abc] } func Print(a ...interface{}) { fmt.Println(a...) }
第一個Print
調用時傳入的是參數a...
,等價于直接調用Print(123, "abc")
;第二個Print
調用時傳入的是未解包的a
,等價于直接調用Print([]interface{}{123, "abc"}
)
。
不僅函數的參數可以有名字,也可以給函數的返回值命名:
func Find(m map[int]int, key int) (value int, ok bool) { value, ok = m[key] return }
如果返回值命名了,可以通過名字來修改返回值,也可以通過defer
語句在return
語句之后修改返回值:
func Inc() (v int) { defer func(){ v++ } () return 42 }
其中defer
語句延遲執行了一個匿名函數,因為這個匿名函數捕獲了外部函數的局部變量v
,這種函數一般稱為閉包。閉包對捕獲的外部變量并不是以傳值方式訪問,而是以引用方式訪?問。
閉包的這種以引用方式訪問外部變量的行為可能會導致一些問題。以Go 1.22版本為界,下面的例子執行會有差異:
func main() { for i := 0; i < 3; i++ { defer func(){ println(i) } () } } // 輸出(版本高于Go 1.22): // 2 // 1 // 0 // 輸出(版本低于Go 1.22): // 3 // 3 // 3
在for
循環
語句中,循環變量i
只會被創建一次,因此defer
語句中的閉包函數每次捕獲的都是同一個i
變量,在循環結束后這個變量的值為3,因此最終輸出的都是3。
上述工作機制完全符合C語言程序員對for
循環的經驗習慣,但是Go語言依然存在大量由這種用法引起的bug。因此,在Go 1.22版本之后,為了配合自定義迭代器必然帶來的語義變化,將for range
的循環變量改成了每次迭代都重新創建一?次。
修復的思路是在每次迭代中為每個defer()
函數
生成獨有的變量。可以用下面兩種方式:
func main() { for i := 0; i < 3; i++ { i := i // 定義一個循環體內的局部變量i defer func(){ println(i) } () } } func main() { for i := 0; i < 3; i++ { // 通過函數傳入i // defer語句對調用參數求值 defer func(i int){ println(i) } (i) } }
第一種方式是在循環體內部再定義一個局部變量,這樣,在每次迭代中,defer
語句中的閉包函數捕獲的都是不同的變量,這些變量的值對應迭代時的值。第二種方式是將循環變量通過閉包函數的參數傳入,defer
語句對調用參數求值。兩種方式都是可以工作的。不過,一般來說,在for
循環內部執行defer
語句并不是一個好的習慣(可能導致大量的defer
延遲執行函數堆積),此處僅為示例,不建議如此使?用。
在Go語言中,如果以切片為參數調用函數,有時候會給人一種參數采用了傳引用的方式的假象:因為在被調用函數內部可以修改傳入的切片的元素。其實,任何可以通過函數參數修改調用參數的情形,都是因為函數參數中顯式或隱式傳入了指針參數。函數參數傳值的規范更準確說是只針對數據結構中固定的部分傳值,例如字符串或切片對應結構體中的指針和字符串長度傳值,但是并不包含指針指向的內容。將切片類型的參數替換為類似reflect.
SliceHeader
結構體就能更好地理解切片傳值的含義了:
func twice(x []int) { for i := range x { x[i] *= 2 } } type IntSliceHeader struct { Data []int Len int Cap int } func twice(x IntSliceHeader) { for i := 0; i < x.Len; i++ { x.Data[i] *= 2 } }
因為切片中的底層數組部分通過隱式指針傳遞(指針本身依然是傳值的,但是指針指向的卻是同一份數據),所以被調用函數可以通過指針修改調用參數切片中的數據。除數據之外,切片結構中還包含了切片長度和切片容量,這兩個信息也是傳值的。如果被調用函數中修改了Len
或Cap
信息,是無法反映到調用參數的切片中的,這時候我們一般會通過返回修改后的切片來更新之前的切片。這也是內置的append()
必須返回一個切片的原?因。
在Go語言中,函數還可以直接或間接地調用自己,也就是支持遞歸調用。Go語言函數的遞歸調用深度在邏輯上沒有限制,函數調用的棧是不會出現溢出錯誤的,因為Go語言運行時會根據需要動態地調整函數棧的大小。每個goroutine剛啟動時只會分配很小的棧(4 KB或8 KB,具體大小依賴實現),根據需要動態調整棧的大小,棧最大可以達到GB級。在Go 1.4以前,采用的是分段式的動態棧,通俗地說就是采用一個鏈表來實現動態棧,每個鏈表的節點內存位置不會發生變化。但是鏈表實現的動態棧對某些跨越鏈表不同節點的熱點調用的性能影響較大,因為相鄰的鏈表節點在內存位置一般不是相鄰的,這會增加CPU高速緩存命中失敗的概率。為了解決熱點調用的CPU緩存命中率問題,Go 1.4之后改用連續的動態棧實現,也就是采用一個類似動態數組的結構來表示棧。不過連續動態棧也帶來了新的問題:當連續棧動態增長時,需要將之前的數據移到新的內存空間,這會導致之前棧中全部變量的地址發生變化。雖然Go語言運行時會自動更新引用了地址變化的棧變量的指針,但Go語言中的指針不再是固定不變的了,因此不能再隨意將指針保存到數值變量中,地址也不能隨意保存到不在垃圾收集器控制的環境中,而且在使用CGO時不能在C語言中長期持有Go語言對象的地?址。
因為Go語言函數的棧會自動調整大小,所以普通Go程序員已經很少需要關心棧的運行機制了。在Go語言規范中甚至故意沒有講到棧和堆的概念。我們無法知道也不需要知道函數參數或局部變量到底是保存在棧中還是堆中,我們只需要知道它們能夠正常工作就可以了。看看下面這個例子:
func f(x int) *int { return &x } func g() int { x = new(int) return *x }
第一個函數直接返回了函數參數變量的地址——這似乎是不可以的,因為如果參數變量在棧中,函數返回之后棧變量就失效了,返回的地址自然也應該失效了。但是Go語言的編譯器和運行時比我們聰明得多,它會保證指針指向的變量在合適的地方。第二個函數內部雖然調用new()
函數創建了*int
類型的指針對象,但是依然不知道它具體保存在哪里。對于有C/C++編程經驗的程序員需要強調的是:不用關心Go語言中函數棧和堆的問題,編譯器和運行時會幫我們搞定;同樣不要假設變量在內存中的位置是固定不變的,指針隨時可能會變化,特別是在你不期望它變化的時?候。
- JSP網絡編程(學習筆記)
- Learning Informatica PowerCenter 10.x(Second Edition)
- YARN Essentials
- VMware虛擬化技術
- 新一代SDN:VMware NSX 網絡原理與實踐
- iPhone應用開發從入門到精通
- Julia 1.0 Programming Complete Reference Guide
- Django 3.0應用開發詳解
- Android應用開發實戰
- ASP.NET Web API Security Essentials
- Python Web自動化測試設計與實現
- Angular Design Patterns
- Learning Unreal Engine Game Development
- Java EE基礎實用教程
- R語言數據分析從入門到實戰