Go語言核心36講(Go語言進階技術六)--學習筆記

12 | 使用函式的正確姿勢

在前幾期文章中,我們分了幾次,把 Go 語言自身提供的,所有集合類的資料型別都講了一遍,額外還講了標準庫的container包中的幾個型別。

在幾乎所有主流的程式語言中,集合類的資料型別都是最常用和最重要的。我希望透過這幾次的討論,能讓你對它們的運用更上一層樓。

從今天開始,我會開始向你介紹使用 Go 語言進行模組化程式設計時,必須瞭解的知識,這包括幾個重要的資料型別以及一些模組化程式設計的技巧。首先我們需要了解的是 Go 語言的函式以及函式型別。

前導內容:函式是一等的公民

在 Go 語言中,函式可是一等的(first-class)公民,函式型別也是一等的資料型別。這是什麼意思呢?

簡單來說,這意味著函式不但可以用於封裝程式碼、分割功能、解耦邏輯,還可以化身為普通的值,在其他函式間傳遞、賦予變數、做型別判斷和轉換等等,就像切片和字典的值那樣。

而更深層次的含義就是:函式值可以由此成為能夠被隨意傳播的獨立邏輯元件(或者說功能模組)。

對於函式型別來說,它是一種對一組輸入、輸出進行模板化的重要工具,它比介面型別更加輕巧、靈活,它的值也藉此變成了可被熱替換的邏輯元件。比如,我在 demo26。go 檔案中是這樣寫的:

package mainimport “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, 78add := genCalculator(op)result, err = add(x, y)fmt。Printf(“The result: %d (error: %v)\n”, result, err)

你可以自己寫出calculateFunc型別和genCalculator函式的實現嗎?你可以動手試一試

package mainimport ( “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就是那個引數值了。如此一來,這個閉包函式的狀態就由“不確定”變為了“確定”,或者說轉到了“閉合”狀態,至此也就真正地形成了一個閉包。

看出來了嗎?我們在用高階函式實現閉包。這也是高階函式的一大功用。

Go語言核心36講(Go語言進階技術六)--學習筆記

(高階函式與閉包)

那麼,實現閉包的意義又在哪裡呢?表面上看,我們只是延遲實現了一部分程式邏輯或功能而已,但實際上,我們是在動態地生成那部分程式邏輯。

我們可以藉此在程式執行的過程中,根據需要生成功能不同的函式,繼而影響後續的程式行為。這與 GoF 設計模式中的“模板方法”模式有著異曲同工之妙,不是嗎?

問題 2:傳入函式的那些引數值後來怎麼樣了?

讓我們把目光再次聚焦到函式本身。我們先看一個示例。

package mainimport “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 mainimport “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被傳入函式的話,這個函式中對該引數值的修改會影響到它的原值嗎?

函式真正拿到的引數值其實只是它們的副本,那麼函式返回給呼叫方的結果值也會被複制嗎?