在程式設(shè)計(jì)的領(lǐng)域裡,「把工作包裝成函式再供人呼叫」算是所有程式設(shè)計(jì)師都必須學(xué)會(huì)的事情,而「如何寫得漂亮」更是所有程式設(shè)計(jì)師一生都必須追求的目標(biāo)。今天就統(tǒng)整一些我從 2019 年開始寫 Golang 累積至今關(guān)於函式的知識(shí)吧。
? 函式的定義
我們使用 func 關(guān)鍵字來(lái)定義函式,然後呼叫它:
func main() { result := add(555, 777) fmt.Println("Sum:", result) // -> Sum: 1332 }
func add(x int, y int) int { return x + y } |
這裡的 (x int, y int) 表示的是輸入兩個(gè)自變數(shù)作為 x 和 y 兩個(gè)變數(shù),緊接在後的 int 則代表這個(gè)函式回傳的值會(huì)是一個(gè) int。
由於範(fàn)例中的自變數(shù) x 和 y 同樣都是 int 類型,我們可以省略前面連續(xù)的相同型態(tài),只要標(biāo)記最後一個(gè)就行了:
func add(x, y int) int { return x + y } |
也就是說(shuō),如果有一個(gè)函數(shù)長(zhǎng)這樣:
func myFunction(a, b, c int, x, y string, p int) { // ... } |
它就相當(dāng)於:
func myFunction(a int, b int, c int, x string, y string, p int) { // ... } |
在這方面,Golang 並沒有嚴(yán)格規(guī)範(fàn)是否要省略,端看你自己的選擇。
既然都說(shuō)到函式了,就順帶講講檔案的管理吧。如果你的 code 規(guī)模非常小,通常我們會(huì)習(xí)慣把這些函式通通丟在同一個(gè)檔案裡,這麼做當(dāng)然無(wú)傷大雅,可是一旦內(nèi)容多了起來(lái),你的專案就會(huì)變得難以維護(hù),光是找個(gè)函式就要翻半天了。
因此,假如你發(fā)現(xiàn)你的 code 越寫越多,那你就可以開始適當(dāng)?shù)卣磉@些函式,把同類型的函式放到同一個(gè)檔案裡。只要它們屬於同一個(gè) package,就可以自由呼叫:
? 函式的多重回傳值
這算是 Golang 的一種特色吧?雖然像 Python 這樣的程式語(yǔ)言也不是沒有,但你應(yīng)該可以在 Golang 裡面看到大量的多重回傳應(yīng)用,例如錯(cuò)誤處理。錯(cuò)誤處理就擱著等等說(shuō),我們可以先來(lái)看一下多重回傳的例子和應(yīng)用。
在 Golang,我們是可以一次 return 好幾個(gè)變數(shù)回來(lái)的。比如說(shuō)我們希望可以寫個(gè)函數(shù) eqTriangle() 來(lái)計(jì)算正三角形的周長(zhǎng)和面積,那麼只要在函式裡面分別計(jì)算出來(lái),然後用括號(hào)把要回傳的型態(tài)定義括起來(lái),就可以分成兩個(gè)變數(shù)傳回來(lái)了:
func main() { perimeter, area := eqTriangle(3) fmt.Printf("周長(zhǎng): %.4f\n面積: %.4f\n", perimeter, area) }
func eqTriangle(sideLength float64) (float64, float64) { perimeter := sideLength * 3 area := (math.Sqrt(3) / 4) * math.Pow(sideLength, 2) return perimeter, area } |
如果專案稍微大一點(diǎn)(或者是別人也需要用到你寫的函數(shù)的情況下),你也可以幫回傳的值命名,計(jì)算好之後直接賦值並且 return 即可:
func eqTriangle(sideLength float64) (perimeter, area float64) { perimeter = sideLength * 3 area = (math.Sqrt(3) / 4) * math.Pow(sideLength, 2) return } |
這樣在開發(fā)程式的時(shí)候就可以直接靠 IDE 上面自動(dòng)完成的提示視窗裡面看到回傳的變數(shù)名字,以便於開發(fā)者一看就知道哪個(gè)是周長(zhǎng)、哪個(gè)是面積:
你還可以在函式的頭上寫註釋,這裡的註釋一樣也會(huì)被顯示在自動(dòng)完成的提示視窗裡:
這對(duì)於開發(fā)大型專案很有用,甚至可以說(shuō)是必須的。除了把函式跟變數(shù)名字取好之外,只要專案夠大、有給其他人用或是長(zhǎng)期自用的需求,建議可以把一些複雜的函式寫上註釋,以避免日後沒有人看得懂這個(gè)函式在做什麼。
? 函式的錯(cuò)誤處理
執(zhí)行程式的時(shí)候總會(huì)有些例外狀況。
Golang 並不像其他語(yǔ)言一樣使用 try-catch 的句法來(lái)捕捉執(zhí)行過(guò)程發(fā)生的錯(cuò)誤,而是在發(fā)生問題時(shí)產(chǎn)生一個(gè) error 對(duì)象,然後直接 return 回來(lái)。我們只要檢查本來(lái)應(yīng)該回傳 error 的地方是否為 nil,就可以知道這個(gè)函式有沒有正常運(yùn)作。
舉個(gè)例子,strconv 這個(gè) package 裡面的 Atoi 函數(shù),專門用來(lái)把字串裡的數(shù)字轉(zhuǎn)換成 int。如果這個(gè)字串不符合格式,那就會(huì)產(chǎn)生一個(gè) error 並且傳回來(lái):
package main
import ( "fmt" "log" "strconv" )
func main() { numberStr := "123456A" number, err := strconv.Atoi(numberStr) if err != nil { log.Fatal(err) // -> strconv.Atoi: parsing "123456A": invalid syntax } fmt.Printf("Number: %d\n", number) fmt.Printf("Number×2 = %d", number*2) } |
當(dāng)然你也可以選擇在發(fā)生錯(cuò)誤的時(shí)候什麼都不做,強(qiáng)制把回傳的 error 直接拋掉:
func main() { numberStr := "123456A" number, _ := strconv.Atoi(numberStr) fmt.Printf("Number: %d\n", number) fmt.Printf("Number×2 = %d", number*2) } |
Atoi 這個(gè)函式本身的設(shè)計(jì)是發(fā)生錯(cuò)誤的時(shí)候回傳 0 和新產(chǎn)生出來(lái)的 error,所以即使把這個(gè) error 拋掉了,程式也不會(huì)因此崩潰或退出。
其實(shí)發(fā)生錯(cuò)誤時(shí),你有很多種方式可以選擇:
1. 讓程式把錯(cuò)誤訊息印出來(lái),並且強(qiáng)制退出程式(使用 log.Fatal() 或是 panic())
2. 讓程式把錯(cuò)誤訊息印出來(lái),但不強(qiáng)制退出程式
3. 什麼都不管,讓它繼續(xù)運(yùn)作
「錯(cuò)誤處理」這件事情很吃程式設(shè)計(jì)師的基本功,你得事先預(yù)想好程式本身可能會(huì)發(fā)生什麼樣的錯(cuò)誤,然後把每一種情況都考慮進(jìn)去。倘若沒有把發(fā)生錯(cuò)誤的所有狀況預(yù)先設(shè)想好,那麼程式就可能會(huì)把錯(cuò)誤的資料繼續(xù)往下接著拿來(lái)用,導(dǎo)致你的錯(cuò)誤越滾越大,甚至造成不可挽回的悲慘結(jié)果。
所以我們會(huì)需要考慮什麼樣的錯(cuò)誤是嚴(yán)重的、什麼樣的錯(cuò)誤是不嚴(yán)重的,自行決定這個(gè)函式在發(fā)生問題時(shí)應(yīng)該直接退出程式還是單純提示錯(cuò)誤訊息。這種時(shí)候我們自然就需要檢測(cè)錯(cuò)誤的類型了,畢竟遇到不同的錯(cuò)誤時(shí)我們可能會(huì)採(cǎi)取不同做法。
如果要檢測(cè)錯(cuò)誤類型的話,你可以使用 errors(註)這個(gè) package 裡面內(nèi)建的 Is 函數(shù),來(lái)檢查收到的 error 是不是特定的類型:
numberStr := "561681949849849498499" number, err := strconv.Atoi(numberStr) if errors.Is(err, strconv.ErrSyntax) { fmt.Println("數(shù)字格式有誤") } else if errors.Is(err, strconv.ErrRange) { fmt.Println("數(shù)字超出了 int 可表示的範(fàn)圍") } else if err != nil { fmt.Println("發(fā)生了其他不可預(yù)期的錯(cuò)誤") } else { fmt.Printf("Number: %d\n", number) fmt.Printf("Number×2 = %d", number*2) } |
註:Golang 內(nèi)建的 errors, strings, bytes 這些 package 分別是用來(lái)專門處理 error, string, byte 類型內(nèi)容的函式庫(kù)。
? 自訂錯(cuò)誤內(nèi)容
承上,如果我們寫了一個(gè)需要回報(bào)各種錯(cuò)誤情況的函式,我們可以用 errors.New() 來(lái)產(chǎn)生錯(cuò)誤對(duì)象。
但要注意的是,每一次 errors.New() 產(chǎn)生出來(lái)的錯(cuò)誤都是不同的對(duì)象,就算訊息一模一樣,errors.Is() 也會(huì)把兩個(gè)錯(cuò)誤視為不同的錯(cuò)誤。因此,我們需要把錯(cuò)誤事先定義出來(lái)存到一個(gè)變數(shù)裡,等到真的發(fā)生錯(cuò)誤而需要回傳的時(shí)候才取用:
var ErrDivisionByZero = errors.New("Cannot divide by zero.")
func main() { result, err := divide(5, 0) if errors.Is(err, ErrDivisionByZero) { fmt.Println("不能用 0 當(dāng)分母!") log.Fatal(err.Error()) } else if err != nil { fmt.Println("遇到了預(yù)期之外的錯(cuò)誤!") log.Fatal(err.Error()) } fmt.Println("Result:", result) }
func divide(x, y float64) (float64, error) { if y == 0 { return 0, ErrDivisionByZero } return (x / y), nil } |
這對(duì)於往後需要把特定的功能封裝成 package 的時(shí)候特別有用,就好像前面示範(fàn)到的 strconv.ErrSyntax,不只程式可讀性會(huì)提高很多,寫起來(lái)也會(huì)很順手。
錯(cuò)誤處理說(shuō)完了,接下來(lái)就講一些比較進(jìn)階(或是奇怪)的用法吧。
? 把函式存進(jìn)變數(shù)裡
我們可以把函式存在變數(shù)裡面,需要用到的時(shí)候再拿出來(lái)用:
square := func(i float64) float64 { return math.Pow(i, 5) } result := square(3) |
這個(gè)我個(gè)人不太常用就是了,我覺得寫起來(lái)很醜。
? 匿名函式
你可以當(dāng)場(chǎng)定義一個(gè)匿名函式,直接呼叫它:
func main() { fmt.Println("Hi") func(i int) { fmt.Println(i) }(48763) fmt.Println("Bye") } |
注意如果你的函數(shù)沒有傳入的自變數(shù),右大括號(hào)後面一樣要加上 () 表示呼叫的行為,不然它就只會(huì)是一個(gè)宣告好卻無(wú)法被使用的函數(shù)。
這在某些場(chǎng)景有機(jī)會(huì)用到,例如不需要佔(zhàn)用命名空間的簡(jiǎn)單函式,或者是需要非同步、並行處理的函式。至於在 Golang 裡面要怎麼樣才能夠讓函式並行處理(就像多執(zhí)行緒),我們就之後再挑個(gè)好時(shí)機(jī)拿出來(lái)講吧。
? 不確定的自變數(shù)數(shù)量
如果我們希望這個(gè)函式一次傳入很多個(gè)值,但不知道具體有幾個(gè),我們可以在型態(tài)前面使用「…」來(lái)表示不確定的數(shù)量(注意,這種不定數(shù)量的自變數(shù)必須被擺在最後面):
func main() { sum("總和:", 1, 2, 3, 4, 5, 6, 7, 8) }
func sum(message string, numbers ...int) { sum := 0 for _, number := range numbers { // it's like for-each sum += number } fmt.Println(message, sum) } |
得到的 numbers 會(huì)是一個(gè) []int(也就是 int slice),我們只要用 for-range 的方式就可以把裡面的每一個(gè)值都取出來(lái)了。
像是我們最常用到的 fmt.Println() 和 fmt.Printf() 就是像這樣設(shè)計(jì),我們也才能直接把很多個(gè)不確定數(shù)量的變數(shù)填進(jìn)去。
? 把語(yǔ)句排定到函式結(jié)束時(shí)執(zhí)行
這也是 Golang 的特色。有時(shí)候我們可能一個(gè)函式裡面會(huì)出現(xiàn)很多個(gè) return,同時(shí)又有些任務(wù)是 return 前必做的(例如關(guān)閉跟資料庫(kù)之間的連線、關(guān)閉檔案等等),那麼我們就可以用 defer 來(lái)把函式排到函數(shù)結(jié)束時(shí)執(zhí)行,避免一直把類似的東西複製貼上,也減少了被漏掉的風(fēng)險(xiǎn):
如果需要執(zhí)行的事情有好幾句,那剛才提到的匿名函式就派上用場(chǎng)了:
那要是一個(gè)函式裡面有很多個(gè) defer,較晚 defer 的會(huì)先被執(zhí)行:
除此之外,panic 的時(shí)候會(huì)執(zhí)行完所有層級(jí)的 defer,然後才離開程式(log.Fatal 的話就不會(huì)執(zhí)行 defer 了,印完錯(cuò)誤訊息之後直接退出程式)。
關(guān)於函式的各種眉角,大概就是這樣了。這系列的下一篇可能是關(guān)於迭代和陣列,可能是關(guān)於 package 的用法,也可能是我最近搞哈哈姆特 BOT 弄出來(lái)的小成果,就讓我再花點(diǎn)時(shí)間決定吧……
縮圖素材原作者:Renée French(CC BY-SA 3.0)