Go函式介紹與一等公民

賈維斯Echo發表於2023-10-14

Go函式介紹與一等公民

函式對應的英文單詞是 FunctionFunction 這個單詞原本是功能、職責的意思。程式語言使用 Function 這個單詞,表示將一個大問題分解後而形成的、若干具有特定功能或職責的小任務,可以說十分貼切。函式代表的小任務可以在一個程式中被多次使用,甚至可以在不同程式中被使用,因此函式的出現也提升了整個程式界程式碼複用的水平

一、Go 函式介紹

所謂函式,就是組織好的,可重複使用的,用於執行指定任務的程式碼塊。

1.1 介紹

在 Go 語言中,函式是唯一一種基於特定輸入,實現特定任務並可返回任務執行結果的程式碼塊(Go 語言中的方法本質上也是函式),然後在程式中多次呼叫它。如果忽略 Go 包在 Go 程式碼組織層面的作用,我們可以說 Go 程式就是一組函式的集合,實際上,我們日常的 Go 程式碼編寫大多都集中在實現某個函式上。

Go函式也是Go 程式碼中的基本功能邏輯單元,以下是一些Go函式的基本特點:

  • Go函式以關鍵字func開始,後面跟著函式名、引數列表和返回型別。
  • 函式名和引數列表一起被稱為函式的簽名。
  • Go支援多返回值,函式可以返回多個值。
  • 函式可以有零個或多個引數,這些引數是輸入函式的值。
  • 函式可以沒有返回值,也可以有一個或多個返回值。
  • Go函式是一等公民,可以賦值給變數,傳遞給其他函式,以及從函式返回。
  • 你可以為函式定義接收者,建立方法(與物件關聯的函式)。
  • 函式可以遞迴呼叫自身。

1.2 特點

  • 無需宣告原型:Go函式無需預先宣告原型。你可以直接定義函式,然後在其他地方呼叫它,不需要提前宣告函式的簽名。
  • 支援不定變參:Go函式可以接受不定數量的引數,這些引數在函式內部可以透過切片來處理。這是透過在引數列表中使用省略號...來實現的。
  • 支援多返回值:Go函式可以返回多個值,而不僅僅是單一值。這在處理多個返回結果的情況下非常有用。
  • 支援命名返回引數:你可以為函式的返回值命名,這樣在函式體內可以直接使用這些變數名,同時也可以提高函式的可讀性。
  • 支援匿名函式和閉包:Go支援匿名函式,允許你在函式內部定義其他函式。這還支援閉包,使得內部函式可以訪問外部函式的變數。
  • 函式也是一種型別:在Go中,函式也是一種型別,你可以將函式賦值給變數,作為引數傳遞給其他函式,或從函式返回。
  • 不支援巢狀:Go不支援在同一個包中擁有相同名稱的函式,即不允許函式巢狀。
  • 不支援過載:Go不支援函式過載,也就是說,不能有相同函式名但不同引數列表的多個函式。
  • 不支援預設引數:Go不支援為函式引數提供預設值,如果需要不同的引數配置,通常使用函式過載來實現。

二、函式宣告

2.1 Go 函式宣告

Go語言中定義函式使用func關鍵字,具體格式如下:

func functionName(parameter1 type1, parameter2 type2, ...) (value1 return_type1, value2 return_type2, ...) {
    // 函式體
    return
}

讓我解釋一下每個部分:

  1. func:這是關鍵字,用於定義一個函式。
  2. functionName:這是函式的名稱。函式名稱是識別符號,它用於標識函式的名稱。遵循Go的識別符號命名規則,通常使用駝峰式命名法。在同一個包中,函式名應該是唯一的。遵循Go的匯出規則,如果函式名以大寫字母開頭,它可以在包的外部使用,否則只能在包內使用。
  3. (parameter1 type1, parameter2 type2, ...):這是函式的引數列表。引數列表包括了函式需要接受的引數。引數列表使用圓括號括起來,引數之間使用逗號分隔。每個引數都包括引數名和引數型別。在引數列表中,你可以定義零個或多個引數。還可以使用變長引數,其語法是在引數型別前新增 ...
  4. (value1 return_type1, value2 return_type2, ...):這是函式的返回值列表。返回值列表指定了函式執行後將返回的結果的型別。返回值列表跟在引數列表後面,兩者之間用一個空格隔開。你可以宣告一個或多個返回值。如果宣告瞭多個返回值,它們應該用括號括起來。通常,Go函式會返回一個或多個值,但如果不需要返回任何值,可以省略返回值列表。
  5. 函式體:這是函式的主體,簡稱函式體,包括函式執行的程式碼塊。函式體包含在花括號 {} 中。

我們來看個例子,下圖是Go 標準庫 fmt 包提供的 Fprintf 函式宣告:

img

我們看到 Go 標準庫 fmt 包提供的 Fprintf 函式 由五部分組成,逐一拆解為:

  1. func 關鍵字:函式宣告始終以 func 關鍵字開始,表示函式定義。
  2. 函式名:函式名是 Fprintf,這是該函式的識別符號,用於在程式碼中呼叫該函式。
  3. 引數列表
    • w io.Writer:這是第一個引數,是一個介面型別 io.Writer。它表示一個用於寫入的輸出流,可以是檔案、網路連線等。
    • format string:這是第二個引數,是一個字串型別,用於指定輸出的格式。
    • a ...interface{}:這是第三個引數,是變長引數,使用了 ... 運算子。這個引數可以接受任意數量的引數,這些引數將根據 format 字串進行格式化輸出。
  4. 返回值列表
    • (n int, err error):這是返回值列表,包括兩個返回值。第一個返回值 n 是一個整數,表示寫入的位元組數。第二個返回值 err 是一個錯誤型別,用於指示是否在寫入時出現了錯誤。返回值列表不僅宣告瞭返回值的型別,還宣告瞭返回值的名稱,這種返回值被稱為具名返回值。多數情況下,我們不需要這麼做,只需宣告返回值的型別即可。
  5. 函式體:函式體包含了實際的程式碼。在這個示例中,Fprintf 函式建立一個新的列印器(newPrinter()),然後呼叫 doPrintf 方法執行格式化操作。接下來,它將格式化的內容寫入到 w 中,並返回寫入的位元組數和錯誤。最後,它釋放資源並返回結果。

2.3 函式宣告與變數宣告形式上的差距

同為宣告,為啥函式宣告與之前的變數宣告在形式上差距這麼大呢? 變數宣告中的變數名、型別名和初值與上面的函式宣告是怎麼對應的呢?

這裡我們就橫向對比一下,把上面fmt標準庫的Fprintf的宣告等價轉換為變數宣告的形式看看:

img

轉換後的程式碼不僅和之前的函式宣告是等價的,而且這也是完全合乎 Go 語法規則的程式碼。對照一下這兩張圖,你是不是有一種豁然開朗的感覺呢?這不就是在宣告一個型別為函式型別的變數嗎!

我們看到,函式宣告中的函式名其實就是變數名,函式宣告中的 func 關鍵字、引數列表和返回值列表共同構成了函式型別而引數列表與返回值列表的組合也被稱為函式簽名,它是決定兩個函式型別是否相同的決定因素。因此,函式型別也可以看成是由 func 關鍵字與函式簽名組合而成的。

通常,在表述函式型別時,我們會省略函式簽名引數列表中的引數名,以及返回值列表中的返回值變數名。比如上面 Fprintf 函式的函式型別是:

func(io.Writer, string, ...interface{}) (int, error)

這樣,如果兩個函式型別的函式簽名是相同的,即便引數列表中的引數名,以及返回值列表中的返回值變數名都是不同的,那麼這兩個函式型別也是相同型別,比如下面兩個函式型別:

func (a int, b string) (results []string, err error)
func (c int, d string) (sl []string, err error)

如果我們把這兩個函式型別的引數名與返回值變數名省略,那它們都是 func (int, string) ([]string, error),因此它們是相同的函式型別。

到這裡,我們可以得到這樣一個結論:每個函式宣告所定義的函式,僅僅是對應的函式型別的一個例項,就像 var a int = 13這個變數宣告語句中 a 是 int 型別的一個例項一樣。

接著,我們使用複合型別字面值對結構體型別變數進行顯式初始化和用變數宣告來宣告函式變數的形式,把這兩種形式都以最簡化的樣子表現出來,看下面程式碼:

s := T{}      // 使用複合型別字面值對結構體型別T的變數進行顯式初始化
f := func(){} // 使用變數宣告形式的函式宣告

這裡,T{}被稱為複合型別字面值,那麼處於同樣位置的 func(){}是什麼呢?Go 語言也為它準備了一個名字,叫“函式字面值(Function Literal)”。我們可以看到,函式字面值由函式型別與函式體組成,它特別像一個沒有函式名的函式宣告,因此我們也叫它匿名函式

到這裡,你可能會想:既然是等價的,那我以後就用這種變數宣告的形式來宣告一個函式吧。萬萬不可!這裡只是為了幫你理解函式宣告做了一個等價變換。在 Go 中的絕大多數情況,我們還是會透過傳統的函式宣告來宣告一個特定函式型別的例項,也就是我們俗稱的“定義一個函式”。

三、函式的呼叫

定義了函式之後,我們可以透過函式名()的方式呼叫函式。

這裡簡單舉個例子,程式碼如下:

func greet(name string) {
    fmt.Println("Hello, " + name)
}

func main() {
    greet("Alice")
    greet("Bob")
}

在這個示例中,greet 函式接受一個字串引數,但沒有返回值。在 main 函式中,我們呼叫 greet 函式兩次,分別傳遞不同的名字作為引數。函式 greet 的目的是列印一條問候訊息,而不返回任何值。

當呼叫有返回值的函式時,可以不接收其返回值。比如下面的程式碼:

func add(x, y int) int {
    return x + y
}

func main() {
    add(3, 5) // 不接收返回值
    result := add(10, 7) // 接收返回值,將結果儲存在 result 變數中
}

在上述示例中,add 函式返回一個整數值,但在第一個呼叫中,我們沒有分配或使用該返回值。在第二個呼叫中,我們將返回值儲存在 result 變數中。

四、引數

4.1 引數介紹

函式引數列表中的引數,是函式宣告的、用於函式體實現的區域性變數。由於函式分為宣告與使用兩個階段,在不同階段,引數的稱謂也有不同。

在函式宣告階段,我們把引數列表中的引數叫做形式引數(Parameter,簡稱形參),在函式體中,我們使用的都是形參。

  • 形式引數(Parameters):形式引數是函式宣告中的引數,它們充當函式體中的區域性變數,用於接收函式呼叫時傳遞的實際引數的值。形式引數位於函式宣告的引數列表中,它們指定了函式在被呼叫時可以接受的輸入。

而在函式實際呼叫時傳入的引數被稱為實際引數(Argument,簡稱實參)。

  • 實際引數(Arguments):實際引數是函式在被呼叫時傳遞給形式引數的值。它們位於函式呼叫的括號內,用於提供函式需要的輸入資料。

我們還是繼續用Go 標準庫fmt標準庫的Fprintf為例,下面這張示意圖快速幫助你理解形參和實參。

img

當我們實際呼叫函式的時候,實參會傳遞給函式,並和形式引數逐一繫結,編譯器會根據各個形參的型別與數量,來檢查傳入的實參的型別與數量是否匹配。只有匹配,程式才能繼續執行函式呼叫,否則編譯器就會報錯。

Go 語言中,函式引數傳遞採用是值傳遞的方式。所謂“值傳遞”,就是將實際引數在記憶體中的表示逐位複製(Bitwise Copy)到形式引數中。對於像整型、陣列、結構體這類型別,它們的記憶體表示就是它們自身的資料內容,因此當這些型別作為實參型別時,值傳遞複製的就是它們自身,傳遞的開銷也與它們自身的大小成正比。
但是像 string、切片、map 這些型別就不是了,它們的記憶體表示對應的是它們資料內容的“描述符”。當這些型別作為實參型別時,值傳遞複製的也是它們資料內容的“描述符”,不包括資料內容本身,所以這些型別傳遞的開銷是固定的,與資料內容大小無關。這種只複製“描述符”,不複製實際資料內容的複製過程,也被稱為“淺複製”

不過函式引數的傳遞也有兩個例外,當函式的形參為介面型別,或者形參是變長引數時,簡單的值傳遞就不能滿足要求了,這時 Go 編譯器會介入:對於型別為介面型別的形參,Go 編譯器會把傳遞的實參賦值給對應的介面型別形參;對於為變長引數的形參,Go 編譯器會將零個或多個實參按一定形式轉換為對應的變長形參。

那麼這裡,零個或多個傳遞給變長形式引數的實參,被 Go 編譯器轉換為何種形式了呢?我們透過下面示例程式碼來看一下:

func myAppend(sl []int, elems ...int) []int {
    fmt.Printf("%T\n", elems) // []int
    if len(elems) == 0 {
        println("no elems to append")
        return sl
    }

    sl = append(sl, elems...)
    return sl
}

func main() {
    sl := []int{1, 2, 3}
    sl = myAppend(sl) // no elems to append
    fmt.Println(sl) // [1 2 3]
    sl = myAppend(sl, 4, 5, 6)
    fmt.Println(sl) // [1 2 3 4 5 6]
}

我們重點看一下程式碼中的 myAppend 函式,這個函式基於 append,實現了向一個整型切片追加資料的功能。它支援變長引數,它的第二個形參 elems 就是一個變長引數。myAppend 函式透過 Printf 輸出了變長引數的型別。執行這段程式碼,我們將看到變長引數 elems 的型別為[]int
這也就說明,在 Go 中,變長引數實際上是透過切片來實現的。所以,我們在函式體中,就可以使用切片支援的所有操作來操作變長引數,這會大大簡化了變長引數的使用複雜度。比如 myAppend 中,我們使用 len 函式就可以獲取到傳給變長引數的實參個數。

4.2 型別簡寫

函式的引數中如果相鄰變數的型別相同,則可以省略型別,例如:

func intSum(x, y int) int {
	return x + y
}

上面的程式碼中,intSum函式有兩個引數,這兩個引數的型別均為int,因此可以省略x的型別,因為y後面有型別說明,x引數也是該型別。

4.3 可變引數

可變引數是指函式的引數數量不固定。Go語言中的可變引數透過在引數名後加...來標識。

注意:可變引數通常要作為函式的最後一個引數。

舉個例子:

// 函式接收可變引數
// 可變引數在函式體中是切片型別
func intSum2(x ...int) int {
	fmt.Println(x) //x是一個切片
	sum := 0
	for _, v := range x {
		sum = sum + v
	}
	return sum
}

呼叫上面的函式:

ret1 := intSum2()
ret2 := intSum2(10)
ret3 := intSum2(10, 20)
ret4 := intSum2(10, 20, 30)
fmt.Println(ret1, ret2, ret3, ret4) //0 10 30 60

固定引數搭配可變引數使用時,可變引數要放在固定引數的後面,示例程式碼如下:

func intSum3(x int, y ...int) int {
	fmt.Println(x, y)
	sum := x
	for _, v := range y {
		sum = sum + v
	}
	return sum
}

呼叫上述函式:

ret5 := intSum3(100)
ret6 := intSum3(100, 10)
ret7 := intSum3(100, 10, 20)
ret8 := intSum3(100, 10, 20, 30)
fmt.Println(ret5, ret6, ret7, ret8) //100 110 130 160

本質上,函式的可變引數是透過切片來實現的。

五、返回值

Go語言中透過return關鍵字向外輸出返回值

5.1 多返回值

Go語言中函式支援多返回值,多返回值可以讓函式將更多結果資訊返回給它的呼叫者,Go 語言的錯誤處理機制很大程度就是建立在多返回值的機制之上的,函式如果有多個返回值時必須用()將所有返回值包裹起來。

函式返回值列表從形式上看主要有三種:

func foo()                       // 無返回值
func foo() error                 // 僅有一個返回值
func foo() (int, string, error)  // 有2或2個以上返回值

如果一個函式沒有顯式返回值,那麼我們可以像第一種情況那樣,在函式宣告中省略返回值列表。而且,如果一個函式僅有一個返回值,那麼通常我們在函式宣告中,就不需要將返回值用括號括起來,如果是 2 個或 2 個以上的返回值,那我們還是需要用括號括起來的。

5.2 具名返回值

具名返回值是一種將返回值命名的方式,通常不太常見。它使得在函式體中可以直接使用這些返回值的名稱,同時也可以在 return 語句中省略返回值。但在大多數情況下,Go函式宣告只宣告返回值的型別,而不使用具名返回值。

在函式宣告的返回值列表中,我們通常會像上面例子那樣,僅列舉返回值的型別,但我們也可以像 fmt.Fprintf 函式的返回值列表那樣,為每個返回值宣告變數名,這種帶有名字的返回值被稱為具名返回值(Named Return Value)。這種具名返回值變數可以像函式體中宣告的區域性變數一樣在函式體內使用。

在日常編碼中,我們究竟該使用普通返回值形式,還是具名返回值形式呢?

Go 標準庫以及大多數專案程式碼中的函式,都選擇了使用普通的非具名返回值形式。但在一些特定場景下,具名返回值也會得到應用。比如,當函式使用 defer,而且還在 defer 函式中修改外部函式返回值時,具名返回值可以讓程式碼顯得更優雅清晰。

再比如,當函式的返回值個數較多時,每次顯式使用 return 語句時都會接一長串返回值,這時,我們用具名返回值可以讓函式實現的可讀性更好一些,比如下面 Go 標準庫 time 包中的 parseNanoseconds 函式就是這樣:

// $GOROOT/src/time/format.go
func parseNanoseconds(value string, nbytes int) (ns int, rangeErrString string, err error) {
    if !commaOrPeriod(value[0]) {
        err = errBad
        return
    }
    if ns, err = atoi(value[1:nbytes]); err != nil {
        return
    }
    if ns < 0 || 1e9 <= ns {
        rangeErrString = "fractional second"
        return
    }

    scaleDigits := 10 - nbytes
    for i := 0; i < scaleDigits; i++ {
        ns *= 10
    }
    return
}

5.3 空白符

_ 在 Go 中被用作空白符,可以用作表示任何型別的任何值。

在 Go 中,下劃線 _ 是一個特殊的識別符號,通常用作空白符或佔位符。它的主要用途包括:

  1. 空白符:當你在宣告變數時,如果不想使用這個變數,可以使用下劃線 _ 作為佔位符,表示你不打算使用它。這可以幫助避免編譯器出現未使用的變數警告。

    x, _ := someFunction() // 使用下劃線表示不關心第二個返回值
    
  2. 匿名變數:在某些情況下,你可能只對一個函式的一部分返回值感興趣,而不關心其他返回值。使用下劃線 _ 可以幫助你忽略其他返回值。

    _, result := someFunction() // 忽略第一個返回值
    
  3. 匯入包時的空白符:在匯入包時,下劃線 _ 可用於匿名匯入,表示雖然匯入了包,但不會顯式使用它,只是為了觸發包內的初始化程式碼。

    import _ "package_name"
    

總之,下劃線 _ 在Go中是一個非常有用的識別符號,用於表示不感興趣的值或匯入包時的匿名匯入。這有助於編寫更清晰和靈活的程式碼。

六、函式是“一等公民”

在文章開頭介紹,函式在 Go 語言中屬於“一等公民(First-Class Citizen)”。要知道,並不是在所有程式語言中函式都是“一等公民”。

那麼,什麼是程式語言的“一等公民”呢?關於這個名詞,業界和教科書都沒有給出精準的定義。我們這裡可以引用一下 wiki 發明人、C2 站點作者沃德·坎寧安 (Ward Cunningham)對“一等公民”的解釋

如果一門程式語言對某種語言元素的建立和使用沒有限制,我們可以像對待值(value)一樣對待這種語法元素,那麼我們就稱這種語法元素是這門程式語言的“一等公民”。擁有“一等公民”待遇的語法元素可以儲存在變數中,可以作為引數傳遞給函式,可以在函式內部建立並可以作為返回值從函式返回。

基於這個解釋,我們來看看 Go 語言的函式作為“一等公民”,表現出的各種行為特徵。

6.1 特徵一:Go 函式可以儲存在變數中

按照沃德·坎寧安對一等公民的解釋,身為一等公民的語法元素是可以儲存在變數中的。其實,這點我們在前面理解函式宣告時已經驗證過了,這裡我們再用例子簡單說明一下:

var (
    myFprintf = func(w io.Writer, format string, a ...interface{}) (int, error) {
        return fmt.Fprintf(w, format, a...)
    }
)

func main() {
    fmt.Printf("%T\n", myFprintf) // func(io.Writer, string, ...interface {}) (int, error)
    myFprintf(os.Stdout, "%s\n", "Hello, Go") // 輸出Hello,Go
}

在這個例子中,我們把新建立的一個匿名函式賦值給了一個名為 myFprintf 的變數,透過這個變數,我們便可以呼叫剛剛定義的匿名函式。然後我們再透過 Printf 輸出 myFprintf 變數的型別,也會發現結果與我們預期的函式型別是相符的。

特徵二:支援在函式內建立並透過返回值返回

Go 函式不僅可以在函式外建立,還可以在函式內建立。而且由於函式可以儲存在變數中,所以函式也可以在建立後,作為函式返回值返回。我們來看下面這個例子:

func setup(task string) func() {
    println("do some setup stuff for", task)
    return func() {
        println("do some teardown stuff for", task)
    }
}

func main() {
    teardown := setup("demo")
    defer teardown()
    println("do some bussiness stuff")
}

這個例子,模擬了執行一些重要邏輯之前的上下文建立(setup),以及之後的上下文拆除(teardown)。在一些單元測試的程式碼中,我們也經常會在執行某些用例之前,建立此次執行的上下文(setup),並在這些用例執行後拆除上下文(teardown),避免這次執行對後續用例執行的干擾。

在這個例子中,我們在 setup 函式中建立了這次執行的上下文拆除函式,並透過返回值的形式,將這個拆除函式返回給了 setup 函式的呼叫者。setup 函式的呼叫者,在執行完對應這次執行上下文的重要邏輯後,再呼叫 setup 函式返回的拆除函式,就可以完成對上下文的拆除了。

從這段程式碼中我們也可以看到,setup 函式中建立的拆除函式也是一個匿名函式,但和前面我們看到的匿名函式有一個不同,這個不同就在於這個匿名函式使用了定義它的函式 setup 的區域性變數 task,這樣的匿名函式在 Go 中也被稱為閉包(Closure)。閉包本質上就是一個匿名函式或叫函式字面值,它們可以引用它的包裹函式,也就是建立它們的函式中定義的變數。然後,這些變數在包裹函式和匿名函式之間共享,只要閉包可以被訪問,這些共享的變數就會繼續存在。顯然,Go 語言的閉包特性也是建立在“函式是一等公民”特性的基礎上的。

特徵三:作為引數傳入函式

既然函式可以儲存在變數中,也可以作為返回值返回,那我們可以理所當然地想到,把函式作為引數傳入函式也是可行的。比如我們在日常編碼時經常使用、標準庫 time 包的 AfterFunc 函式,就是一個接受函式型別引數的典型例子。你可以看看下面這行程式碼,這裡透過 AfterFunc 函式設定了一個 2 秒的定時器,並傳入了時間到了後要執行的函式。這裡傳入的就是一個匿名函式:

