為什麼不建議使用goto

烟草的香味發表於2024-11-08

前提

最近在公司程式碼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("處理訂單結束")
}

現在, 新的需求來了:

  1. 付款資訊處理可能因各種原因失敗, 需要重試, 最多重試3次
  2. 驗證訂單也可能失敗(非同步介面驗證, 網路抖動等), 需要重試, 最多重試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("處理訂單結束")
}

再來:

  1. 驗證付款資訊失敗, 可能是因為沒有付款等, 需要進行付款處理的邏輯
  2. 驗證訂單付款時, 訂單可能認為取消支付, 需要處理資源清理等
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("處理訂單結束")
}

再來

  1. 增加處理失敗的日誌記錄
  2. 增加訂單驗證失敗的日誌記錄
  3. 不管是取消訂單支付, 還是付款處理失敗, 都需要進行一些清理工作
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("處理訂單結束")
}

現在, 如果你還覺得邏輯清晰, 那我只能說一句"牛".

程式碼演進到現在, 邏輯已經十分混亂了, 邏輯的混亂會導致一系列問題:

  1. 難以理解, 逐步增加後續迭代的成本
  2. 造成額外的心智負擔
  3. 追蹤困難
  4. 如果是if for 在邏輯上是自上而下的, 但引入 goto會導致邏輯上下橫跳
  5. 等等

可能有人會覺得我舉的例子有些極端, 實際中沒有人會這麼做. 那是因為例子總是簡單化的, 現實中的場景實際上要更加複雜:

  1. 大段邏輯分散: 例子中的所有單條print語句, 在實際專案中都可能會對應一大段的邏輯
  2. 最小改動原則: 對現有專案進行改動的時候(尤其是需求要的比較急, 要求改動最小實現功能), 對現有程式碼的改動越小, 則風險越小. 因此邏輯會越堆越難以理解, 直至最後無法使用
  3. 每個人的水平不同: 同一份程式碼會由團隊中的不同人在不同時間維護, 即使你自信自己的水平, 也無法保證程式碼在未來不會向著這個方向發展

使用場景

當然, 我也不是把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最重要的一點:

  1. 你無法保證自己擁有掌控goto的實力
  2. 即使你自信, 也無法保證同事有掌控goto的實力

一旦在使用過程中產生破窗效應, 使用goto破壞的速度一定是比不用要快的多. 程式碼很快就會脫離掌控.

因此, 為了避免這種情況, 最好的方式就是在最開始杜絕掉.

最後的最後, goto如果能夠好好用的話, 確實能夠帶來一定的便利性, 前提是專案由你一人開發, 或你擁有掌控權可以拒絕某些腐敗程式碼進入程式碼庫.

goto並不可怕, 可怕的是不加限制的亂用goto.


歡迎來辯...

相關文章