CodeGym /課程 /Go SELF /函式設計:guard clauses

函式設計:guard clauses

Go SELF
等級 37, 課堂 1
開放

1. 扁平程式碼,取代 if 的「聖誕樹」

當人們第一次聽到「函式設計」時,常常會以為這是在談美感:有人喜歡早期 return,有人則喜歡一個大的 if。 但實際上遠比這更務實。函式的結構會直接影響 bug 數量、修改速度,以及你在睡一覺、吃完飯、又活過一天之後,能多快看懂自己的程式碼。

在 Go 中尤其明顯,糟糕的結構會導致「檢查之林」。甚至連 Go 的作者都曾指出,若程式碼中有一半的行都是錯誤檢查,閱讀起來會很吃力,也很容易誤解。 因此我們要學會把函式寫成 主要流程由上到下、順著讀就能懂 的樣子,而不是讓檢查把程式碼變成多層巢狀的樹。

巢狀過深的問題與 happy path 的流失

想像一下,你打開一個函式,想在 15 秒內理解它在做什麼。如果程式碼像 if 套 if 的俄羅斯娃娃, 大腦就必須同時維持一堆條件:現在是不是在 title 不為空的分支裡,但 id 有效,還沒有發生錯誤…… 這不是因為你不夠強,而是注意力本來就有限。而且注意力會生氣。

我們來看一個簡化版的例子,來自我們的教學應用程式「todo」(目前它只存在於記憶體中,還沒打算和 Trello 爭名氣)。

範例:以「全部塞進一個 if」風格撰寫的命令處理器

package main

import "fmt"

func addTaskBad(title string) {
	if title != "" {
		if len(title) <= 50 {
			fmt.Println("added:", title) // added: ...
		} else {
			fmt.Println("title too long") // title too long
		}
	} else {
		fmt.Println("empty title") // empty title
	}
}

問題不在於這段程式碼「不正確」。問題在於 happy path (正常情境:「新增任務」)被藏在某個角落,而讀者一開始看到的是分支與例外。

Guard clauses:把檢查放在上方,並提早離開

Guard clause 就像「門口的警衛」。它會檢查前置條件,然後立刻把不好的情況擋回去。 如此一來,巢狀幾乎消失,而主要流程就會落到下方,也就是它該在的位置。

概念其實很簡單:如果條件不好,就先處理並立刻離開。 如果可以在入口就把垃圾丟掉,就沒必要把 happy path 包在三層 if 裡。

同一個範例,但改用 guard clauses

package main

import "fmt"

func addTaskGood(title string) {
	if title == "" {
		fmt.Println("empty title") // empty title
		return
	}
	if len(title) > 50 {
		fmt.Println("title too long") // title too long
		return
	}
	fmt.Println("added:", title) // added: ...
}

注意這個效果:現在函式讀起來,就像你在向別人解釋它的行為: 「如果是空的,就報錯;如果太長,就報錯;否則就新增。」

可以把它想成一個迷你流程圖:

flowchart TD
    A[函式開始] --> B{title 为空?}
    B -- 是 --> C[報告錯誤 + return]
    B -- 否 --> D{太長?}
    D -- 是 --> E[報告錯誤 + return]
    D -- 否 --> F[執行主要流程]

搭配 error 的早期返回

當我們以 Go 風格 (T, error) 工作時,早期返回會變得更有價值。 在這種風格裡,「錯誤」不是什麼罕見又戲劇化的東西,而是正常輸出:操作成功,或者失敗。

閱讀時的關鍵規則是:真正做事的程式碼,應該盡可能保持線性。 而錯誤應該快速處理、盡量靠近發生位置,並且立刻導致返回。

不佳版本:巢狀的 happy path

package todo

import "errors"

func NormalizeTitleBad(title string) (string, error) {
	if title != "" {
		return title, nil
	}
	return "", errors.New("title is empty")
}

這還算可以接受,但在實際函式中,條件不會只有一個,而是五個;你很容易就會掉進「樹林」裡。

使用 guard clause 的版本

package todo

import "errors"

func NormalizeTitle(title string) (string, error) {
	if title == "" {
		return "", errors.New("title is empty")
	}
	return title, nil
}

沒錯,表面上看起來「幾乎一樣」。但當條件變多時,第二種風格會更容易擴展: 你不必一直記著「我們現在是在 if 分支裡,還是已經離開了?」。

3. 小函式與責任

一個函式,一項責任

來到新手最愛問的問題:「小函式要多小才算小?」 老實說:「小到讀者不會哭」就差不多了。 更工程化一點的說法是:一個小函式,應該能用一句話描述,而且這句話不需要用到「和」。

