良好的(乾淨的) Golang 編碼 (Clean Go code)(5)

Golang的指標 (Pointer in Go)

指標是一個很廣泛的題目,它是Go語言中一個很大的部分,因此基本上不可能不具備指標的知識就來寫Go的程式,因此,了解怎麼使用指標、且不增加不必要的複雜度(藉此讓程式好讀、好乾淨)是很重要的,我們在這邊不會詳細的說明在 golang中的指標應該怎麼用,我們會專注於一些不好的Go指標使用習慣,並說明可以怎麼處理它。

指標增加了程式的複雜度,如果開發者不夠小心,不正確的使用指標會導致一些很麻煩的副作用或bug;若有遵循我們前面講的基本原則來開發,我們至少能減少一些不必要的程式複雜度。

指標可變性(Pointer Mutability)

我們前面有看過可變變數帶來的問題,主要問題來自於全域變數或是作用域很大的變數。但可變性本身是無罪的,它是一個很有用的工具,但我們應該只在需要的時候使用它,我們再來看看一個例子:

func (store *UserStore) Insert(user *User) error {
    if store.userExists(user.ID) {
        return ErrItemAlreaydExists
    }
    store.users[user.ID] = user
    return nil
}

func (store *UserStore) userExists(id int64) bool {
    _, ok := store.users[id]
    return ok
}

第一眼看過去,沒有看到甚麼問題,事實上,就是一個對一般的list進行簡單的insert的功能,我們拿一個User指標作為輸入變數,以該變數的id屬性作查詢,若該id不存在於目前的list中,那我們就把這個user(為一個指標) insert到我們的list中,接著我們使用我們的公開函數來創建新的user:

func CreateUser(w http.ResponseWriter, r *http.Request) {
    user, err := parseUserFromRequest(r)
    if err != nil {
        http.Error(w, err, http.StatusBadRequest)
        return
    }
    if err := insertUser(w, user); err != nil {
      http.Error(w, err, http.StatusInternalServerError)
      return
    }
}

func insertUser(w http.ResponseWriter, user User) error {
  	if err := store.Insert(user); err != nil {
        return err
    }
  	user.Password = ""
	  return json.NewEncoder(w).Encode(user)
}

看起來仍然沒甚麼問題。我們從接收到的request解析出user及其屬性,然後把這個user insert到我們的store list中,注意,當我們成功insert後,我們將user的password屬性設為空字串,然後才將user 轉成JSON字串,作為 response傳回給我們的client。這是一個很常見的操作,我們不希望將hash過的password傳回給我們的client。

可是,上面這段code卻會產生我們未預期的結果,我們發現我們store list上面記錄的user的password也被改成空字串了!這是因為我們傳入Insert的user是一個指標,而不是一個user物件,因此store list裡面紀錄的也是user指標,指向我們parseUserFromRequest的user,當我們改這個user的值時,也就導致了store裡面存的user的屬性值一起被更改了。

上述是一個很好的例子,說明為什麼可變性及變數作用域可能會導致很嚴重的問題及bug,當傳入一個指標作為一個函數的輸入變數,我們實際上擴大了這個變數的作用域,更讓人擔心的事實是我們擴張了作用域到達一個未定義的程度,幾乎等於到達全域的程度,從上面的例子可看出,這樣的情況可導致非常難發現及消除的bug。

幸好,要改正這樣的情況其實很簡單:

func (store *UserStore) Insert(user User) error {
    if store.userExists(user.ID) {
        return ErrItemAlreaydExists
    }
    store.users[user.ID] = &user
    return nil
}

不要用指標作為輸入變數,我們改為傳值(a copy of a User),如上面的作法,我們仍然可以用指標存在store中,只是這個指標不是從外面引進來的,而是我們函數內的user的指標,函數內的user的作用域就只在函數內了。很好,這樣解決了上面看到的問題,但是,如果我們不注意的話仍有可能產生另一個問題,考慮如下的code:

func (store *UserStore) Get(id int64) (*User, error) {
    user, ok := store.users[id]
    if !ok {
        return EmptyUser, ErrUserNotFound
    }
    return store.users[id], nil
}

這仍然是很常見的作法,一個我們store list的getter函數,然而,這段code是不好的,因為我們再一次的擴張了我們指標的作用域,因此可能導致未預期的副作用。當我們將我們存在 store中的指標直接返回給外部的函數,我們基本上就給了外部的函數直接修改我們store中的值的可能性,我們的store應該是唯一能去修改它自己的值的實體。因此最簡單的改法就是回傳該指標的物件值(傳值)而不是回傳指標(傳址)。

注意: 考慮如果我們的例子用在多執行緒的情況下,傳址到同一個記憶體位址可能會造成資料競賽狀況(race condition),可能造成data race panic。

請留意其實回傳指標本質上並沒有錯,然而,變數作用域的擴張(以及可以影響這些變數的實體的數量的增加)是我們要使用傳指標的方式時需要特別考慮的,也就是上例為什麼會特別拿出來討論的原因。但是一般的golang建構子(constructors)如下是沒問題的:

func AddName(user *User, name string) {
    user.Name = name
}

