前往
大廳
主題 達人專欄

Golang 函式的各種用法

解凍豬腳 | 2021-10-15 19:15:01 | 巴幣 3524 | 人氣 1781

 
在程式設計的領域裡,「把工作包裝成函式再供人呼叫」算是所有程式設計師都必須學會的事情,而「如何寫得漂亮」更是所有程式設計師一生都必須追求的目標。今天就統整一些我從 2019 年開始寫 Golang 累積至今關於函式的知識吧。

本篇文章有不少程式碼,如果這裡的排版讓你感到閱讀困難,請服用 HackMD 好讀版


► 函式的定義

我們使用 func 關鍵字來定義函式,然後呼叫它:

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) 表示的是輸入兩個自變數作為 x 和 y 兩個變數,緊接在後的 int 則代表這個函式回傳的值會是一個 int。

由於範例中的自變數 x 和 y 同樣都是 int 類型,我們可以省略前面連續的相同型態,只要標記最後一個就行了:

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

也就是說,如果有一個函數長這樣:

func myFunction(a, b, c int, x, y string, p int) {
    // ...
}

它就相當於:

func myFunction(a int, b int, c int, x string, y string, p int) {
    // ...
}

在這方面,Golang 並沒有嚴格規範是否要省略,端看你自己的選擇。

既然都說到函式了,就順帶講講檔案的管理吧。如果你的 code 規模非常小,通常我們會習慣把這些函式通通丟在同一個檔案裡,這麼做當然無傷大雅,可是一旦內容多了起來,你的專案就會變得難以維護,光是找個函式就要翻半天了。

因此,假如你發現你的 code 越寫越多,那你就可以開始適當地整理這些函式,把同類型的函式放到同一個檔案裡。只要它們屬於同一個 package,就可以自由呼叫:



► 函式的多重回傳值

這算是 Golang 的一種特色吧?雖然像 Python 這樣的程式語言也不是沒有,但你應該可以在 Golang 裡面看到大量的多重回傳應用,例如錯誤處理。錯誤處理就擱著等等說,我們可以先來看一下多重回傳的例子和應用。

在 Golang,我們是可以一次 return 好幾個變數回來的。比如說我們希望可以寫個函數 eqTriangle() 來計算正三角形的周長和面積,那麼只要在函式裡面分別計算出來,然後用括號把要回傳的型態定義括起來,就可以分成兩個變數傳回來了:

func main() {
    perimeter, area := eqTriangle(3)
    fmt.Printf("周長: %.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
}

如果專案稍微大一點(或者是別人也需要用到你寫的函數的情況下),你也可以幫回傳的值命名,計算好之後直接賦值並且 return 即可:

func eqTriangle(sideLength float64) (perimeter, area float64) {
    perimeter = sideLength * 3
    area = (math.Sqrt(3) / 4) * math.Pow(sideLength, 2)
    return
}

這樣在開發程式的時候就可以直接靠 IDE 上面自動完成的提示視窗裡面看到回傳的變數名字,以便於開發者一看就知道哪個是周長、哪個是面積:


你還可以在函式的頭上寫註釋,這裡的註釋一樣也會被顯示在自動完成的提示視窗裡:


這對於開發大型專案很有用,甚至可以說是必須的。除了把函式跟變數名字取好之外,只要專案夠大、有給其他人用或是長期自用的需求,建議可以把一些複雜的函式寫上註釋,以避免日後沒有人看得懂這個函式在做什麼。


► 函式的錯誤處理

執行程式的時候總會有些例外狀況。

Golang 並不像其他語言一樣使用 try-catch 的句法來捕捉執行過程發生的錯誤,而是在發生問題時產生一個 error 對象,然後直接 return 回來。我們只要檢查本來應該回傳 error 的地方是否為 nil,就可以知道這個函式有沒有正常運作。

舉個例子,strconv 這個 package 裡面的 Atoi 函數,專門用來把字串裡的數字轉換成 int。如果這個字串不符合格式,那就會產生一個 error 並且傳回來:

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)
}