如果你說:「這個函式會讀取輸入 並且 解析 並且 驗證 並且 更新資料 並且 印出結果」,那恭喜你:你找到一個重構候選人。

而且這很正常:我們一開始幾乎都這樣寫。大腦想的是「把事情做完」,不是「把程式設計好」。 但事情做完之後,你還得和這段程式碼一起生活。程式碼就像貓一樣,如果你不教牠規矩,它就會往拖鞋裡亂搞。

判斷準則:什麼時候抽出去,什麼時候留下來

有時候初學者聽到「做小函式」就開始把所有東西切碎: isNotEmptyString、isPositiveInt、printOk。 結果反而更難讀:原本線性的程式碼,變成像踩石頭一樣在檔案之間跳來跳去。

這裡有個簡單的想法可參考。當某個步驟有獨立的名稱與意義、或會重複、或它隱藏了妨礙閱讀流程的細節時,就值得抽出去。 如果只是一次性的、很明顯的、而且不會增加噪音,那就沒必要抽出去。

你也可以問自己一句很實用的問題:如果我把它命名成一個獨立函式,這個名字會比函式本體更有用嗎? 如果會,那就抽出去;如果不會,就留著。

為了讓它更直觀,這裡有一個小表格(有時候不用表格,文字很難說清楚):

情境 該怎麼做 原因
函式裡有很多步驟,流程變得看不清楚 把步驟抽成 helper 讓 happy path 回到線性閱讀
相同的檢查在 3 個地方重複出現 把檢查抽出去 減少重複碼,也更容易修改規則
檢查非常短,而且只會用一次 通常保留原地 否則會變成「為了抽離而抽離」
函式能用「和」來描述 拆分 很可能混合了多個責任

4. 範例:把 todo 寫得更易讀

現在把這些片段組成一個迷你應用程式。假設我們有一個 in-memory 任務服務。 我們不是在建複雜架構,而是在練習 函式結構: happy path、guard clauses,以及小 helper。

任務模型

先從簡單結構開始。請注意:這個範例很小,而且也不是「完美」的,因為今天我們訓練的是函式的可讀性,而不是領域模型設計。

package todo

type Task struct {
	ID    int
	Title string
	Done  bool
}

使用 in-memory 儲存的服務

這個服務保存任務列表與下一個 ID。沒有什麼宇宙級的東西。

package todo

type Service struct {
	nextID int
	tasks  []Task
}

建構函式:給預設值

我們希望 Service 能以可預測的方式啟動。

package todo

func NewService() Service {
	return Service{nextID: 1}
}

Add 方法:guard clauses 與線性的 happy path

在新增任務時,通常有兩種檢查:「輸入本身正常嗎?」以及「能不能執行這個動作?」。 今天我們只需要驗證標題。

package todo

import "errors"

func (s *Service) Add(title string) (Task, error) {
	if title == "" {
		return Task{}, errors.New("title is empty")
	}
	t := Task{ID: s.nextID, Title: title}
	s.nextID++
	s.tasks = append(s.tasks, t)
	return t, nil
}

這裡有個重要的小細節:happy path(建立任務、遞增 ID、加入清單)是連續進行的,沒有 else。 而錯誤則是快速早退。

如果之後你要加入正規化(例如去除空白),你大概會想把它抽成一個獨立的小函式——而那會是正確的時機,因為那時候正規化已經變成一個獨立步驟。

小 helper:搜尋與 MarkDone

現在我們來加上根據 ID 完成任務的功能。這裡很容易看出新手常寫的一大包函式:ID 解析、搜尋、修改、錯誤處理全塞在一起。 我們會做得更乾淨:分成獨立搜尋與獨立操作。

小 helper:找到任務索引

package todo

func (s *Service) findIndexByID(id int) (int, bool) {
	for i, t := range s.tasks {
		if t.ID == id {
			return i, true
		}
	}
	return -1, false
}

這個函式很小,但非常有用:現在 MarkDone 會像一段流程,而不是像「搜尋也在這裡」的雜燴。

主要方法:使用 guard clauses 的 MarkDone

package todo

import "errors"

func (s *Service) MarkDone(id int) error {
	if id <= 0 {
		return errors.New("id must be positive")
	}
	i, ok := s.findIndexByID(id)
	if !ok {
		return errors.New("task not found")
	}
	s.tasks[i].Done = true
	return nil
}

你看得出這個函式的「骨架」嗎?它非常整齊:檢查輸入 → 找到項目 → 修改 → 結束。

從字串解析 ID