這樣沒問題是因為變數的作用域,是被呼叫這個函數的人所定義的,在函數返回後作用域並沒有變化,呼叫這個函數的人仍是這個變數的唯一擁有者(owner),因此確保該指標不會被用未預期的方式處理。

閉包是一個函數指標(Closures are Function pointers)

在我們開始談論golang令人興奮的interface之前,我們先來看看一個可替代的作法,這樣的做法在C語言叫做函數指標(function pointers),而在其他語言叫做閉包(closures),一個閉包其實就是一個輸入參數,只是這個參數指向一個可被呼叫的函數,在Javascript,我們很常用閉包作為回呼函數(callbacks),也就是當某個非同步函數執行完成時會被呼叫執行的函數。在golang的世界裡,其實並沒有這樣的概念,但是我們可以用閉包來處理golang中沒有”泛型”(generics)造成的問題。

思考一下下面的函數簽章:

func something(closure func(float64) int8) float32 { ... }

something這個函數以一個函數作為輸入參數(也就是一個閉包),最後返回一個float32,作為輸入參數的函數則會用一個float64作為輸入參數 並最後返回一個int8;像這樣的模式其實在建立一個鬆耦合的架構時非常有用,讓新增功能時能不影響到其他部分的程式。假設我們有一個struct內帶有一些我們要處理的資料,透過這個struct的 Do() 方法來處理這些資料,如果我們一開始就知道有那些處理的做法,我們當然可以在Do()中直接寫出來,像下面這樣:

func (datastore *Datastore) Do(operation Operation, data []byte) error {
  switch(operation) {
  case COMPARE:
    return datastore.compare(data)
  case CONCAT:
    return datastore.add(data)
  default:
    return ErrUnknownOperation
  }
}

但也許你也感覺到了,這樣的寫法相當死板,所有的處理方法(compare、add) 必須預先定義好、寫好,假設有一天我們新增了一個處理方法,我們就得回來維護Do()這個函數,若處理方法很多,又會新增、刪除,Do()就變得更難維護,必須要持續關注Do()要包含哪些處理方法。另外,對於其他工程師想使用我們的datastore物件,他不能改我們package的code的情況下,也會造成問題。

我們試著使用閉包來調整一下上面的Do():

func (datastore *Datastore) Do(operation func(data []byte, data []byte) ([]byte, error), data []byte) error {
  result, err := operation(datastore.data, data)
  if err != nil {
    return err
  }
  datastore.data = result
  return nil
}

func concat(a []byte, b []byte) ([]byte, error) {
  ...
}

func main() {
  ...
  datastore.Do(concat, data)
  ...
}

這樣修改後,解決了上面所說的每當新增一個處理方法就要修改Do()的問題,我們使用Do()時傳入一個處理方法即可,但,你是不是也覺得Do()目前的函數簽章變得有點複雜? 而且還有另一個問題,Do()能接收的處理函數受限於輸入參數的個數,假設以上例中,如果我們希望concat()函數能一次接受三個[]byte陣列呢?如果新的處理方法需要接收更多或更少的輸入參數或是需要接收不同type的輸入參數呢?

一個解決上述問題的做法是修改我們的concat函數,如下面的例子中,我們將它改成只接收一個輸入參數:

func concat(data []byte) func(data []byte) ([]byte, error) {
  return func(concatting []byte) ([]byte, error) {
    return append(data, concatting), nil
  }
}

func (datastore *Datastore) Do(operation func(data []byte) ([]byte, error)) error {
  result, err := operation(datastore.data)
  if err != nil {
    return err
  }
  datastore.data = result
  return nil
}

func main() {
  ...
  datastore.Do(compare(data))
  ...
}

注意到我們已經將Do()函數複雜的簽章部分移到了concat函數了,讓Do()函數簽章變得較不那麼複雜,而concat函數目前的返回值是另一個函數,而在這個返回函數中,我們將concat函數的輸入變數值(data)存了起來並使用在返回函數中,此時的返回函數因此只需要另外一個輸入參數;從返回函數的程式來看,我們會將此輸入參數跟原本的輸入參數合併起來。這樣的作法,一開始看起來也許很奇怪,但是,我們可以把這樣的作法納為我們的工具庫中,如此例中看到的,這樣的作法能幫助我們在某些情況下達成鬆耦合並讓某些複雜的函數變得比較簡單。

下一段,我們將進入介面(interfaces),在此之前,我們稍微來談談閉包與介面的不同,首先,它們解決了一些相同的問題,在golang中對於介面的實作方式讓我們有時候可以對某些情況選擇用閉包或介面都可以,不管用哪種方式都沒關係,只要能解決我們手上的問題,通常上,如果程式邏輯本身也是簡單的,實作閉包會比較簡單,但是當程式的邏輯變得複雜,還是建議使用介面。

關於上述的閉包函數的使用,Dave Cheney有一篇文章寫得很好(也有一個talk),有興趣可以去看看

另外,Jon Bodner 有一段相關的talk:

好,接下來我們進入介面,interfaces。

繼續閱讀: 良好的(乾淨的) Golang 編碼 (Clean Go code)(6)