Go 語言函數
函數格式
func 函數名(參數列表) [返回值] {
// 函數體
}
參數類型簡寫
**概念:**如果幾個形參或者返回值的類型相同那麼類型只需要寫一次就可以了。例如下面代碼所示。
func f(i int, j int, k int, s string, t string)
// i, j, k都是int類型、s, t都是string類型
func f(i, j, k int, s, t string)
**例如:**下面幾個函數的都是帶有兩個 int 類型的參數,下面的書寫格式都是可以的。
package main
import (
"fmt"
)
func add(x int, y int) int { return x + y }
func sub(x, y int) (z int) { z = x - y; return } // 帶有具名返回值的函數, 下面介紹
func first(x int, _ int) int { return x } // 使用空白標識符, 強調參數2在函數中未使用
func zero(int, int) int { return 0 }
func main() {
fmt.Printf("%T\n", add)
fmt.Printf("%T\n", sub)
fmt.Printf("%T\n", first)
fmt.Printf("%T\n", zero)
}
函數簽名
**概念:**函數的類型稱爲函數簽名。
當兩個函數擁有相同的形參列表和返回列表時,認爲這兩個函數的類型或者簽名是相同的。
形參和返回值的名字不會影響到函數類型,採用簡寫同樣也不會影響到函數的類型。
**特殊例子:**你可能偶爾會看到有些函數沒有函數體,那麼說明這個函數使用除了 Go 以外的語言實現。如下所示:
// 使用匯編語言實現
func Sin(x float64) float64
傳值調用,傳引用調用
對於普通的類型來說,實參是按照值進行傳遞的,所以函數接收到的是每個實參的副本(與其他語言不一樣,Go 中的數組也是傳值調用)。
如果實參是引用類型,比如指針,slice、map、函數或者通道,那麼當函數使用形參變量時就有可能會間接地修改實參變量。
傳值調用演示:
package main
import (
"fmt"
)
func add(val int) { val++ }
func main() {
val := 1
// 傳值調用
add(val)
fmt.Println(val)
}
傳引用調用演示:
package main
import (
"fmt"
)
func add(val *int) { (*val)++ }
func main() {
val := 1
add(&val)
fmt.Println(val)
}
遞歸
函數可以遞歸調用,遞歸代表着函數直接或間接的調用自己。
Go 語言的函數調用棧
大部分編程語言使用固定大小的函數調用棧,常見的大小從 64KB 到 2MB 不等。固定大小棧會限制遞歸的深度,當你用遞歸處理大量數據時,需要避免棧溢出;除此之外,還會導致安全性問題。
與之相反,Go 語言使用可變長度的棧,棧的大小會隨着使用而增長,可達到 1GB 左右的上限。這使得我們可以安全地使用遞歸而不用擔心溢出問題。
演示案例:
package main
import "fmt"
func feedMe(portion int, eaten int) int {
eaten = portion +eaten
if(eaten >= 5) {
fmt.Println("I'm full! I've eaten %d\n", eaten)
return eaten
}
fmt.Println("I'm still hungry! I've eaten %d\n", eaten)
return feedMe(portion,eaten)
}
func main() {
fmt.Println(feedMe(1 ,0))
}
多返回值
**概念:**Go 函數能夠返回不止一個結果,函數可以返回 0 個、1 個或多個結果。
當函數返回多個返回值時,格式如下:
func 函數名(參數列表) (返回值1 類型, 返回值2 類型...) {
// 函數體
// return 返回值1,返回值2
}
**演示案例:**下面的 getPrice() 函數返回 2 個結果,一個爲 int 類型,一個爲 string 類型
package main
import (
"fmt"
)
func getPrize() (int, string) {
i := 2
s := "HelloWorld"
return i,s
}
func main() {
quantity, prize := getPrize()
fmt.Println(quantity, prize)
}
多返回值的接收
規則:當函數返回多個值時,你必須使用相同數量的接收者去接收函數返回的值。
例如,在上面的例子中,getPrice() 函數返回了 2 個結果,那麼我們就用 quantiti 和 price 來接收。
**空標識符(_)的使用:**如果一個函數的返回值你不想要,那麼可以使用空標識符來接收。如下所示,我們不想接收 getPrice() 函數的第 2 個返回值
quantity, _ := getPrize()
具名返回值
**概念:**具名返回值讓函數能夠在返回前將值賦值給具名變量,這有助於提升函數的可讀性,使其功能更加明確。
格式如下:
func 函數名(參數列表) (具名變量1 數據類型, 具名變量2 數據類型...) {
函數體
return
}
相關說明:
-
具名返回值是在函數運行之前就定義好的,因此直接在函數內部給其賦值就好了,不需要定義。
-
具名返回值是函數內部的局部變量。
-
具名返回值會根據數據類型的零值初始化爲相應的默認值。
**例如:**下面的 sayHi() 函數定義了兩個具名變量 x,y,然後我們在函數內爲變量 x,y 賦值,最後使用一個 return 將它們返回。
package main
import "fmt"
func sayHi() (x, y string){
x = "Hello"
y = "World"
return
}
func main() {
fmt.Println(sayHi())
}
**裸返回:**從上面可以看到,如果使用了具名返回值,那麼我們可以不將對應的返回值進行返回,直接 return 即可,這就稱爲裸返回。
func sayHi() (x, y string){
x = "Hello"
y = "World"
// 裸返回
return
}
幾點注意事項
**注意事項①:**即使我們可以裸返回,但是我們也可以顯式的將返回值進行返回。例如下面的代碼是正確的。
func sayHi() (x int, y string){
x = 10
y = "Hello"
// 返回x和y
return x, y
}
**注意事項②:**如果想要顯式返回結果,必須將所有的返回值都進行顯式返回,不能只返回其中的一部分,例如下面的代碼都是錯誤的。
func sayHi() (x int, y string){
x = 10
y = "Hello"
// 只返回了x
return x
}
func sayHi() (x int, y string){
x = 10
y = "Hello"
// 只返回了y
return y
}
**注意事項③:**我們可以不在函數內部定義相關的具名返回值,那麼未定義的返回值將使用自己對應的默認值。
package main
import "fmt"
func sayHi() (x int, y bool, z string){
z = "World"
return
}
func main() {
fmt.Println(sayHi())
}
變長函數(不定參函數)
**概念:**變長函數是指擁有可變參數個數的函數。
不定參本質上是一個切片類型。
**格式如下:**中間使用三個點...,最後使用一個數據類型表示這些參數的數據類型。
func 函數名(切片變量 ... 數據類型) [返回值] {
// 函數體
}
例如:下面定義一個函數來計算不定參數中的數的和。
package main
import "fmt"
func sumNumbers(str string, numbers...int) (string, int){
total := 0
for _,number := range numbers {
total += number
}
return str, total
}
func main() {
fmt.Println(sumNumbers("sum", 1, 2, 3, 4))
}
參數傳遞的注意事項
調用不定參函數時,可以不傳遞參數給函數,此處參數數量爲 0。
package main
func myFunc(numbers...int) { }
func main() {
myFunc()
}
不定參參數列表之前還可以有其他參數值。
package main
func myFunc(str string, numbers...int) { }
func main() {
myFunc("HelloWorld")
myFunc("HelloWorld", 1)
myFunc("HelloWorld", 1, 2)
}
如果出了不定參參數列表還有其他參數,不定參參數列表必須放在最後,不能放在其他參數前面。
//這個函數的定義是錯誤的,不定參參數必須放在後面
func myFunc(numbers...int, str string) { }
變長函數與切片 / 數組的關係
將數組 / slice 傳遞給變長函數:變長函數的參數就是一個切片,因此我們可以直接申請一個數組 / 切片,然後傳遞給變長函數(注意,傳遞的時候需要在最後加上省略號)。
例如:
package main
import (
"fmt"
)
func sum(vals ... int) int {
total := 0
for _, val := range vals {
total += val
}
return total
}
func main() {
fmt.Println(sum(1, 2, 3, 4))
// 同上, 定義一個數組傳遞給變長函數
values := []int { 1, 2, 3, 4 }
fmt.Println(sum(values...))
}
變長函數不等於帶有普通 slice 參數的函數:這兩者在概念上和語法上是不一樣的。
func f(...int) { } // 不定參函數
func g([]int) { } // 參數類型爲slice的函數
演示案例(格式化字符串)
變長函數通常用於格式化字符串。
下面的 errorf() 函數構建一條格式化的錯誤消息,在消息的開頭帶有行號。函數的後綴 f 是廣泛使用的命名習慣,用於可變長的 Printf 風格的字符串格式化輸出函數。
package main
import (
"fmt"
"os"
)
func errorf(linenum int, format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "Line %d: ", linenum)
fmt.Fprintf(os.Stderr, format, args...)
fmt.Fprintln(os.Stderr)
}
func main() {
linenum, name := 12, "count"
errorf(linenum, "underfined: %s", name)
}
函數參數中的 interface{ } 類型意味着這個函數的最後一個參數可以接受任何類型的值,可以參閱 "接口" 的相關介紹。
變量的生命週期(逃逸分析)
**生命週期:**在程序執行過程中變量存在的時間段。
不同級別變量的聲明週期:
-
包級別變量的生命週期是整個程序的執行時間。
-
局部變量有一個動態的聲明週期:每次執行聲明語句時創建一個新的實體,變量一直生存到它變得不可訪問,這是它佔用的存儲空間被回收。函數參數和返回值都是局部變量。
**垃圾回收器如何知道一個變量是否應該被回收?**基本思路是每一個包級別的變量或者局部變量,可以作爲追溯該變量的路徑的源頭,通過指針和其他方式的引用可以找到變量。如果變量的路徑不存在,那麼變量變得不可訪問,因此它不會影響到任何其他的計算過程。
**局部變量的生命週期延長:**因爲變量的聲明週期是通過它是否可達來確定的,所以對於函數或者循環中的這些局部變量來說,它們可以在函數或者循環結束之後繼續存在。可以參考下面的逃逸。
堆棧
在 C 和 C++ 程序中,變量是在堆上還是在棧上,是由內存分配方式決定的(new 申請的在堆上,否則在棧上);但是 Go 語言不同,Go 語言中變量存儲在棧上還是堆上是由程序決定的。
請看下面的演示案例:f() 函數中的 x 是在堆上申請的, 因爲其被全局變量指向了,儘管它是一個局部變量,但是它在 f() 函數執行完之後聲明週期仍然存在,我們稱之爲 "逃逸";但是 g() 函數中的 y 是在棧上申請的, 函數執行完之後就釋放了(不要被 new 誤導了)。
"逃逸" 需要一次額外的內存分配過程,但要記住它在性能優化的時候是有好處的,因爲每一次變量逃逸都需要一次額外的內存分配過程。
var global *int
func f() {
// x是在堆上申請的, 因爲其被全局變量指向了
var x int
x = 1
global = &x
}
// f()函數結束之後, x仍然沒有被釋放, 仍可以正常使用
func g() {
// y是在棧上申請的, 函數執行完之後就釋放了
y := new(int)
*y = 1
}
**go FAQ
**
關於 Go 語言的 “逃逸分析”,可以參考 go FAQ 裏的講解,大意如下:
Go 編譯器在給函數中的局部變量分配空間時會盡可能地分配在棧空間中,但是如果編譯器無法證明函數返回後是否還有該變量的引用存在,則編譯器爲避免懸掛空指針的錯誤,就會將該局部變量分配在堆空間中;
如果局部變量佔用內存很大,Go 編譯器會認爲將其存儲在堆空間中更有意義;
Go 編譯器如果看到了程序中有使用某個變量的地址,則該變量會變成在堆空間上分配內存的候選對象,此時 Go 編譯器會通過分析,判斷出該指針的使用會不會超過函數的範圍,如果沒超過,該變量就可以駐留在棧空間上;如果超過了,就必須將其分配在堆空間中。
垃圾回收對於寫出正確的程序有巨大的幫助,但是免不了考慮內存的負擔。
錯誤處理
函數在運行時可能也會返回錯誤,對於錯誤處理的方法有很多種。
在 Go 中,當函數調用發生錯誤時返回一個附加的結果作爲錯誤,一般將這個錯誤作爲函數的最後一個返回值返回。
例如,下面是一個查詢緩存的例子,其 Lookup() 函數返回 2 個值,其中最後一個返回值爲布爾類型,代表該函數是否發生錯誤。
value, ok := cache.Lookup(key)
if !ok {
// ...cache[key]不存在
}
**error 接口:**對於錯誤的原因可能有很多種,調用者可能需要一些詳細的信息。Go 語言還是提供了 error 接口類型,在發生錯誤時 error 類型被賦值,並且可以通過調用它的 Error 方法或者調用調用 fmt.Println(err) 或 fmt.Printf("%v", err) 直接輸出錯誤信息。error 接口在後面介紹【接口】時詳細介紹。
Go 語言**不提供 try/catch 機制:**Go 語言使用普通的值來表示錯誤,而不提供 try/catch 機制,儘管 Go 語言有異常機制,但是 Go 語言的異常只是針對程序 bug 導致的預料外的錯誤,而不能作爲常規的錯誤處理方法出現在程序中。相比之下,Go 程序使用控制流機制(比如 if 和 return 語句)應對錯誤,這種方法在錯誤處理邏輯方面要求更加小心嚴謹,但這恰恰是設計的要點。
當一個函數調用返回一個錯誤時,調用者應當負責錯誤並採取適合的處理應對。針對不同的處理場景,下面給出了 5 個例子。
場景 1
最常見的情形是將錯誤傳遞下去,使得在子例程中發生的錯誤變爲主調例程的錯誤。
例如,某個函數中調用 http.Get 失敗,則立即向調用者返回這個 HTTP 錯誤:
resp, err := http.Get(url)
if err != nil {
return nil, err
}
例如:fmt.Errorf 使用 fmt.Sprintf 函數格式化一條錯誤消息並且返回一個新的錯誤值。我們爲原始的錯誤消息不斷地添加額外的上下文信息來建立一個可讀的錯誤描述。當錯誤最終被程序的 main 函數處理時,它應當能夠提供一個從最根本問題到總體故障的清晰因果鏈。
doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errof("parsing %s as HTML: %v", url, err)
}
設計一個錯誤消息的時候應當慎重,確保每一條消息的描述都是有意義的,包含充足的相關信息,並且保持一致性,不論被同一個函數還是同一個包下面的一組函數返回時,這樣的錯誤都可以保持統一的形式和錯誤處理方式。
比如, OS 包保證每一個文件操作(比如 as.Open 或針對打開的文件的 Read、Write 或 Close 方法)返回的錯誤不僅包括錯誤的信息(沒有權限、路徑不存在等)還包含文件的名字,因此調用者在構造錯誤消息的時候不需要再包含這些信息。
一般地, f(x) 調用只負責報告函數的行爲 f 和參數值 x,因爲它們和錯誤的上下文相關。調用者負責添加進一步的信息,但是 f(x) 本身並不會,就像上面函數中 URL 和 html.Parse 的關係。
場景 2
對於不固定或者不可預測的錯誤,在短暫的間隔後對操作進行重試是合乎情理的,超出一定的重試次數和限定的時間後再退出報錯。
例如:
package main
import (
"fmt"
"log"
"net/http"
"os"
"time"
)
// WaitForServer嘗試連接URL對應的服務器
// 在一分鐘內使用指數退避策略進行重試
// 歲哦有的嘗試失敗後返回錯誤
func WaitForServer(url string) error {
const timeout = 1 * time.Minute
deadline := time.Now().Add(timeout)
for tries := 0; time.Now().Before(deadline); tries++ {
_, err := http.Head(url)
if err == nil {
return nil // success
}
log.Printf("server not responding (%s); retrying...", err)
time.Sleep(time.Second << uint(tries)) // 指數退避策略
}
return fmt.Errorf("server %s failed to respond after %s", url, timeout)
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, "usage: wait url\n")
os.Exit(1)
}
url := os.Args[1]
if err := WaitForServer(url); err != nil {
fmt.Fprintf(os.Stderr, "Site is down: %v\n", err)
os.Exit(1)
}
}
場景 3
如果依舊不能順利進行下去,調用者能夠輸出錯誤然後優雅地停止程序,但一般這樣的處理應該留給主程序部分。通常庫函數應當將錯誤傳遞給調用者,除非這個錯誤表示一個內部一致性錯誤,這意味着庫內部存在 bug 。例如,在上面的程序的 main 函數中:
if err := WaitForServer(url); err != nil {
fmt.Fprintf(os.Stderr, "Site is down: %v\n", err)
os.Exit(1)
}
一個更加方便的方法是通過調用 log.Fatalf 實現相同的效果。就和所有的日誌函數一樣,它默認會將時間和日期作爲前綴添加到錯誤消息前。
if err := WaitForServer(url); err != nil {
log.Fatalf("Site ios down: %v\n", err)
}
默認的格式有助於長期運行的服務器,而對於交互式的命令行工具則意義不大:
2006/01/02 15:04:05 Site is down: no such domain: bad.gopl.io
一種更吸引人的輸出方式是自己定義命令的名稱作爲 log 包的前綴,並且將日期和時間略去。
log.SetPrefix("wait: ")
log.SetFlags(0)
場景 4
在一些錯誤情況下,只記錄下錯誤信息然後程序繼續運行。同樣地,可以選擇使用 log 包來增加日誌的常用前綴:
if err:= Ping(); err!= nil {
log.Printf("ping failed: %v; networking disabled", err)
}
並且直接輸出到標準錯誤流:
if err:= Ping(); err!= nil {
fmt.Fprintf(os.Stderr, "ping failed: %v; networking disabled\n", err)
}
(所有 log 函數都會爲缺少換行符的日誌補充一個換行符。)
場景 5
在某些罕見的情況下我們直接安全地忽略掉整個日誌:
dir, err := ioutil.TempDir("", "scratch")
if err != nil {
return fmt.Errorf("failed to create temp dir: %v", err)
}
// ...使用臨時目錄...
os.RemoveAll(dir) //忽略錯誤,$TMPDIR 會被週期性刪除
調用 os.RemoveAll 可能會失敗,但程序忽略了這個錯誤,原因是操作系統會週期性地清理臨時目錄。在這個例子中,我們有意地拋棄了錯誤,但程序的邏輯看上去就和我們忘記去處理了一樣。要習慣考慮到每一個函數調用可能發生的出錯情況,當你有意地忽略一個錯誤的時候,清楚地註釋一下你的意圖。
Go 語言的錯誤處理有特定的規律。進行錯誤檢查之後,檢測到失敗的情況往往都在成功之前。如果檢測到的失敗導致函數返回,成功的邏輯一般不會放在 else 塊中而是在外層的作用域中。函數會有一種通常的形式,就是在開頭有一連串的檢查用來返回錯誤,之後跟着實際的函數體一直到最後。
函數變量(函數類型)
**概念:**函數也相當於一種數據類型,也可以當做變量來使用,例如可以將函數賦值給變量,或者傳遞,或者從其他函數中返回。函數變量可以像其他函數一樣調用。
例如,下面我們打印函數的類型:
package main
import "fmt"
func f1() { }
func f2(x int) { }
func f3(x int)string {
return ""
}
func main() {
a := f1
b := f2
c := f3
fmt.Printf("%T\n", a)
fmt.Printf("%T\n", b)
fmt.Printf("%T\n", c)
}
演示案例
例如,下面我們把 square() 函數和 negative() 函數當做變量賦值給 f,然後調用 f 調用函數。
package main
import (
"fmt"
)
func square(n int) int { return n * n}
func negative(n int) int { return -n }
func main() {
// 把square()賦值給f變量
f := square
// 再通過f調用sqaure()函數
fmt.Println(f(3))
// 同理
f = negative
fmt.Println(f(3))
}
當然,對於類型不同函數類型之間是不可以進行賦值的,例如
package main
import (
"fmt"
)
func square(n int) int { return n * n}
func negative(n int) int { return -n }
func product(m, n int) int { return m * n }
func main() {
f := square // 此時f爲func(int)類型
f = product // 錯誤, 不能func(int)類型賦值給func(int, int)類型
}
將函數作爲參數傳遞和返回值返回
既然函數也是一種變量,那麼也就可以作爲函數參數進行傳遞,也可以作爲函數的返回值。
作爲函數參數傳遞:下面的 calc 函數的第 3 個參數傳遞的就是函數類型。
package main
import (
"fmt"
)
func add(num1 int, num2 int) int { return num1 + num2 }
func reduce(num1 int, num2 int) int { return num1 - num2 }
// 參數3是函數類型, 爲含有兩個int類型參數, 返回值類型爲int的函數類型
func calc(num1 int, num2 int, myFunc func(int, int) int) int {
return myFunc(num1, num2)
}
func main() {
fmt.Println(calc(1, 1, add))
fmt.Println(calc(1, 1, reduce))
}
作爲函數的返回值:請看下面演示。
package main
import "fmt"
func ff(a, b int) int {
return a + b
}
func f() func(int, int) int {
return ff
}
func main() {
a := f()
fmt.Println(a(1, 2))
}
**注意事項 1:**函數類型的零值是 nil(空值),調用一個空的函數變量將導致宕機
// 定義了一個函數變量, 類型爲func(int) int
// 因爲沒有賦值, 所以f默認爲nil
var f func(int) int
// 錯誤, 不可以調用nil變量
f(3)
**注意事項 2:**函數變量可以和空值比較,但是不可以與其他函數 / 本身之間進行比較,也不能作爲鍵值出現在 map 中。例如:
var f func(int) int
if f !nil {
f(3)
}
匿名函數(函數字面量)
命名函數必須在包級別的作用域進行聲明,但我們能夠使用 “函數字面量” 在任何表達式內指定函數變量。函數字面量就像函數聲明,但在 func 關鍵字後面沒有函數的名稱。它是一個表達式,它的值稱爲匿名函數。
匿名函數類似於 C++11 中的 lambda 表達式。
例如,下面是一個匿名函數:
func(x, y int) {
fmt.Println(x + y)
}
我們可以把匿名函數賦值給一個變量,然後進行調用;或者用在函數傳參或返回值等地方。
var f = func(x, y int) {
fmt.Println(x + y)
}
f(1, 2)
我們知道,普通的函數是不可以定義在其他函數內部的,但是匿名函數不同,匿名函數可以定義在命名函數中。
func main() {
func(x, y int) {
fmt.Println(x + y)
}
}
另外,匿名函數可以在定義的時候直接調用,例如:
func main() {
func(x, y int) {
fmt.Println(x + y)
}(1, 2)
}
演示案例
先來看一個例子:下面用到了 string.Map() 函數,該函數可以把參數 2 字符串中的每個字符依次傳遞給參數 1 所指定的函數進行處理。例如:
package main
import (
"fmt"
"strings"
)
func add1(r rune) rune { return r + 1}
func main() {
// 把參數2所指定的字符串各個字符依次傳遞給add1()函數
fmt.Println(strings.Map(add1, "123"))
fmt.Println(strings.Map(add1, "ABC"))
}
上面是定義了一個 add1 函數,然後再把其傳遞給 strings.Map() 函數。有了匿名函數的概念,我們可以直接傳遞匿名函數,而不需要重新定義 add1 函數。代碼如下所示。
// 匿名函數: 省略了函數名, 但是func、參數、返回值都有定義
fmt.Println(strings.Map(func (r rune) rune { return r + 1 }, "123"))
// 同上
fmt.Println(strings.Map(func (r rune) rune { return r + 1 }, "ABC"))
捕獲迭代變量(重點)
下面將介紹 Go 語言的詞法作用域規則的陷阱。
假設現在我們有下面這樣一個程序:該程序創建一系列的目錄,然後再刪除這些目錄。可以使用一個包含函數變量的 slice 進行清理操作。
// 創建一個切片, 保存刪除文件的函數
var rmdirs [] func()
// 創建一系列的目錄
for _, d := range tempDirs() {
// 獲取一個文件, 然後創建文件
dir := d
os.MkdirAll(dir, 0755)
// 添加到rmdirs中, 後面用來刪除
rmdirs = append(rmdirs, func() {
os.RemoveAll(dir)
})
}
// ...進行一系列的操作
// 然後再執行刪除目錄的操作, 遍歷rmdirs, 刪除所有文件
for _, rmdir := range rmdirs {
rmdir()
}
上面的代碼是正確的,但是可能會有人疑惑:爲什麼要在第一個 for 循環中引入一個 dir 變量呢?
有些人可能會寫出下面的代碼,直接把 for 循環中的 d 賦值給匿名函數,但是是錯誤的,原因分析:
-
循環變量的作用域是在整個循環週期中,因此 d 存在於整個 for 循環週期中。
-
我們又在 for 循環內部創建了匿名函數(函數變量),下面 "閉包" 章節會講解,在 for 循環裏創建的所有函數變量都共享相同的變量——一個可訪問的存儲位置,而不是固定的值。
-
因此,d 變量在不斷迭代中進行更新,但是 append() 函數里面的匿名函數都共享這個 d 變量,所以 d 變量的實際取值是最後一次迭代時的值並且所有的 os.RemoveAll() 調用都引用這個值,所以導致的最終結果就是最後刪除的是同一個文件。
for _, d := range tempDirs() {
os.MkdirAll(d, 0755)
rmdirs = append(rmdirs, func() {
os.RemoveAll(d)
})
}
// 此處刪除的是同一個文件
for _, rmdir := range rmdirs {
rmdir()
}
因此,我們引入了一個內部變量來進行操作。
for _, d := range tempDirs() {
dir := d // 聲明內部dir, 並以外部d初始化
}
上面這樣的隱患不僅僅存在於使用 range 的 for 循環裏,在下面的循環中也面臨由於無意間捕獲的索引變量 i 而導致的同樣問題。
var rmdirs [] func()
dirs := tempDirs()
for i := 0; i < len(dirs); i++ {
os.MkdirAll(dirs[i], 0755)
rmdirs = append(rmdirs, func() {
os.RemoveAll(dirs[i]) // 此處也相當於存儲了最後一個i的內容
})
}
在 go 語句(goroutine 中講解)和 defer 語句(下面會看到中) 的使用當中,迭代變量捕獲的問題是最頻繁的,這是因爲這兩個邏輯會推遲函數的執行時機,直到循環結束。但是這個問題並不是由 go 或者 defer 語句造成的。
閉包
**概念:**閉包指的是一個函數和與其相關的引用環境組合而成的實體。簡單來說,閉包 = 函數 + 引用環境。
表現形式:
-
當一個函數的返回值是另外一個函數,而返回的那個函數如果調用了其父函數內部的其它變量,如果返回的這個函數在外部被執行,就產生了閉包。
-
使函數外部能夠調用函數內部定義的變量。
演示案例
package main
import (
"fmt"
)
func squares() func() int {
var x int
return func() int {
x++
return x * x
}
}
func main() {
f := squares()
fmt.Println(f()) // 1
fmt.Println(f()) // 4
fmt.Println(f()) // 9
fmt.Println(f()) // 16
}
運行結果如下:
squares() 函數的返回值是一個匿名函數。該匿名函數內部還調用了其作用域外的 x 變量,並且將這個 x 變量連同返回值一起返回出去了。
像上面的 squares() 函數一樣,其返回是一個函數,並且該返回函數還包含了其作用域外的內容,我們就說這個函數產生了閉包。
函數變量類似於使用閉包方法實現的變量,Go 程序員通常把函數變量稱爲閉包。
閉包函數的引用環境
還是看上面的演示案例,在第一次調用 f() 的時候,x 被增加爲 1,第二次調用 f() 的時候 x 變爲 2...... 以此類推。
因此,該案例演示了函數變量不僅是一段代碼還可以擁有狀態。裏面的匿名函數能夠獲取和更新外層 squares 函數的局部變量。這些隱藏的變量引用就是我們把函數歸類爲引用類型而且函數變量無法進行比較的原因。
通過這個例子,再次證明了 " 變量的生命週期不是由它的作用域決定的 ":變量 x 在 main 函數中返回 squares 函數後依舊存在(雖然 x 在這個時候是隱藏在函數變量 f 中的)。
下面來看一個比較複雜的例子:
package main
import "fmt"
// 備註: 參數base被閉包函數捕獲了
func calc(base int) (func(int) int, func(int) int) {
add := func(i int) int {
base += i
return base
}
sub := func(i int) int {
base -= i
return base
}
return add, sub
}
func main() {
f1, f2 := calc(10)
// calc的參數10被比閉包函數捕獲了, 所在在下面的一系列調用中都存在
fmt.Println(f1(1), f2(2)) //11 9
fmt.Println(f1(3), f2(4)) //12 8
fmt.Println(f1(5), f2(6)) //13 7
}
閉包的作用場景之一(減少全局變量的聲明)
閉包的作用之一可以減少全局變量的聲明,但是作用域和生命週期也會延長。
例如,在 C++ 程序中,我們可能會封裝一個類,然後定義一個私有成員,最後再開放一個接口給外部來操作該私有成員。
class MyClass {
public:
int add(int value) { return data += value; }
private:
int data;
};
上面的內容用閉包也就可以實現了,代碼如下:定義一個局部變量,然後使用閉包函數將其返回,獲取返回值之後我們就可以一直只用這個函數了。
func add() func(int) int {
var x int32
f := func(value int32) int {
x += value
return x
}
return f
}
演示案例
再來看一個例子。假設現在我們有這樣的需求:定義了一個 f1 函數(假設是同時開發的),一個 f2 函數(假設是自己開發的),現在我們想要將 f2 作爲參數傳遞給 f1,然後在 f2 內部調用 f1。
那麼,下面的編碼方式是不行的,因爲 f2 的類型不滿足 f1 參數類型要求,所以調用會失敗。
package main
import "fmt"
func f1(f func()) {
f()
}
func f2(x, y int) {
fmt.Println(x + y)
}
func main() {
f1(f2) // 錯誤的, f2的類型不滿足f1參數類型要求
}
有了閉包的概念之後,我們可以重新設計代碼,滿足需求,如下所示:
-
定義第三個函數 f3,我們將 f2 作爲參數傳遞給 f3,並且還傳遞了 f2 需要的相關參數。
-
然後 f3 在內部返回一個匿名函數,匿名函數又調用了 f(x,y),此時產生了閉包。
-
之後我們就可以把 f3 作爲參數傳遞給 f1 了。
package main
import "fmt"
func f1(f func()) {
f()
}
func f2(x, y int) {
fmt.Println(x + y)
}
func f3(f func(int, int), x, y int) func() {
ret := func() {
f(x, y)
}
return ret
}
func main() {
ret := f3(f2, 100, 200)
f1(ret)
}
實際演示案例
下面的 makeSuffixFunc() 函數是一個閉包,其實現的功能是:
-
傳入一個字符串 suffix,然後在閉包函數中進行判斷。
-
func() 對參數 name 進行判斷,如果 name 不是以 suffix 結尾的,那麼就返回 name.suffix;否則直接返回 name。
有了上面的函數之後,我們就可以調用 makeSuffixFunc() 函數來相應的創建一些閉包函數變量。
下面是演示代碼
package main
import (
"fmt"
"strings"
)
func makeSuffixFunc(suffix string) func(string) string {
return func(name string) string {
if !strings.HasSuffix(name, suffix) {
return name + suffix
}
return name
}
}
func main() {
jpgFunc := makeSuffixFunc(".jpg") // 操作.jpg的
txtFunc := makeSuffixFunc(".txt") // 操作.txt的
fmt.Println(jpgFunc("test1")) // 在text1末尾加上.jpg
fmt.Println(jpgFunc("test1.jpg")) // 直接返回
fmt.Println(txtFunc("test2")) // 在text2末尾加上.txt
fmt.Println(txtFunc("test2.txt")) // 直接返回
}
延遲函數調用(defer)
defer 是一個很有用的 Go 語言功能,它能夠讓您在函數返回前執行另一個函數。
defer 語句通常用於執行清理操作或確保操作(如網絡調用)完成後再執行另一個函數。
defer 後面可以跟語句,也可以跟函數。
例如:
package main
import "fmt"
func myFunc() {
defer fmt.Println("I am run after myFunc completes")
fmt.Println("I am myFunc()")
}
func main() {
myFunc()
}
defer 執行時機
在 Go 語言的函數中,return 語句在底層並不是原子操作,它分爲給返回值賦值和 RET 指令兩步。
而 defer 語句執行的時機就在返回值賦值操作後,RET 指令執行前。具體如下圖所示:
下面是一個比較複雜的代碼,值得仔細閱讀以下。
package main
import "fmt"
/* Go語言中函數的return不是原子操作,如果有defe語句,則函數執行流程如下
第一步:返回值賦值
defer
第二步:真正的RET返回
*/
func f1() int {
x := 5
defer func() {
x++ // 修改的是x不是返回值
}()
return x
// 1. 返回值賦值
// 2. defer
// 3. 真正的RET指令
}
func f2() (x int) {
defer func() {
x++
}()
return 5
// 1. 返回值 = x =5
// 2. defer修改x++
// 3. 真正的返回
}
func f3() (y int) {
x := 5
defer func() {
x++ // 修改的是x
}()
return x
// 1. 返回值 = y = x = 5
// 2. defer修改的是x
// 3. 真正的返回
}
func f4() (x int) {
defer func(x int) {
x++ // 改變的是函數中x的副本
}(x)
return 5 // 返回值 = x = 5
}
func f5() (x int) {
defer func(x int) int {
x++
return x
}(x)
return 5
}
// 傳一個x的指針到匿名函數中
func f6() (x int) {
defer func(x *int) {
(*x)++
}(&x)
return 5 // 1. 返回值=x=5 2. defer x=6 3. RET返回
}
func main() {
fmt.Println(f1()) // 5
fmt.Println(f2()) // 6
fmt.Println(f3()) // 5
fmt.Println(f4()) // 5
fmt.Println(f5()) // 5
fmt.Println(f6()) // 6
}
多條 defer 語句
如果一個函數有多個 defer 語句,那麼 defer 語句的執行順序與它們的定義順序相反。
defer 語句沒有使用次數的限制。
例如:
package main
import "fmt"
func otherFunc() {
fmt.Println("I am otherFunc()")
}
func myFunc() {
defer fmt.Println("first")
defer otherFunc()
defer fmt.Println("second")
fmt.Println("I am myFunc()")
}
func main() {
myFunc()
}
使用 defer 使程序更加安全和簡潔
現在我們有下面這樣一個程序:下面的 title 函數判斷請求的 URL 是否是一個 HTML 文檔,如果是的話返回 Content-Type 頭部,如果不是文檔則返回錯誤。
package main
import (
"fmt"
"net/http"
"os"
"strings"
"golang.org/x/net/html"
)
func forEachNode(n *html.Node, pre, post func(n *html.Node)) {
if pre != nil {
pre(n)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
forEachNode(c, pre, post)
}
if post != nil {
post(n)
}
}
func title(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
// 檢查Content-Type是HTML( 如"text/html; charset=utf-8")
ct := resp.Header.Get("Content-Type")
if ct != "text/html" && !strings.HasPrefix(ct, "text/html;") {
resp.Body.Close()
return fmt.Errorf("%s has type %s, not text/html", url, ct)
}
doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
return fmt.Errorf("parsing %s as HTML: %v", url, err)
}
visitNode := func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "title" &&
n.FirstChild != nil {
fmt.Println(n.FirstChild.Data)
}
}
forEachNode(doc, visitNode, nil)
return nil
}
func main() {
for _, arg := range os.Args[1:] {
if err := title(arg); err != nil {
fmt.Fprintf(os.Stderr, "title: %v\n", err)
}
}
}
上面的 titile() 函數中有多個 resp.Body.Clise() 的調用,該調用保證 title 函數在任何執行路徑下都會關閉網絡連接。但是隨着函數越來越複雜,並且需要處理更多的情況,這樣重複的調用 resp.Body.Clise() 會使得代碼比較複雜且不容易維護。
下面我們來看看 defer 機制是如何優化代碼的(只給出了 titlr() 函數):我們在函數中定義了 "defer resp.Body.Close()",這樣就使得 title() 函數在執行完之後一定會關閉網絡連接,這樣就使得代碼量降低並且容易管理。
func title(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
// 該函數調用會在title()函數執行完之後調用
defer resp.Body.Close()
ct := resp.Header.Get("Content-Type")
if ct != "text/html" && !strings.HasPrefix(ct, "text/html;") {
return fmt.Errorf("%s has type %s, not text/html", url, ct)
}
doc, err := html.Parse(resp.Body)
if err != nil {
return fmt.Errorf("parsing %s as HTML: %v", url, err)
}
visitNode := func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "title" &&
n.FirstChild != nil {
fmt.Println(n.FirstChild.Data)
}
}
forEachNode(doc, visitNode, nil)
return nil
}
defer 常用於成對操作
defer 語句經常適用於成對的操作,比如打開和關閉,連接和斷開,加鎖和解鎖等等。
例如,上面開啓網絡連接,然後關閉網絡連接的操作。
又例如,關閉一個打開的文件。
func ReadFile(filename string) ([]byte, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
// 執行文件關閉操作
defer f.Close()
return ReadAll(f)
}
又例如,解鎖一個互斥鎖。
var mu sync.Mutex
var m = make(map[string]int)
func lookup(key string) int {
mu.lock()
// 解鎖
defer mu.Unlock()
return m[key]
}
一個面試題
package main
import "fmt"
func calc(index string, a, b int) int {
ret := a + b
fmt.Println(index, a, b, ret)
return ret
}
func main() {
x := 1
y := 2
defer calc("AA", x, calc("A", x, y)) // 第15行
x = 10
defer calc("BB", x, calc("B", x, y)) // 第19行
y = 20
}
**第一步:**main() 函數在運行到第 15 行的時候,先註冊第一個 calc() 函數,但是註冊該函數和之前,該函數的參數調用了 calc(),所以先運行 calc("A", x, y) 打印 "A 1 2 3"。
**第二步:**運行完第一步之後,註冊第 15 行的 calc() 函數,註冊時 calc() 的函數簽名爲 calc("AA",1,3)。
**第三步:**之後運行到第 19 行,要註冊第二個 calc() 函數,與第一步一樣,註冊該函數之前,該函數的參數調用了 calc(),所以先運行 calc("B", x, y) 打印 "B 10 2 12"。
**第四步:**運行完第三步之後,註冊第 19 行的 calc() 函數,註冊時 calc() 的函數簽名爲 calc("BB",10,12)。
**第五步:**main() 函數執行完之後,按照 defer 的規則,先調用 calc("BB",10,12) 打印 "BB 10 12 22",再調用 calc("AA",1,3) 打印 "AA 1 3 4"。
使用 defer 調試複雜函數
defer 語句也可以用來調試一個複雜的函數,即在函數的 “入口” 和“出口”處設置調試行爲。下面的 bigSlowOperation 函數在開頭調用 trace 函數,在函數剛進入的時候執行輸出,然後返回一個函數變量,當其被調用的時候執行退出函數的操作。以這種方式推遲返回函數的調用,我們可以使用一個語句在函數入口和所有出口添加處理,甚至可以傳遞一些有用的值,比如每個操作的開始時間。但別忘了 defer 語旬末尾的圓括號,否則入口的操作會在函數退出時執行而出口的操作永遠不會調用!
package main
import (
"log"
"time"
)
func bigSlowOperation() {
defer trace("bigSlowOperation")()
time.Sleep(10 * time.Second)
}
func trace(msg string) func() {
start := time.Now()
log.Printf("enter %s", msg)
return func() { log.Printf("exit %s (%s)", msg, time.Since(start))}
}
func main() {
bigSlowOperation()
}
每次調用 bigSlowOperation,它會記錄進入函數入口和出口的時間與兩者之間的時間差。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/27rwx-SU-KKdfbns09G5Bw