1. 導論
開始寫函式時,你通常會這樣想:「好,我需要回傳 int——那就回傳 int。」但很快就會出現更貼近人類直覺的需求:希望函式簽名本身就能說明回傳值的意義。這在有多個回傳結果的函式中特別明顯,因為如果沒有名稱,很容易搞混哪個是什麼,結果寫出來的程式能跑,卻是錯的(這甚至比編譯錯誤更可怕)。
在 Go 裡,不只能列出回傳結果的型別,還可以直接在簽名裡替它們命名:
func minMax(a, b int) (min int, max int) {
// ...
}
而這不是「好看用的註解」。它們是函式內部真正存在的變數。
命名回傳值就是變數
這裡有個很重要、卻常被跳過的點:之後大家會驚訝為什麼函式回傳了 0。當你使用命名回傳值時,Go 會大致做這樣的事:進入函式時,先建立回傳變數,並把它們初始化為zero value。
也就是說,在這個函式裡:
func example() (x int, ok bool) {
// ...
}
變數 x 和 ok 從一開始就已經存在了: x == 0, ok == false. 這有點像你在函式開頭寫了:
x := 0
ok := false
(只是這不完全等於 :=,但意思很接近。)
因此有兩個推論。第一,你可以像對一般變數一樣,在函式本體裡為這些回傳值賦值。第二,Go 允許一種特殊的回傳形式——不帶值的 return。
2. Naked return:不帶值的 return
不帶值的 return 只有在回傳值是命名的時候才允許使用。它的意思就是:「回傳目前存在於回傳變數中的內容。」
這很方便,但也很危險。方便之處在於可以寫得更短,尤其當你要回傳很多值時更明顯。危險之處在於,程式碼讀者(包含一週後的你自己)可能看不出每個分支究竟會回傳什麼。
我們先看一個容易理解的例子:
package main
import "fmt"
func minMax(a, b int) (min int, max int) {
if a < b {
min = a
max = b
return
}
min = b
max = a
return
}
func main() {
lo, hi := minMax(10, 3)
fmt.Println(lo, hi) // 3 10
}
這裡不帶值的 return 看起來還算可以接受,因為函式很短,而且在每個 return 之前,我們都明確地把 min 和 max 賦好了值。
但老實說:這只在函式很小、很線性的時候好用。一旦條件變多、暫存變數變多、邏輯變複雜,naked return 就會開始變成一個小謎題。
4. 迷你應用:由函式組成的計算器
為了不只是空談命名回傳值,我們用一個簡單又能擴充的小應用來貫穿:命令列「兩數計算器」。它會讀取 a、op、b,執行運算並印出結果。今天我們不是在打造一個完美無缺、可用一輩子的計算器;我們更重要的是學會怎麼寫出乾淨的函式。
先假設輸入格式是這樣:
10 / 2
先做一個函式,負責嘗試解析兩個數字和一個運算子。為了簡化,我們先假設 fmt.Scan 已經把三個 token 當成字串讀進來了。
5. 在 (T, error) 中命名:可以,但要小心
現在很典型的誘惑來了:既然 Go 允許命名回傳值,那我們就把 (result, error) 命名起來,然後直接用不帶值的 return 吧。有時候確實有人這麼寫。但你必須知道這樣做的代價。
下面是一個具有明確實用價值的解析函式例子:
package main
import (
"strconv"
)
func parseInt(s string) (n int, err error) {
n, err = strconv.Atoi(s)
return
}
沒錯,這能運作。如果 strconv.Atoi 回傳錯誤,它會進到 err,而 n 會保留為零值(這正符合「錯誤時回傳 zero value」的慣例)。
但問題不在於「能不能運作」。問題在於它好不好讀。如果你看到不帶值的 return,你得先記住:喔,回傳值是命名的;喔,那就代表會回傳目前的 n 和 err;喔,那我還得去找它們是哪裡被改過。
對於超短函式來說,這沒問題。但在較長的函式裡,它就會開始惹人煩,像半夜 3 點的蚊子:很小,但為什麼還在這裡?
很多時候,寫得稍微長一點,但更透明,反而更易讀:
package main
import "strconv"
func parseInt(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, err
}
return n, nil
}
這個版本多了幾行,但它不會讓人猜謎:出錯時回傳 0,成功時回傳 n。
6. 什麼時候命名回傳值真的有幫助
有些情況下,回傳值名稱真的能當成程式中的文件說明。特別是當回傳結果很多,而且你不想讓讀者自己猜每個位置代表什麼時。
例如,解析表達式「a op b」的函式,可能會回傳三個實體。沒有名稱時,簽名看起來像這樣:
func parseExpr(aS, opS, bS string) (int, string, int, error)
表面上沒問題,但眼睛會黏住:嗯,第一個 int 是 a,字串是 op,第二個 int 是 b……大概吧。
加上名稱就清楚多了:
package main
import (
"errors"
)
func parseExpr(aS, opS, bS string) (a int, op string, b int, err error) {
if opS != "+" && opS != "-" && opS != "*" && opS != "/" {
return 0, "", 0, errors.New("unknown operator")
}
// 接下來再解析數字(稍後處理)
return
}
但請注意:我已經不再使用不帶值的 return 作為主要風格了。我在錯誤時明確回傳 (0, "", 0, err)。即使回傳值是命名的,這也讓函式行為一目了然。
7. 為什麼最好避免 naked return
當函式變長時,不帶值的 return 會讓可讀性變差,原因很簡單:它把重點藏起來了——到底回傳的是什麼。讀者不得不在腦中「執行程式碼」,追蹤目前 res、err、count、ok 等變數裡到底放了什麼。
還有一個不太討喜的效果:naked return 會讓錯誤更安靜。你可能忘了在某個分支上為結果變數賦值,而函式會很平靜地回傳 zero value。可 zero value 可能看起來就像「正常結果」。例如,0 本來就可能是合法值。然後你會花一個小時去找,為什麼「有時候會是零」。
所以在教學風格(以及許多 production code)裡,通常會遵守一個原則:命名回傳值可以用來表達意義,但不帶值的 return 只適合非常短、而且一眼就能看懂的函式。
8. 陷阱 1:命名回傳值旁邊的 shadowing
現在來到最有趣的部分。命名回傳值其實很容易被 := 在沒有任何紅色編譯錯誤的情況下「弄壞」——只因為 shadowing。
情境是這樣:你有一個命名回傳值 err,型別是 error。你在函式裡寫了 :=,不小心又建立了一個新的變數 err,它只活在那個區塊裡,而「真正的」結果變數 err 仍然是 nil。之後你用不帶值的 return——結果回傳出去的是 nil,即使區塊內其實發生了錯誤。
這不是幻想,這是 Go 裡的典型陷阱::= 可能會建立新變數,而不是對既有變數賦值;如果你「射偏了」,就會出現 shadowing。
壞範例(請仔細看):
package main
import (
"strconv"
)
func parseIntBad(s string) (n int, err error) {
if s == "" {
err := strconv.ErrSyntax // BUG: 這是新的 err,不是結果變數!
_ = err
return // 會回傳 n=0, err=nil(糟透了)
}
n, err = strconv.Atoi(s)
return
}
這裡 err := ... 建立了區域性的 err,它沒有進到命名回傳值裡。等到 return 不帶值地把結果帶出去時,外面拿到的 err == nil。
修正方式:使用 =(賦值),而不是 :=(宣告加賦值):
package main
import "strconv"
func parseIntOK(s string) (n int, err error) {
if s == "" {
err = strconv.ErrSyntax
return
}
n, err = strconv.Atoi(s)
return
}
而且如果想要更透明,還可以更進一步,不用 naked return:
package main
import (
"errors"
"strconv"
)
func parseIntClear(s string) (int, error) {
if s == "" {
return 0, errors.New("empty number")
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, err
}
return n, nil
}
9. 陷阱 2:忘了替結果賦值
還有另一個故事,在數學函式裡尤其危險。你會這樣寫:
func abs(x int) (res int) {
if x < 0 {
res = -x
return
}
return
}
表面上看起來還滿「優雅」的。但在 x >= 0 的分支裡,你回傳的是仍然為零的 res。也就是說,abs(10) 會回傳 0。錯誤不吵不鬧,編譯器也不抱怨,測試又沒有——世界一片美好,直到使用者發現計算器「有點憂鬱」。
正確做法要嘛是在每個分支都賦值:
package main
func abs(x int) (res int) {
if x < 0 {
res = -x
return
}
res = x
return
}
要嘛乾脆不用 naked return:
package main
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
第二個版本更容易讀。這正是「更短」和「更好」有時候不是同一件事的好例子。
10. 給計算器的實用風格
我們把幾個乾淨的部分組成一條清楚的邏輯線。
先寫一個運算函式,讓結果與錯誤都一目了然。這裡 (int, error) 這個合約就非常自然。
package main
import "errors"
func calc(a int, op string, b int) (int, error) {
if op == "/" && b == 0 {
return 0, errors.New("division by zero")
}
if op == "+" {
return a + b, nil
}
if op == "-" {
return a - b, nil
}
if op == "*" {
return a * b, nil
}
if op == "/" {
return a / b, nil
}
return 0, errors.New("unknown operator")
}
這裡我們刻意不使用命名回傳值:這個函式本來就很透明,而明確的 return 看起來更好。
接著寫一個 main,讀入三個 token,做解析並計算:
package main
import (
"fmt"
)
func main() {
var aS, opS, bS string
fmt.Scan(&aS, &opS, &bS)
a, err := parseIntClear(aS)
if err != nil {
fmt.Println("error:", err)
return
}
b, err := parseIntClear(bS)
if err != nil {
fmt.Println("error:", err)
return
}
res, err := calc(a, opS, b)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("result:", res) // result: 5(例如)
}
這還不是超級漂亮的寫法(我們把解析重複了兩次),但就課程層級來說,這是一個可接受、清楚的線性版本。重點是:每個函式都有明確合約,而且沒有任何「神祕的回傳值」。
小表格:三種回傳風格與其代價
當你在選擇風格時,直接比較各種選項會很有幫助。下面這張表不是 Go 的「規則」,而是給大腦看的便利速查表:
| 風格 | 簽名範例 | 如何回傳 | 什麼時候正常 | 什麼時候不好 |
|---|---|---|---|---|
| 沒有命名回傳值 | |
|
幾乎總是 | 幾乎從不 |
| 有命名回傳值,但明確 return | |
/ |
當名稱真的增加意義時 | 如果名稱很多卻沒有讓事情更清楚 |
| 命名回傳值 + 不帶值的 return | |
|
非常短的函式裡 | 長函式、有分支、以及有 shadowing 風險時 |
11. 常見錯誤
錯誤 1:因為「可以」就把不帶值的 return 用在長函式裡。
問題不會立刻出現:程式能編譯,看起來也能跑,但讀起來很痛苦。過一陣子你就會開始害怕修改這種函式,因為根本不清楚每個分支真正會回傳什麼。在實際專案裡,這通常最後會導向反向重構:改回明確的 return x, y。
錯誤 2:忘了在某個分支替命名回傳值賦值。
最麻煩的是,編譯器不會幫你:它認為既然結果變數存在,那你就是有意要回傳它目前的值。最後得到的就是「安靜的 0」(或空字串),看起來像合法值,卻在不報錯的情況下把邏輯弄壞。
錯誤 3:在命名回傳值旁邊因為 := 造成 shadowing。
這是經典陷阱:你以為自己是在替結果 err 賦值,實際上卻在區塊裡建立了新的 err。之後 return 回傳的還是舊的(通常是 nil)。這種錯誤特別痛,因為從外表看起來程式碼很「Go」,但它是安靜地壞掉。這類情況其實對 := 很典型,而且當你需要更新已存在的變數時尤其危險。
錯誤 4:不加思考地替結果命名,卻沒有語意價值。
有時候簽名裡會出現 a int, b int, c int 這樣的回傳名稱——這其實沒有解釋任何事情。名稱應該是有意義的(像 min、max、n、err),不然就乾脆不要命名。否則你只是在增加雜訊,還會讓人誤以為「這裡有什麼很複雜的東西」。
錯誤 5:在同一個函式裡,沒有理由地混用不同的回傳風格。
當一部分分支做 return,一部分做 return x, y,另一部分又做 return 0, err,讀者就會開始懷疑:「這些分支回傳的東西真的一樣嗎?」如果你使用了命名回傳值,通常更易讀的做法是維持明確的 return value, err,而把不帶值的 return 留給非常短、非常明顯的情況。
GO TO FULL VERSION