time.AfterFunc(time.Second*2, func() { println("timer fired") })

特徵四:擁有自己的型別

在前面我們曾得到過這樣一個結論:每個函式宣告定義的函式僅僅是對應的函式型別的一個例項,就像 var a int = 13 這個變數宣告語句中的 a,只是 int 型別的一個例項一樣。換句話說,每個函式都和整型值、字串值等一等公民一樣,擁有自己的型別,也就是我們講過的函式型別

我們甚至可以基於函式型別來自定義型別,就像基於整型、字串型別等型別來自定義型別一樣。下面程式碼中的 HandlerFuncvisitFunc 就是 Go 標準庫中,基於函式型別進行自定義的型別:

// $GOROOT/src/net/http/server.go
type HandlerFunc func(ResponseWriter, *Request)

// $GOROOT/src/sort/genzfunc.go
type visitFunc func(ast.Node) ast.Visitor

到這裡,我們已經可以看到,Go 函式確實表現出了沃德·坎寧安詮釋中“一等公民”的所有特徵:Go 函式可以儲存在變數中,可以在函式內建立並透過返回值返回,可以作為引數傳遞給其他函式,可以擁有自己的型別。透過這些分析,你也能感受到,和 C/C++ 等語言中的函式相比,作為“一等公民”的 Go 函式擁有難得的靈活性。

七、函式“一等公民”特性的高效運用

7.1 應用一:函式型別的妙用

Go 函式是“一等公民”,也就是說,它擁有自己的型別。而且,整型、字串型等所有型別都可以進行的操作,比如顯式轉型,也同樣可以用在函式型別上面,也就是說,函式也可以被顯式轉型。並且,這樣的轉型在特定的領域具有奇妙的作用,一個最為典型的示例就是標準庫 http 包中的 HandlerFunc 這個型別。我們來看一個使用了這個型別的例子:

func greeting(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Welcome, Gopher!\n")
}                    

func main() {
    http.ListenAndServe(":8080", http.HandlerFunc(greeting))
}

這我們日常最常見的、用 Go 構建 Web Server 的例子。它的工作機制也很簡單,就是當使用者透過瀏覽器,或者類似 curl 這樣的命令列工具,訪問 Web server 的 8080 埠時,會收到“Welcome, Gopher!”這樣的文字應答。

我們先來看一下 http 包的函式 ListenAndServe 的原始碼:

// $GOROOT/src/net/http/server.go
func ListenAndServe(addr string, handler Handler) error {
    server := &Server{Addr: addr, Handler: handler}
    return server.ListenAndServe()
}

函式 ListenAndServe 會把來自客戶端的 http 請求,交給它的第二個引數 handler 處理,而這裡 handler 引數的型別 http.Handler,是一個自定義的介面型別,它的原始碼是這樣的:

// $GOROOT/src/net/http/server.go
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

我們還沒有系統學習介面型別,你現在只要知道介面是一組方法的集合就好了。這個介面只有一個方法 ServeHTTP,他的函式型別是 func(http.ResponseWriter, *http.Request)。這和我們自己定義的 http 請求處理函式 greeting 的型別是一致的,但是我們沒法直接將 greeting 作為引數值傳入,否則編譯器會報錯:

func(http.ResponseWriter, *http.Request) does not implement http.Handler (missing ServeHTTP method)

這裡,編譯器提示我們,函式 greeting 還沒有實現介面 Handler 的方法,無法將它賦值給 Handler 型別的引數。現在我們再回過頭來看下程式碼,程式碼中我們也沒有直接將 greeting 傳給 ListenAndServe 函式,而是將 http.HandlerFunc(greeting) 作為引數傳給了 ListenAndServe。那這個 http.HandlerFunc 究竟是什麼呢?我們直接來看一下它的原始碼:

// $GOROOT/src/net/http/server.go

type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
        f(w, r)
}

透過它的原始碼我們看到,HandlerFunc 是一個基於函式型別定義的新型別,它的底層型別為函式型別 func(ResponseWriter, *Request) 。這個型別有一個方法 ServeHTTP,然後實現了 Handler 介面。也就是說 http.HandlerFunc(greeting) 這句程式碼的真正含義,是將函式 greeting 顯式轉換為 HandlerFunc 型別,後者實現了 Handler 介面,滿足ListenAndServe函式第二個引數的要求。

另外,之所以http.HandlerFunc(greeting)這段程式碼可以透過編譯器檢查,正是因為 HandlerFunc 的底層型別是 func(ResponseWriter, *Request) ,與 greeting 函式的型別是一致的,這和下面整型變數的顯式轉型原理也是一樣的:

type MyInt int
var x int = 5
y := MyInt(x) // MyInt的底層型別為int,類比HandlerFunc的底層型別為func(ResponseWriter, *Request)

7.2 應用二:利用閉包簡化函式呼叫

我們前面講過,Go 閉包是在函式內部建立的匿名函式,這個匿名函式可以訪問建立它的函式的引數與區域性變數。我們可以利用閉包的這一特性來簡化函式呼叫,這裡我們看一個具體例子:

func times(x, y int) int {
  return x * y
}

在上面的程式碼中,times 函式用來進行兩個整型數的乘法。我們使用 times 函式的時候需要傳入兩個實參,比如:

times(2, 5) // 計算2 x 5
times(3, 5) // 計算3 x 5
times(4, 5) // 計算4 x 5

不過,有些場景存在一些高頻使用的乘數,這個時候我們就沒必要每次都傳入這樣的高頻乘數了。那我們怎樣能省去高頻乘數的傳入呢? 我們看看下面這個新函式 partialTimes

func partialTimes(x int) func(int) int {
  return func(y int) int {
    return times(x, y)
  }
}

這裡,partialTimes 的返回值是一個接受單一引數的函式,這個由 partialTimes 函式生成的匿名函式,使用了 partialTimes 函式的引數 x。按照前面的定義,這個匿名函式就是一個閉包。partialTimes 實質上就是用來生成以 x 為固定乘數的、接受另外一個乘數作為引數的、閉包函式的函式。當程式呼叫 partialTimes(2) 時,partialTimes 實際上返回了一個呼叫 times(2,y) 的函式,這個過程的邏輯類似於下面程式碼:

timesTwo = func(y int) int {
    return times(2, y)
}

這個時候,我們再看看如何使用 partialTimes,分別生成以 2、3、4 為固定高頻乘數的乘法函式,以及這些生成的乘法函式的使用方法:

func main() {
  timesTwo := partialTimes(2)   // 以高頻乘數2為固定乘數的乘法函式
  timesThree := partialTimes(3) // 以高頻乘數3為固定乘數的乘法函式
  timesFour := partialTimes(4)  // 以高頻乘數4為固定乘數的乘法函式
  fmt.Println(timesTwo(5))   // 10,等價於times(2, 5)
  fmt.Println(timesTwo(6))   // 12,等價於times(2, 6)
  fmt.Println(timesThree(5)) // 15,等價於times(3, 5)
  fmt.Println(timesThree(6)) // 18,等價於times(3, 6)
  fmt.Println(timesFour(5))  // 20,等價於times(4, 5)
  fmt.Println(timesFour(6))  // 24,等價於times(4, 6)
}

你可以看到,透過 partialTimes,我們生成了三個帶有固定乘數的函式。這樣,我們在計算乘法時,就可以減少引數的重複輸入。你看到這裡可能會說,這種簡化的程度十分有限啊!

不是的。這裡我只是舉了一個比較好理解的簡單例子,在那些動輒就有 5 個以上引數的複雜函式中,減少引數的重複輸入給開發人員帶去的收益,可要比這個簡單的例子大得多。

相關文章