前提
最近在公司程式碼review
過程中, 看到同事的程式碼中大量使用了goto
, 我給出了"不用 goto"的建議. 但其給出的理由是使用goto
更簡單. 確實, 使用goto
可以使得邏輯更簡單直接, 但前提是不亂用goto
, 而在公司的專案中又很難保證這一點.
問題
使用goto
帶來的最直觀的問題就是邏輯的複雜度直線升高. 舉個例子來展現goto
是如何一步步導致邏輯破敗不堪的. (當然, 這個例子是我臆想出來的場景)
首先, 我們有一個建立訂單並驗證支付的需求:
package main
import "fmt"
func main() {
fmt.Println("處理訂單開始")
fmt.Println("Step 1: 建立訂單")
fmt.Println("Step 2: 驗證訂單")
fmt.Println("Step 3: 驗證付款資訊")
fmt.Println("Step 4: 訂單完成")
fmt.Println("處理訂單結束")
}
此時邏輯很清楚吧. 現在, 我們要對驗證訂單的結果進行處理, 如果驗證失敗, 則進行錯誤處理, 很合理吧:
package main
import "fmt"
func main() {
fmt.Println("處理訂單開始")
fmt.Println("Step 1: 建立訂單")
var validErr error
fmt.Println("Step 2: 驗證訂單")
if validErr != nil {
goto Fail
}
fmt.Println("Step 3: 驗證付款資訊")
fmt.Println("Step 4: 訂單完成")
goto End
Fail:
fmt.Println("驗證付款失敗")
End:
fmt.Println("處理訂單結束")
}
現在, 新的需求來了:
- 付款資訊處理可能因各種原因失敗, 需要重試, 最多重試3次
- 驗證訂單也可能失敗(非同步介面驗證, 網路抖動等), 需要重試, 最多重試3次
- 若訂單驗證失敗, 需要提示並重新建立訂單
package main
import "fmt"
func main() {
fmt.Println("處理訂單開始")
CreatOrder:
fmt.Println("Step 1: 建立訂單")
validRetryNum := 0
ValidOrder:
var validErr error
fmt.Println("Step 2: 驗證訂單")
if validErr != nil {
validRetryNum++
if validRetryNum <= 3 {
goto ValidOrder
}
fmt.Println("訂單驗證失敗")
goto CreatOrder
}
checkRetryNum := 0
CheckOrder:
var checkErr error
fmt.Println("Step 3: 驗證付款資訊")
if checkErr != nil {
checkRetryNum++
if checkRetryNum <= 3 {
goto CheckOrder
}
goto CheckErr
}
fmt.Println("Step 4: 訂單完成")
goto End
CheckErr:
fmt.Println("付款資訊驗證失敗")
End:
fmt.Println("處理訂單結束")
}
再來:
- 驗證付款資訊失敗, 可能是因為沒有付款等, 需要進行付款處理的邏輯
- 驗證訂單付款時, 訂單可能認為取消支付, 需要處理資源清理等
package main
import "fmt"
func main() {
fmt.Println("處理訂單開始")
CreatOrder:
fmt.Println("Step 1: 建立訂單")
validRetryNum := 0
ValidOrder:
var validErr error
fmt.Println("Step 2: 驗證訂單")
if validErr != nil {
validRetryNum++
if validRetryNum <= 3 {
goto ValidOrder
}
fmt.Println("訂單驗證失敗")
goto CreatOrder
}
checkRetryNum := 0
CheckOrder:
var checkErr error
var paid, cancel bool
fmt.Println("Step 3: 驗證付款資訊")
if checkErr != nil {
checkRetryNum++
if checkRetryNum <= 3 {
goto CheckOrder
}
goto CheckErr
}
if cancel {
goto CancelOrder
}
if !paid {
goto ProcessOrder
}
fmt.Println("Step 4: 訂單完成")
goto End
CheckErr:
fmt.Println("付款資訊驗證失敗")
goto End
CancelOrder:
fmt.Println("取消訂單支付")
goto End
ProcessOrder:
fmt.Println("處理付款資訊")
goto CheckOrder
End:
fmt.Println("處理訂單結束")
}
再來
- 增加處理失敗的日誌記錄
- 增加訂單驗證失敗的日誌記錄
- 不管是取消訂單支付, 還是付款處理失敗, 都需要進行一些清理工作
package main
import "fmt"
func main() {
fmt.Println("處理訂單開始")
CreatOrder:
fmt.Println("Step 1: 建立訂單")
validRetryNum := 0
checkRetryNum := 0
var checkErr error
var validErr error
var paid, cancel bool
ValidOrder:
fmt.Println("Step 2: 驗證訂單")
if validErr != nil {
validRetryNum++
if validRetryNum <= 3 {
goto ValidOrderErrorLog
}
fmt.Println("訂單驗證失敗")
goto CreatOrder
}
CheckOrder:
fmt.Println("Step 3: 驗證付款資訊")
if checkErr != nil {
checkRetryNum++
if checkRetryNum <= 3 {
goto CheckOrderErrorLog
}
goto CheckErr
}
if cancel {
goto CancelOrder
}
if !paid {
goto ProcessOrder
}
fmt.Println("Step 4: 訂單完成")
goto End
ValidOrderErrorLog:
fmt.Println("記錄訂單驗證失敗")
goto ValidOrder
CheckOrderErrorLog:
fmt.Println("記錄付款驗證失敗")
goto CheckOrder
CheckErr:
fmt.Println("付款資訊驗證失敗")
goto CleanOrder
CancelOrder:
fmt.Println("取消訂單支付")
goto CleanOrder
ProcessOrder:
fmt.Println("處理付款資訊")
goto CheckOrder
CleanOrder:
fmt.Println("訂單關閉的清理工作")
goto End
End:
fmt.Println("處理訂單結束")
}
現在, 如果你還覺得邏輯清晰, 那我只能說一句"牛".
程式碼演進到現在, 邏輯已經十分混亂了, 邏輯的混亂會導致一系列問題:
- 難以理解, 逐步增加後續迭代的成本
- 造成額外的心智負擔
- 追蹤困難
- 如果是
if
for
在邏輯上是自上而下的, 但引入goto
會導致邏輯上下橫跳 - 等等
可能有人會覺得我舉的例子有些極端, 實際中沒有人會這麼做. 那是因為例子總是簡單化的, 現實中的場景實際上要更加複雜:
- 大段邏輯分散: 例子中的所有單條
print
語句, 在實際專案中都可能會對應一大段的邏輯 - 最小改動原則: 對現有專案進行改動的時候(尤其是需求要的比較急, 要求改動最小實現功能), 對現有程式碼的改動越小, 則風險越小. 因此邏輯會越堆越難以理解, 直至最後無法使用
- 每個人的水平不同: 同一份程式碼會由團隊中的不同人在不同時間維護, 即使你自信自己的水平, 也無法保證程式碼在未來不會向著這個方向發展
使用場景
當然, 我也不是把goto
一棒子打死, 同事在反駁我的時候也給出了強有力的理由"Go 標準庫也存在大量使用goto
的地方". 比如:
func ParseMAC(s string) (hw HardwareAddr, err error) {
if len(s) < 14 {
goto error
}
if s[2] == ':' || s[2] == '-' {
if (len(s)+1)%3 != 0 {
goto error
}
n := (len(s) + 1) / 3
if n != 6 && n != 8 && n != 20 {
goto error
}
// ...
} else if s[4] == '.' {
if (len(s)+1)%5 != 0 {
goto error
}
n := 2 * (len(s) + 1) / 5
if n != 6 && n != 8 && n != 20 {
goto error
}
// ...
} else {
goto error
}
return hw, nil
error:
return nil, &AddrError{Err: "invalid MAC address", Addr: s}
}
這種其實是可以接受的, 能夠簡化流程, 但是, 但是, 不要忘記我不建議使用goto
最重要的一點:
- 你無法保證自己擁有掌控
goto
的實力 - 即使你自信, 也無法保證同事有掌控
goto
的實力
一旦在使用過程中產生破窗效應, 使用goto
破壞的速度一定是比不用要快的多. 程式碼很快就會脫離掌控.
因此, 為了避免這種情況, 最好的方式就是在最開始杜絕掉.
最後的最後, goto
如果能夠好好用的話, 確實能夠帶來一定的便利性, 前提是專案由你一人開發, 或你擁有掌控權可以拒絕某些腐敗程式碼進入程式碼庫.
goto
並不可怕, 可怕的是不加限制的亂用goto
.
歡迎來辯...