我們之前談過解題的一般模板:「read → parse → compute → print」。在應用層級上,原理也是一樣:不要把所有事情攪成一鍋粥。 即使你暫時還是把所有東西寫在 main 裡,你仍然可以用小步驟,把邏輯拆進函式。

假設我們有一個命令字串:"done 3"。我們希望把它好好解析出來。 我們可以建立一個 parseDoneID 函式,只負責解析。

package todo

import (
	"errors"
	"strconv"
)

func parseDoneID(s string) (int, error) {
	id, err := strconv.Atoi(s)
	if err != nil {
		return 0, errors.New("id must be integer")
	}
	return id, nil
}

沒錯,這裡的錯誤目前只是簡單字串,對於現在的層級來說很正常。重點是:這個函式只做 一件事,而且很容易測試, 即使你現在還沒寫測試(不過你未來的自己會感謝你)。

main 的迷你重構:更少巢狀,更多意義

我們來寫一個很基本的 main,它會讀取命令與參數。不是 CLI parser,也不是 flags——只是展示結構。

糟糕風格:全部塞在一起,而且巢狀很多

package main

import (
	"fmt"
)

func main() {
	var cmd, arg string
	fmt.Scan(&cmd, &arg)

	if cmd == "done" {
		if arg != "" {
			fmt.Println("ok") // ok
		} else {
			fmt.Println("missing id") // missing id
		}
	} else {
		fmt.Println("unknown command") // unknown command
	}
}

這裡的「ok」其實完全不代表真的 ok,因為我們什麼都沒做就先輸出了。這是典型症狀:函式沒有清楚的流程。

稍微好一點:guard clauses 與步驟分離

package main

import (
	"fmt"
)

func main() {
	var cmd, arg string
	fmt.Scan(&cmd, &arg)

	if cmd == "" {
		fmt.Println("empty command") // empty command
		return
	}
	if cmd != "done" {
		fmt.Println("unknown command") // unknown command
		return
	}
	if arg == "" {
		fmt.Println("missing id") // missing id
		return
	}
	fmt.Println("command looks valid") // command looks valid
}

這當然還不是完整邏輯,但結構已經成熟一些了:「先檢查 → 早退 → 再處理主要流程」。 當你接上真正的 todo.Service 時,只要把最後一行換成「解析 id → mark done → 印出結果」,函式仍然會保持可讀。

5. 常見錯誤

錯誤 #1:因為「比較安心」就把 happy path 塞進三層 if 裡。
這通常來自於想「把一切都掌控住」:先檢查條件,再檢查更多條件,最後才做事。 結果主要流程被藏得太深,快速瀏覽時根本看不出來。 解法就是 guard clauses:壞情況立刻處理並離開,把真正的事情留在下方。

錯誤 #2:因為習慣就寫 if err != nil { ... } else { ... }。
在 Go 裡,「先錯誤、再工作」的風格能讓程式碼更扁平。如果你加上 else,幾乎總會在沒有真正好處的情況下增加巢狀。 早期 return 更容易閱讀,也更容易擴充新的檢查。

錯誤 #3:把小函式變成沒有意義的轉接層。
有時候你會想把每一行都抽出去,結果就變成「空氣架構」:名字很多,意思很少。 判斷標準是:如果一個函式只是呼叫另一個函式,卻沒有增加任何意義,或者它的名字無法說明存在原因,那多半就不是好抽法。 小函式應該降低認知負擔,而不是增加它。

錯誤 #4:把解析、驗證與狀態變更混在同一個「萬用大雜燴」裡。
在小型程式裡這不太顯眼,但一旦出現第二個情境,混亂就開始了:錯誤在哪裡產生、邊界在哪裡檢查、資料在哪裡變更。 即使你暫時把所有東西都放在同一個檔案裡,也應該邏輯上把步驟分開:先解析輸入,再檢查,最後執行動作。

錯誤 #5:把早期 return 變成「沒有契約的早退」。
早退有價值的前提是它可預期:不是回傳結果,就是回傳錯誤,或者印出清楚訊息並結束。 不好的情況是,函式在中途可以「直接離開」,卻沒有任何說明,留下資料處於奇怪狀態。 因此,早期返回特別適合那些要嘛不改變狀態,要嘛只在所有檢查都通過後才改變狀態的函式。

1
任務
Go SELF, 等級 37, 課堂 1
上鎖
登入名稱驗證
登入名稱驗證
1
任務
Go SELF, 等級 37, 課堂 1
上鎖
分配戰利品
分配戰利品
1
任務
Go SELF, 等級 37, 課堂 1
上鎖
收銀員優惠券
收銀員優惠券
1
任務
Go SELF, 等級 37, 課堂 1
上鎖
備忘錄命令
備忘錄命令
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION