返回值(Return values)
返回固定的錯誤碼(Returning Defined Errors)
我們從要怎麼比較清楚的的描述錯誤訊息來說明關於返回值,如前面所說的,乾淨的程式碼的目標是讓我們的程式具備可讀性、可測試性及可維護性,如果能設計好的錯誤訊息說明將會對上述的目標有著極大的幫助。
我們先來看看一般的返回一個特定錯誤訊息的方式,這是一個假設性的例子,描述關於一個執行序安全(thread-safe)、命名為Store 的map的實作:
package smelly func (store *Store) GetItem(id string) (Item, error) { store.mtx.Lock() defer store.mtx.Unlock() item, ok := store.items[id] if !ok { return Item{}, errors.New("item could not be found in the store") } return item, nil }
單獨來看,這樣寫沒有甚麼問題: 我們查詢在Store struct中的items map,看看某id的item是否已經存在,如果item存在,返回該item,如果不存在,我們返回一個錯誤訊息”item could not be found in the store”。程式看來沒甚麼問題,直接返回string的錯誤訊息有甚麼問題嗎? 嗯,讓我們來看看當我們於其他package中使用此函數時可能會被怎麼使用:
func GetItemHandler(w http.ReponseWriter, r http.Request) { item, err := smelly.GetItem("123") if err != nil { if err.Error() == "item could not be found in the store" { http.Error(w, err.Error(), http.StatusNotFound) return } http.Error(w, errr.Error(), http.StatusInternalServerError) return } json.NewEncoder(w).Encode(item) }
程式本身沒甚麼問題,但有一個情況看起來有點刺眼: 因為golang對於error的定義是一個interface,只要包含了Error()這個函數並返回string即可,而我們使用了hardcoding string 來作為if子句的判斷 ( if err.Error() == “item could not be found in the store”{}),這樣的hadrcoding string是一串魔術字串 (magic string),使用這個魔術字串就可以通關,但這樣的魔術字串的問題就是程式的彈性問題: 當我們有一天稍微修改了一下字串的內容,我們的程式就出現問題了,除非我們也同步修改其他使用這個魔術字串的所有地方,這表示我們的程式是高度耦合的,程式的不同地方更改時需要進行連動才不會出問題。更糟的是如果是外部客戶使用我們的這個函數,可以想像有一天我們修改我們的package並將此串魔術字串修改得說明更清楚,客戶的程式就馬上出現問題了,這當然是我們希望能避免的,也不難:
package clean var ( NullItem = Item{} ErrItemNotFound = errors.New("item could not be found in the store") ) func (store *Store) GetItem(id string) (Item, error) { store.mtx.Lock() defer store.mtx.Unlock() item, ok := store.items[id] if !ok { return NullItem, ErrItemNotFound } return item, nil }
我們將error 設為一個變數(例子中的ErrItemNotFound),任何其他程式使用這個package時可以用這個變數來判斷而不再判斷實際返回的string內容:
func GetItemHandler(w http.ReponseWriter, r http.Request) { item, err := clean.GetItem("123") if err != nil { if errors.Is(err, clean.ErrItemNotFound) { http.Error(w, err.Error(), http.StatusNotFound) return } http.Error(w, err.Error(), http.StatusInternalServerError) return } json.NewEncoder(w).Encode(item) }
這樣的方式比較好也安全多了,對某些人來說這樣甚至更容易讀程式碼,如果說明的字串落落長,對於開發者而言,讀 ErrItemNotFound當然比讀那段很長的說明字串容易多了,也能順利理解程式發生了甚麼錯誤。其實,不只是錯誤訊息,其他的返回值也可以使用這樣的方式,例如,我們會返回NullItem這樣的變數,而不直接返回 Item{} 這個物件,在許多的情況下,我們也比較喜歡返回一個定義好的物件,而非在返回時初始化它。
上例中返回NullItem同時也是比較安全的,舉例說明,函數的使用者可能會忘了判斷函數返回是否是errors,而直接將一個空的struct的返回值塞給一個新的變數,而這個空的struct中的所有變數目前都是nil,之後使用者可能呼叫到這些nil變數而導致程式的panic;相反的,當我們的返回值是一個預設的變數,我們將這個變數初始化過而能避免其中包含nil的情況,也就避免了上述使用者會造成程式panic的情況了。
不僅對其它使用者,對我們也是有好處的,考量以下的情況: 假設我們一樣希望達到上述的避免panic的情況,如果我們返回的是空物件,那麼我們在使用這個函數的每個地方都要處理這個空物件,但如果我們返回的是一個變數NullItem,那我們只要在NullItem定義的地方修改一下code就收工了。
var NullItem = Item{ itemMap: map[string]Item{}, }
附註: 有時候引起系統的panic反而能提醒忘了檢查是否有error return
返回變動的錯誤碼 (Returning Dynamic Errors)
上面固定的錯誤碼、錯誤變數很好,但一定會有返回固定錯誤變數無法處理的情況,例如在返回錯誤訊息中需要帶入特定變動說明的情況,譬如特定的數字、類型等等,如下:
func (store *Store) GetItem(id string) (Item, error) { store.mtx.Lock() defer store.mtx.Unlock() item, ok := store.items[id] if !ok { return NullItem, fmt.Errorf("Could not find item with ID: %s", id) } return item, nil }
那,怎麼辦?並沒有標準的作法來處理這樣變動的錯誤訊息,個人的偏好是返回一個新的interface,並加上一些函數:
type ErrorDetails interface { Error() string Type() string } type errDetails struct { errtype error details interface{} } func NewErrorDetails(err error, details ...interface{}) ErrorDetails { return &errDetails{ errtype: err, details: details, } } func (err *errDetails) Error() string { return fmt.Sprintf("%v: %v", err.errtype, err.details) } func (err *errDetails) Type() error { return err.errtype }
這樣的寫法跟我們一般標準的error處理相容,我們仍可以比較err != nil,因為它是個interface的實作,我們仍然可以呼叫 .Error(),所以相容於目前的error判斷及處理的程式,而好處則是我們可以檢查error type,同時錯誤訊息包含了動態的資訊,函數傳回錯誤如下這樣寫:
func (store *Store) GetItem(id string) (Item, error) { store.mtx.Lock() defer store.mtx.Unlock() item, ok := store.items[id] if !ok { return NullItem, NewErrorDetails( ErrItemNotFound, fmt.Sprintf("could not find item with id: %s", id)) } return item, nil }
呼叫的函數處理錯誤像這樣寫(以Http Handler為例):
func GetItemHandler(w http.ReponseWriter, r http.Request) { item, err := clean.GetItem("123") if err != nil { if errors.Is(err.Type(), clean.ErrItemNotFound) { http.Error(w, err.Error(), http.StatusNotFound) return } http.Error(w, err.Error(), http.StatusInternalServerError) return } json.NewEncoder(w).Encode(item) }
空值( Nil value)
一個關於golang的爭議就是關於nil,這個值的概念對應到C語言是Null,本質上是一個未初始化的指標(pointer),我們前面已經有討論到一些nil可能造成的問題,總的來說: 當試圖去呼叫一個其值為nil的方法或變數,程式會出問題;因此,建議盡可能避免返回nil值,這樣,使用者比較不會不小心呼叫執行到nil。其他狀況下也有可能因為nil造成不必要的困擾,一個例子是不正確的初始化struct,導致包含了nil 的屬性 (properties),如果呼叫的這個struct的nil屬性變數就會導致panic,如下例:
type App struct { Cache *KVCache } type KVCache struct { mtx sync.RWMutex store map[string]string } func (cache *KVCache) Add(key, value string) { cache.mtx.Lock() defer cache.mtx.Unlock() cache.store[key] = value }
這段 code沒甚麼問題,但是,危險的部分在於App struct 可能沒有被正確的初始化,沒有初始化裡面的Cache這個變數,所以例如下面這段code執行時,就會導致panic:
app := App{} app.Cache.Add("panic", "now")
Cache 從來沒有被初始化!因此你可以想像它目前就是個nil指標,當呼叫這個nil的Add方法當然就引起了panic,compiler會返回以下的錯誤訊息:
panic: runtime error: invalid memory address or nil pointer dereference
相反的,我們可以將Cache設為私有變數(小寫開頭),並寫一個類似getter的函數來處理它,這樣的做法能給我們更多的保障及控制,至少不會返回一個nil 值:
type App struct { cache *KVCache } func (app *App) Cache() *KVCache { if app.cache == nil { app.cache = NewKVCache() } return app.cache }
前面那段出問題的code會改寫成這樣(Cache變成Cache(),呼叫變數變成呼叫函數):
app := App{} app.Cache().Add("panic", "now")
這樣的做法使我們package的使用者不需要擔心是否有正確的初始化一個struct,他們可以專注於自己的clean code。
1 thought on “良好的(乾淨的) Golang 編碼 (Clean Go code)(4)”