當然你也可以選擇在發生錯誤的時候什麼都不做,強制把回傳的 error 直接拋掉:

func main() {
    numberStr := "123456A"
    number, _ := strconv.Atoi(numberStr)
    fmt.Printf("Number: %d\n", number)
    fmt.Printf("Number×2 = %d", number*2)
}

Atoi 這個函式本身的設計是發生錯誤的時候回傳 0 和新產生出來的 error,所以即使把這個 error 拋掉了,程式也不會因此崩潰或退出。

其實發生錯誤時,你有很多種方式可以選擇:
1. 讓程式把錯誤訊息印出來,並且強制退出程式(使用 log.Fatal() 或是 panic())
2. 讓程式把錯誤訊息印出來,但不強制退出程式
3. 什麼都不管,讓它繼續運作

「錯誤處理」這件事情很吃程式設計師的基本功,你得事先預想好程式本身可能會發生什麼樣的錯誤,然後把每一種情況都考慮進去。倘若沒有把發生錯誤的所有狀況預先設想好,那麼程式就可能會把錯誤的資料繼續往下接著拿來用,導致你的錯誤越滾越大,甚至造成不可挽回的悲慘結果。

所以我們會需要考慮什麼樣的錯誤是嚴重的、什麼樣的錯誤是不嚴重的,自行決定這個函式在發生問題時應該直接退出程式還是單純提示錯誤訊息。這種時候我們自然就需要檢測錯誤的類型了,畢竟遇到不同的錯誤時我們可能會採取不同做法。

如果要檢測錯誤類型的話,你可以使用 errors(註)這個 package 裡面內建的 Is 函數,來檢查收到的 error 是不是特定的類型:

numberStr := "561681949849849498499"
number, err := strconv.Atoi(numberStr)
if errors.Is(err, strconv.ErrSyntax) {
    fmt.Println("數字格式有誤")
} else if errors.Is(err, strconv.ErrRange) {
    fmt.Println("數字超出了 int 可表示的範圍")
} else if err != nil {
    fmt.Println("發生了其他不可預期的錯誤")
} else {
    fmt.Printf("Number: %d\n", number)
    fmt.Printf("Number×2 = %d", number*2)
}

註:Golang 內建的 errors, strings, bytes 這些 package 分別是用來專門處理 error, string, byte 類型內容的函式庫。


► 自訂錯誤內容

承上,如果我們寫了一個需要回報各種錯誤情況的函式,我們可以用 errors.New() 來產生錯誤對象。

但要注意的是,每一次 errors.New() 產生出來的錯誤都是不同的對象,就算訊息一模一樣,errors.Is() 也會把兩個錯誤視為不同的錯誤。因此,我們需要把錯誤事先定義出來存到一個變數裡,等到真的發生錯誤而需要回傳的時候才取用:

var ErrDivisionByZero = errors.New("Cannot divide by zero.")

func main() {
    result, err := divide(5, 0)
    if errors.Is(err, ErrDivisionByZero) {
        fmt.Println("不能用 0 當分母!")
        log.Fatal(err.Error())
    } else if err != nil {
        fmt.Println("遇到了預期之外的錯誤!")
        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
}

這對於往後需要把特定的功能封裝成 package 的時候特別有用,就好像前面示範到的 strconv.ErrSyntax,不只程式可讀性會提高很多,寫起來也會很順手。

錯誤處理說完了,接下來就講一些比較進階(或是奇怪)的用法吧。


► 把函式存進變數裡

我們可以把函式存在變數裡面,需要用到的時候再拿出來用:

square := func(i float64) float64 { return math.Pow(i, 5) }
result := square(3)


這個我個人不太常用就是了,我覺得寫起來很醜。


► 匿名函式

你可以當場定義一個匿名函式,直接呼叫它:

func main() {
    fmt.Println("Hi")
    func(i int) {
        fmt.Println(i)
    }(48763)
    fmt.Println("Bye")
}


注意如果你的函數沒有傳入的自變數,右大括號後面一樣要加上 () 表示呼叫的行為,不然它就只會是一個宣告好卻無法被使用的函數。

這在某些場景有機會用到,例如不需要佔用命名空間的簡單函式,或者是需要非同步、並行處理的函式。至於在 Golang 裡面要怎麼樣才能夠讓函式並行處理(就像多執行緒),我們就之後再挑個好時機拿出來講吧。


► 不確定的自變數數量

如果我們希望這個函式一次傳入很多個值,但不知道具體有幾個,我們可以在型態前面使用「…」來表示不確定的數量(注意,這種不定數量的自變數必須被擺在最後面):

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 會是一個 []int(也就是 int slice),我們只要用 for-range 的方式就可以把裡面的每一個值都取出來了。

像是我們最常用到的 fmt.Println() 和 fmt.Printf() 就是像這樣設計,我們也才能直接把很多個不確定數量的變數填進去。


► 把語句排定到函式結束時執行

這也是 Golang 的特色。有時候我們可能一個函式裡面會出現很多個 return,同時又有些任務是 return 前必做的(例如關閉跟資料庫之間的連線、關閉檔案等等),那麼我們就可以用 defer 來把函式排到函數結束時執行,避免一直把類似的東西複製貼上,也減少了被漏掉的風險:


如果需要執行的事情有好幾句,那剛才提到的匿名函式就派上用場了:


那要是一個函式裡面有很多個 defer,較晚 defer 的會先被執行:


除此之外,panic 的時候會執行完所有層級的 defer,然後才離開程式(log.Fatal 的話就不會執行 defer 了,印完錯誤訊息之後直接退出程式)。

關於函式的各種眉角,大概就是這樣了。這系列的下一篇可能是關於迭代和陣列,可能是關於 package 的用法,也可能是我最近搞哈哈姆特 BOT 弄出來的小成果,就讓我再花點時間決定吧……




縮圖素材原作者:Renée French(CC BY-SA 3.0)
送禮物贊助創作者 !
83
留言

創作回應

夢想成為虹夏的奴隸
才華洋溢...[e11]
2021-10-15 19:38:10
解凍豬腳
我身上洋溢著藝術家氣息
2021-10-18 07:32:29
勳章向創作者進行贊助 ✦
2021-10-28 11:01:09
解凍豬腳
感謝勳章大~ [e38]
2021-10-29 11:45:10
費玟
那個匿名函式最後的()讓我想起了JavaScript 呢…
宣告完直接呼叫它的感覺

話說宣告ErrDivisionByZero的時候為什麼要首字大寫?
是因為他是var?還是因為要註明他是error.new出來的常數?
2022-04-17 01:21:25
解凍豬腳
在 Golang 裡面,如果一個 var 或 func 的名稱是首字大寫,那麼它就會是一個 exported 的變數,可以被其他的 package 存取

既然我們要封裝成一個 package,別人在引用它的時候總也有需要錯誤處理的情境,比如我們今天使用 strconv 這個 package,那麼當我們需要 handle 從它那邊拋出來的錯誤,就會有類似這種情形:


// 在 main package 裡面使用 strconv 的功能

str := "test123"
result, err := strconv.Atoi(str)
if errors.Is(err, strconv.ErrSyntax) {
fmt.Println("An error occured")
}

如果今天 strconv 裡面把 ErrSyntax 取名叫做 errSyntax 的話,這個 error 的對象就會是 unexported,不能被其他的 package 引用,那這樣 strconv 裡面的 errSyntax 就等於白定義了、有寫跟沒寫一樣,畢竟只有 strconv 內部自己能用
2022-04-17 01:42:21
費玟
哦哦哦哦了解
難怪每個第三方package的function都是首字大寫
感覺有點像java在定義存取權限那樣

不過在Go這裡是只有首字大寫的才能被別人用啊…
會用這種方式區分還滿酷的 真有特色

也許這就是Go的可讀性那麼高的原因吧…
不像java宣告個變數/寫個function囉哩囉嗦
2022-04-17 01:50:39
解凍豬腳
TEST
2022-04-18 17:36:35
解凍豬腳
1234567
4567898
6541948
[e13]
2022-04-18 17:37:54

更多創作