CodeGym /課程 /Go SELF /命名回傳值與不帶值的 return

命名回傳值與不帶值的 return

Go SELF
等級 9, 課堂 3
開放

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 的「規則」,而是給大腦看的便利速查表:

風格 簽名範例 如何回傳 什麼時候正常 什麼時候不好
沒有命名回傳值
func f() (int, error)
return 0, err
幾乎總是 幾乎從不
有命名回傳值,但明確 return
func f() (n int, err error)
return 0, err
/
return n, nil
當名稱真的增加意義時 如果名稱很多卻沒有讓事情更清楚
命名回傳值 + 不帶值的 return
func f() (n int, err error)
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 留給非常短、非常明顯的情況。

1
任務
Go SELF, 等級 9, 課堂 3
上鎖
兩個參考點
兩個參考點
1
任務
Go SELF, 等級 9, 課堂 3
上鎖
座標的絕對值
座標的絕對值
1
任務
Go SELF, 等級 9, 課堂 3
上鎖
靜默解析器
靜默解析器
1
任務
Go SELF, 等級 9, 課堂 3
上鎖
指令解析
指令解析
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION