12 | 使用函數的正確姿勢
在前幾期文章中,我們分了幾次,把 Go 語言自身提供的,所有集合類的數據類型都講了一遍,額外還講了標准庫的container包中的幾個類型。
在幾乎所有主流的編程語言中,集合類的數據類型都是最常用和最重要的。我希望通過這幾次的討論,能讓你對它們的運用更上一層樓。
從今天開始,我會開始向你介紹使用 Go 語言進行模塊化編程時,必須了解的知識,這包括幾個重要的數據類型以及一些模塊化編程的技巧。首先我們需要了解的是 Go 語言的函數以及函數類型。
前導內容:函數是一等的公民
在 Go 語言中,函數可是一等的(first-class)公民,函數類型也是一等的數據類型。這是什么意思呢?
簡單來說,這意味着函數不但可以用於封裝代碼、分割功能、解耦邏輯,還可以化身為普通的值,在其他函數間傳遞、賦予變量、做類型判斷和轉換等等,就像切片和字典的值那樣。
而更深層次的含義就是:函數值可以由此成為能夠被隨意傳播的獨立邏輯組件(或者說功能模塊)。
對於函數類型來說,它是一種對一組輸入、輸出進行模板化的重要工具,它比接口類型更加輕巧、靈活,它的值也借此變成了可被熱替換的邏輯組件。比如,我在 demo26.go 文件中是這樣寫的:
package main
import "fmt"
type Printer func(contents string) (n int, err error)
func printToStd(contents string) (bytesNum int, err error) {
return fmt.Println(contents)
}
func main() {
var p Printer
p = printToStd
p("something")
}
這里,我先聲明了一個函數類型,名叫Printer。
注意這里的寫法,在類型聲明的名稱右邊的是func關鍵字,我們由此就可知道這是一個函數類型的聲明。
在func右邊的就是這個函數類型的參數列表和結果列表。其中,參數列表必須由圓括號包裹,而只要結果列表中只有一個結果聲明,並且沒有為它命名,我們就可以省略掉外圍的圓括號。
書寫函數簽名的方式與函數聲明的是一致的。只是緊挨在參數列表左邊的不是函數名稱,而是關鍵字func。這里函數名稱和func互換了一下位置而已。
函數的簽名其實就是函數的參數列表和結果列表的統稱,它定義了可用來鑒別不同函數的那些特征,同時也定義了我們與函數交互的方式。
注意,各個參數和結果的名稱不能算作函數簽名的一部分,甚至對於結果聲明來說,沒有名稱都可以。
只要兩個函數的參數列表和結果列表中的元素順序及其類型是一致的,我們就可以說它們是一樣的函數,或者說是實現了同一個函數類型的函數。
嚴格來說,函數的名稱也不能算作函數簽名的一部分,它只是我們在調用函數時,需要給定的標識符而已。
我在下面聲明的函數printToStd的簽名與Printer的是一致的,因此前者是后者的一個實現,即使它們的名稱以及有的結果名稱是不同的。
通過main函數中的代碼,我們就可以證實這兩者的關系了,我順利地把printToStd函數賦給了Printer類型的變量p,並且成功地調用了它。
總之,“函數是一等的公民”是函數式編程(functional programming)的重要特征。Go 語言在語言層面支持了函數式編程。我們下面的問題就與此有關。
今天的問題是:怎樣編寫高階函數?
先來說說什么是高階函數?簡單地說,高階函數可以滿足下面的兩個條件:
1. 接受其他的函數作為參數傳入;
2. 把其他的函數作為結果返回。
只要滿足了其中任意一個特點,我們就可以說這個函數是一個高階函數。高階函數也是函數式編程中的重要概念和特征。
具體的問題是,我想通過編寫calculate函數來實現兩個整數間的加減乘除運算,但是希望兩個整數和具體的操作都由該函數的調用方給出,那么,這樣一個函數應該怎樣編寫呢。
典型回答
首先,我們來聲明一個名叫operate的函數類型,它有兩個參數和一個結果,都是int類型的。
type operate func(x, y int) int
然后,我們編寫calculate函數的簽名部分。這個函數除了需要兩個int類型的參數之外,還應該有一個operate類型的參數。
該函數的結果應該有兩個,一個是int類型的,代表真正的操作結果,另一個應該是error類型的,因為如果那個operate類型的參數值為nil,那么就應該直接返回一個錯誤。
順便說一下,函數類型屬於引用類型,它的值可以為nil,而這種類型的零值恰恰就是nil。
func calculate(x int, y int, op operate) (int, error) {
if op == nil {
return 0, errors.New("invalid operation")
}
return op(x, y), nil
}
calculate函數實現起來就很簡單了。我們需要先用衛述語句檢查一下參數,如果operate類型的參數op為nil,那么就直接返回0和一個代表了具體錯誤的error類型值。
衛述語句是指被用來檢查關鍵的先決條件的合法性,並在檢查未通過的情況下立即終止當前代碼塊執行的語句。在 Go 語言中,if 語句常被作為衛述語句。
如果檢查無誤,那么就調用op並把那兩個操作數傳給它,最后返回op返回的結果和代表沒有錯誤發生的nil。
問題解析
其實只要你搞懂了“函數是一等的公民”這句話背后的含義,這道題就會很簡單。我在上面已經講過了,希望你已經清楚了。我在上一個例子中展示了其中一點,即:把函數作為一個普通的值賦給一個變量。
在這道題中,我問的其實是怎樣實現另一點,即:讓函數在其他函數間傳遞。
在答案中,calculate函數的其中一個參數是operate類型的,而且后者就是一個函數類型。在調用calculate函數的時候,我們需要傳入一個operate類型的函數值。這個函數值應該怎么寫?
只要它的簽名與operate類型的簽名一致,並且實現得當就可以了。我們可以像上一個例子那樣先聲明好一個函數,再把它賦給一個變量,也可以直接編寫一個實現了operate類型的匿名函數。
op := func(x, y int) int {
return x + y
}
calculate函數就是一個高階函數。但是我們說高階函數的特點有兩個,而該函數只展示了其中一個特點,即:接受其他的函數作為參數傳入。
那另一個特點,把其他的函數作為結果返回。這又是怎么玩的呢?你可以看看我在 demo27.go 文件中聲明的函數類型calculateFunc和函數genCalculator。其中,genCalculator函數的唯一結果的類型就是calculateFunc。
這里先給出使用它們的代碼。
x, y = 56, 78
add := genCalculator(op)
result, err = add(x, y)
fmt.Printf("The result: %d (error: %v)\n", result, err)
你可以自己寫出calculateFunc類型和genCalculator函數的實現嗎?你可以動手試一試
package main
import (
"errors"
"fmt"
)
type operate func(x, y int) int
// 方案1。
func calculate(x int, y int, op operate) (int, error) {
if op == nil {
return 0, errors.New("invalid operation")
}
return op(x, y), nil
}
// 方案2。
type calculateFunc func(x int, y int) (int, error)
func genCalculator(op operate) calculateFunc {
return func(x int, y int) (int, error) {
if op == nil {
return 0, errors.New("invalid operation")
}
return op(x, y), nil
}
}
func main() {
// 方案1。
x, y := 12, 23
op := func(x, y int) int {
return x + y
}
result, err := calculate(x, y, op)
fmt.Printf("The result: %d (error: %v)\n",
result, err)
result, err = calculate(x, y, nil)
fmt.Printf("The result: %d (error: %v)\n",
result, err)
// 方案2。
x, y = 56, 78
add := genCalculator(op)
result, err = add(x, y)
fmt.Printf("The result: %d (error: %v)\n",
result, err)
}
知識擴展
問題 1:如何實現閉包?
閉包又是什么?你可以想象一下,在一個函數中存在對外來標識符的引用。所謂的外來標識符,既不代表當前函數的任何參數或結果,也不是函數內部聲明的,它是直接從外邊拿過來的。
還有個專門的術語稱呼它,叫自由變量,可見它代表的肯定是個變量。實際上,如果它是個常量,那也就形成不了閉包了,因為常量是不可變的程序實體,而閉包體現的卻是由“不確定”變為“確定”的一個過程。
我們說的這個函數(以下簡稱閉包函數)就是因為引用了自由變量,而呈現出了一種“不確定”的狀態,也叫“開放”狀態。
也就是說,它的內部邏輯並不是完整的,有一部分邏輯需要這個自由變量參與完成,而后者到底代表了什么在閉包函數被定義的時候卻是未知的。
即使對於像 Go 語言這種靜態類型的編程語言而言,我們在定義閉包函數的時候最多也只能知道自由變量的類型。
在我們剛剛提到的genCalculator函數內部,實際上就實現了一個閉包,而genCalculator函數也是一個高階函數。
func genCalculator(op operate) calculateFunc {
return func(x int, y int) (int, error) {
if op == nil {
return 0, errors.New("invalid operation")
}
return op(x, y), nil
}
}
genCalculator函數只做了一件事,那就是定義一個匿名的、calculateFunc類型的函數並把它作為結果值返回。
而這個匿名的函數就是一個閉包函數。它里面使用的變量op既不代表它的任何參數或結果也不是它自己聲明的,而是定義它的genCalculator函數的參數,所以是一個自由變量。
這個自由變量究竟代表了什么,這一點並不是在定義這個閉包函數的時候確定的,而是在genCalculator函數被調用的時候確定的。
只有給定了該函數的參數op,我們才能知道它返回給我們的閉包函數可以用於什么運算。
看到if op == nil {那一行了嗎?Go 語言編譯器讀到這里時會試圖去尋找op所代表的東西,它會發現op代表的是genCalculator函數的參數,然后,它會把這兩者聯系起來。這時可以說,自由變量op被“捕獲”了。
當程序運行到這里的時候,op就是那個參數值了。如此一來,這個閉包函數的狀態就由“不確定”變為了“確定”,或者說轉到了“閉合”狀態,至此也就真正地形成了一個閉包。
看出來了嗎?我們在用高階函數實現閉包。這也是高階函數的一大功用。

(高階函數與閉包)
那么,實現閉包的意義又在哪里呢?表面上看,我們只是延遲實現了一部分程序邏輯或功能而已,但實際上,我們是在動態地生成那部分程序邏輯。
我們可以借此在程序運行的過程中,根據需要生成功能不同的函數,繼而影響后續的程序行為。這與 GoF 設計模式中的“模板方法”模式有着異曲同工之妙,不是嗎?
問題 2:傳入函數的那些參數值后來怎么樣了?
讓我們把目光再次聚焦到函數本身。我們先看一個示例。
package main
import "fmt"
func main() {
array1 := [3]string{"a", "b", "c"}
fmt.Printf("The array: %v\n", array1)
array2 := modifyArray(array1)
fmt.Printf("The modified array: %v\n", array2)
fmt.Printf("The original array: %v\n", array1)
}
func modifyArray(a [3]string) [3]string {
a[1] = "x"
return a
}
這個命令源碼文件(也就是 demo28.go)在運行之后會輸出什么?這是我常出的一道考題。
package main
import "fmt"
func main() {
// 示例1。
array1 := [3]string{"a", "b", "c"}
fmt.Printf("The array: %v\n", array1)
array2 := modifyArray(array1)
fmt.Printf("The modified array: %v\n", array2)
fmt.Printf("The original array: %v\n", array1)
fmt.Println()
// 示例2。
slice1 := []string{"x", "y", "z"}
fmt.Printf("The slice: %v\n", slice1)
slice2 := modifySlice(slice1)
fmt.Printf("The modified slice: %v\n", slice2)
fmt.Printf("The original slice: %v\n", slice1)
fmt.Println()
// 示例3。
complexArray1 := [3][]string{
{"d", "e", "f"},
{"g", "h", "i"},
{"j", "k", "l"},
}
fmt.Printf("The complex array: %v\n", complexArray1)
complexArray2 := modifyComplexArray(complexArray1)
fmt.Printf("The modified complex array: %v\n", complexArray2)
fmt.Printf("The original complex array: %v\n", complexArray1)
}
// 示例1。
func modifyArray(a [3]string) [3]string {
a[1] = "x"
return a
}
// 示例2。
func modifySlice(a []string) []string {
a[1] = "i"
return a
}
// 示例3。
func modifyComplexArray(a [3][]string) [3][]string {
a[1][1] = "s"
a[2] = []string{"o", "p", "q"}
return a
}
我在main函數中聲明了一個數組array1,然后把它傳給了函數modify,modify對參數值稍作修改后將其作為結果值返回。main函數中的代碼拿到這個結果之后打印了它(即array2),以及原來的數組array1。關鍵問題是,原數組會因modify函數對參數值的修改而改變嗎?
答案是:原數組不會改變。為什么呢?原因是,所有傳給函數的參數值都會被復制,函數在其內部使用的並不是參數值的原值,而是它的副本。
由於數組是值類型,所以每一次復制都會拷貝它,以及它的所有元素值。我在modify函數中修改的只是原數組的副本而已,並不會對原數組造成任何影響。
注意,對於引用類型,比如:切片、字典、通道,像上面那樣復制它們的值,只會拷貝它們本身而已,並不會拷貝它們引用的底層數據。也就是說,這時只是淺表復制,而不是深層復制。
以切片值為例,如此復制的時候,只是拷貝了它指向底層數組中某一個元素的指針,以及它的長度值和容量值,而它的底層數組並不會被拷貝。
另外還要注意,就算我們傳入函數的是一個值類型的參數值,但如果這個參數值中的某個元素是引用類型的,那么我們仍然要小心。
比如:
complexArray1 := [3][]string{
[]string{"d", "e", "f"},
[]string{"g", "h", "i"},
[]string{"j", "k", "l"},
}
變量complexArray1是[3][]string類型的,也就是說,雖然它是一個數組,但是其中的每個元素又都是一個切片。這樣一個值被傳入函數的話,函數中對該參數值的修改會影響到complexArray1本身嗎?我想,這可以留作今天的思考題。
總結
我們今天主要聚焦於函數的使用手法。在 Go 語言中,函數可是一等的(first-class)公民。它既可以被獨立聲明,也可以被作為普通的值來傳遞或賦予變量。除此之外,我們還可以在其他函數的內部聲明匿名函數並把它直接賦給變量。
你需要記住 Go 語言是怎樣鑒別一個函數的,函數的簽名在這里起到了至關重要的作用。
函數是 Go 語言支持函數式編程的主要體現。我們可以通過“把函數傳給函數”以及“讓函數返回函數”來編寫高階函數,也可以用高階函數來實現閉包,並以此做到部分程序邏輯的動態生成。
我們在最后還說了一下關於函數傳參的一個注意事項,這很重要,可能會關系到程序的穩定和安全。
一個相關的原則是:既不要把你程序的細節暴露給外界,也盡量不要讓外界的變動影響到你的程序。你可以想想這個原則在這里可以起到怎樣的指導作用。
思考題
今天我給你留下兩道思考題。
- complexArray1被傳入函數的話,這個函數中對該參數值的修改會影響到它的原值嗎?
- 函數真正拿到的參數值其實只是它們的副本,那么函數返回給調用方的結果值也會被復制嗎?
本作品采用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。
歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含鏈接: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改后的作品務必以相同的許可發布。

