Skip to content

Go函數

在 Go 中,函數是一等公民,函數是 Go 最基礎的組成部分,也是 Go 的核心。

聲明

函數的聲明格式如下

go
func 函數名([參數列表]) [返回值] {
  函數體
}

聲明函數有兩種辦法,一種是通過func關鍵字直接聲明,另一種就是通過var關鍵字來聲明,如下所示

go
func sum(a int, b int) int {
  return a + b
}

var sum = func(a int, b int) int {
  return a + b
}

函數簽名由函數名稱,參數列表,返回值組成,下面是一個完整的例子,函數名稱為Sum,有兩個int類型的參數ab,返回值類型為int

go
func Sum(a int, b int) int {
   return a + b
}

還有一個非常重要的點,即 Go 中的函數不支持重載,像下面的代碼就無法通過編譯

go
type Person struct {
  Name    string
  Age     int
  Address string
  Salary  float64
}

func NewPerson(name string, age int, address string, salary float64) *Person {
  return &Person{Name: name, Age: age, Address: address, Salary: salary}
}

func NewPerson(name string) *Person {
  return &Person{Name: name}
}

Go 的理念便是:如果簽名不一樣那就是兩個完全不同的函數,那麼就不應該取一樣的名字,函數重載會讓代碼變得混淆和難以理解。這種理念是否正確見仁見智,至少在 Go 中你可以僅通過函數名就知道它是干什麼的,而不需要去找它到底是哪一個重載。

參數

Go 中的參數名可以不帶名稱,一般這種是在接口或函數類型聲明時才會用到,不過為了可讀性一般還是建議盡量給參數加上名稱

go
type ExWriter func(io.Writer) error

type Writer interface {
  ExWrite([]byte) (int, error)
}

對於類型相同的參數而言,可以只需要聲明一次類型,不過條件是它們必須相鄰

go
func Log(format string, a1, a2 any) {
  ...
}

變長參數可以接收 0 個或多個值,必須聲明在參數列表的末尾,最典型的例子就是fmt.Printf函數。

go
func Printf(format string, a ...any) (n int, err error) {
  return Fprintf(os.Stdout, format, a...)
}

值得一提的是,Go 中的函數參數是傳值傳遞,即在傳遞參數時會拷貝實參的值。如果你覺得在傳遞切片或 map 時會復制大量的內存,我可以告訴你大可不必擔心,因為這兩個數據結構本質上都是指針。

返回值

下面是一個簡單的函數返回值的例子,Sum函數返回一個int類型的值。

go
func Sum(a, b int) int {
   return a + b
}

當函數沒有返回值時,不需要void,不帶返回值即可。

go
func ErrPrintf(format string, a ...any) {
  _, _ = fmt.Fprintf(os.Stderr, format, a...)
}

Go 允許函數有多個返回值,此時就需要用括號將返回值圍起來。

go
func Div(a, b float64) (float64, error) {
  if a == 0 {
    return math.NaN(), errors.New("0不能作為被除數")
  }
  return a / b, nil
}

Go 也支持具名返回值,不能與參數名重復,使用具名返回值時,return關鍵字可以不需要指定返回哪些值。

go
func Sum(a, b int) (ans int) {
  ans = a + b
  return
}

和參數一樣,當有多個同類型的具名返回值時,可以省略掉重復的類型聲明

go
func SumAndMul(a, b int) (c, d int) {
  c = a + b
  d = a * b
  return
}

不管具名返回值如何聲明,永遠都是以return關鍵字後的值為最高優先級。

go
func SumAndMul(a, b int) (c, d int) {
  c = a + b
  d = a * b
    // c,d將不會被返回
  return a + b, a * b
}

匿名函數

匿名函數就是沒有簽名的函數,例如下面的函數func(a, b int) int,它沒有名稱,所以我們只能在它的函數體後緊跟括號來進行調用。

go
func main() {
   func(a, b int) int {
      return a + b
   }(1, 2)
}

在調用一個函數時,當它的參數是一個函數類型時,這時名稱不再重要,就可以直接傳遞一個匿名函數,如下所示

go
type Person struct {
  Name   string
  Age    int
  Salary float64
}

func main() {
  people := []Person{
    {Name: "Alice", Age: 25, Salary: 5000.0},
    {Name: "Bob", Age: 30, Salary: 6000.0},
    {Name: "Charlie", Age: 28, Salary: 5500.0},
  }

  slices.SortFunc(people, func(p1 Person, p2 Person) int {
    if p1.Name > p2.Name {
      return 1
    } else if p1.Name < p2.Name {
      return -1
    }
    return 0
  })
}

這是一個自定義排序規則的例子,slices.SortFunc接受兩個參數,一個是切片,另一個就是比較函數,不考慮復用的話,我們就可以直接傳遞匿名函數。

閉包

閉包(Closure)這一概念,在一些語言中又被稱為 Lambda 表達式,與匿名函數一起使用,閉包 = 函數 + 環境引用嗎,看下面一個例子:

go
func main() {
  grow := Exp(2)
  for i := range 10 {
    fmt.Printf("2^%d=%d\n", i, grow())
  }
}

func Exp(n int) func() int {
  e := 1
  return func() int {
    temp := e
    e *= n
    return temp
  }
}

輸出

2^0=1
2^1=2
2^2=4
2^3=8
2^4=16
2^5=32
2^6=64
2^7=128
2^8=256
2^9=512

Exp函數的返回值是一個函數,這裡將稱成為grow函數,每將它調用一次,變量e就會以指數級增長一次。grow函數引用了Exp函數的兩個變量:en,它們誕生在Exp函數的作用域內,在正常情況下隨著Exp函數的調用結束,這些變量的內存會隨著出棧而被回收。但是由於grow函數引用了它們,所以它們無法被回收,而是逃逸到了堆上,即使Exp函數的生命周期已經結束了,但變量en的生命周期並沒有結束,在grow函數內還能直接修改這兩個變量,grow函數就是一個閉包函數。

利用閉包,可以非常簡單的實現一個求費波那契數列的函數,代碼如下

go
func main() {
    // 10個斐波那契數
  fib := Fib(10)
  for n, next := fib(); next; n, next = fib() {
    fmt.Println(n)
  }
}

func Fib(n int) func() (int, bool) {
  a, b, c := 1, 1, 2
  i := 0
  return func() (int, bool) {
    if i >= n {
      return 0, false
    } else if i < 2 {
      f := i
      i++
      return f, true
    }

    a, b = b, c
    c = a + b
    i++

    return a, true
  }
}

輸出為

0
1
1
2
3
5
8
13
21
34

延遲調用

defer關鍵字可以使得一個函數延遲一段時間調用,在函數返回之前這些 defer 描述的函數最後都會被逐個執行,看下面一個例子

go
func main() {
  Do()
}

func Do() {
  defer func() {
    fmt.Println("1")
  }()
  fmt.Println("2")
}

輸出

2
1

因為 defer 是在函數返回前執行的,你也可以在 defer 中修改函數的返回值

go
func main() {
  fmt.Println(sum(3, 5))
}

func sum(a, b int) (s int) {
  defer func() {
    s -= 10
  }()
  s = a + b
  return
}

當有多個 defer 描述的函數時,就會像棧一樣先進後出的順序執行。

go
func main() {
  fmt.Println(0)
  Do()
}

func Do() {
  defer fmt.Println(1)
  fmt.Println(2)
  defer fmt.Println(3)
  defer fmt.Println(4)
  fmt.Println(5)
}
0
2
5
4
3
1

延遲調用通常用於釋放文件資源,關閉網絡連接等操作,還有一個用法是捕獲panic,不過這是錯誤處理一節中才會涉及到的東西。

循環

雖然沒有明令禁止,一般建議不要在 for 循環中使用 defer,如下所示

go
func main() {
  n := 5
  for i := range n {
    defer fmt.Println(i)
  }
}

輸出如下

4
3
2
1
0

這段代碼結果是正確的,但過程也許不對。在 Go 中,每創建一個 defer,就需要在當前協程申請一片內存空間。假設在上面例子中不是簡單的 for n 循環,而是一個較為復雜的數據處理流程,當外部請求數突然激增時,那麼在短時間內就會創建大量的 defer,在循環次數很大或次數不確定時,就可能會導致內存佔用突然暴漲,這種我們一般稱之為內存洩漏。

參數預計算

對於延遲調用有一些反直覺的細節,比如下面這個例子

go
func main() {
  defer fmt.Println(Fn1())
  fmt.Println("3")
}

func Fn1() int {
  fmt.Println("2")
  return 1
}

這個坑還是非常隱晦的,筆者以前就因為這個坑,半天排查不出來是什麼原因,可以猜猜輸出是什麼,答案如下

2
3
1

可能很多人認為是下面這種輸出

3
2
1

按照使用者的初衷來說,fmt.Println(Fn1())這部分應該是希望它們在函數體執行結束後再執行,fmt.Println確實是最後執行的,但Fn1()是在意料之外的,下面這個例子的情況就更加明顯了。

go
func main() {
  var a, b int
  a = 1
  b = 2
  defer fmt.Println(sum(a, b))
  a = 3
  b = 4
}

func sum(a, b int) int {
  return a + b
}

它的輸出一定是 3 而不是 7,如果使用閉包而不是延遲調用,結果又不一樣了

go
func main() {
  var a, b int
  a = 1
  b = 2
  f := func() {
    fmt.Println(sum(a, b))
  }
  a = 3
  b = 4
  f()
}

閉包的輸出是 7,那如果把延遲調用和閉包結合起來呢

go
func main() {
  var a, b int
  a = 1
  b = 2
  defer func() {
    fmt.Println(sum(a, b))
  }()
  a = 3
  b = 4
}

這次就正常了,輸出的是 7。下面再改一下,沒有閉包了

go
func main() {
  var a, b int
  a = 1
  b = 2
  defer func(num int) {
    fmt.Println(num)
  }(sum(a, b))
  a = 3
  b = 4
}

輸出又變回 3 了。通過對比上面幾個例子可以發現這段代碼

defer fmt.Println(sum(a,b))

其實等價於

defer fmt.Println(3)

go 不會等到最後才去調用sum函數,sum函數早在延遲調用被執行以前就被調用了,並作為參數傳遞了fmt.Println。總結就是,對於defer直接作用的函數而言,它的參數是會被預計算的,這也就導致了第一個例子中的奇怪現象,對於這種情況,尤其是在延遲調用中將函數返回值作為參數的情況尤其需要注意。

Golang學習網由www.golangdev.cn整理維護