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

乾淨的函數(Cleaning Function)

接著我們來看看如何讓函數(function)變得更乾淨。

函數的長度

How small should a function be? Smaller than that! – Robert C. Martin

當我們試著寫乾淨的程式碼,我們主要的目標是讓我們的程式碼更易於消化,而最有效的達成方式是讓我們的函數盡量短小。這麼做並不一定是為了避免程式碼的重複,更重要的是為了讓程式碼易於理解。

我們來看例子,下面是關於一個函數的文字說明版:

fn GetItem:
    - parse json input for order id
    - get user from context
    - check user has appropriate role
    - get order from database

寫成短的函數(通常為5-8行),我們可以讓以下的程式讀起來感覺就像讀上述的說明一樣自然:

var (
    NullItem = Item{}
    ErrInsufficientPrivileges = errors.New("user does not have sufficient privileges")
)

func GetItem(ctx context.Context, json []bytes) (Item, error) {
    order, err := NewItemFromJSON(json)
    if err != nil {
        return NullItem, err
    }
    if !GetUserFromContext(ctx).IsAdmin() {
	      return NullItem, ErrInsufficientPrivileges
    }
    return db.GetItem(order.ItemID)
}

使用較短的函數同時也消除了一個不太好的寫code習慣 : 縮排地獄(identation hell);縮排地獄通常會發生在一個函數內使用了一堆if 敘述,對人類要讀這樣的程式碼很辛苦且應該盡量避免;而在golang中,縮排地獄又很常發生在使用了interface{}及 型態轉換(type casting)時,例如下例:

func GetItem(extension string) (Item, error) {
    if refIface, ok := db.ReferenceCache.Get(extension); ok {
        if ref, ok := refIface.(string); ok {
            if itemIface, ok := db.ItemCache.Get(ref); ok {
                if item, ok := itemIface.(Item); ok {
                    if item.Active {
                        return Item, nil
                    } else {
                      return EmptyItem, errors.New("no active item found in cache")
                    }
                } else {
                  return EmptyItem, errors.New("could not cast cache interface to Item")
                }
            } else {
              return EmptyItem, errors.New("extension was not found in cache reference")
            }
        } else {
          return EmptyItem, errors.New("could not cast cache reference interface to Item")
        }
    }
    return EmptyItem, errors.New("reference not found in cache")
}

首先,縮排地獄讓其他的開發者很難去閱讀及理解你的程式碼,第二,讓我們的if 越來越多,追蹤到哪個return屬於哪個if的困難度呈現指數型的增加(且寫code時也很容易漏掉某個 return沒寫到),同時像這樣長又深的條件判斷也強迫讀者必須來回的拉上拉下並用腦力記憶不同的情況的邏輯,測試時也會增加難度,變得更難測試及抓到bug,因為有這麼多的if else必須一一考慮並測試到。

縮排地獄會讓讀者很累,自然的,我們希望避免這樣的情況;所以我們要怎麼做呢? 很幸運的,其實不難,第一步,我們盡量快速的返回error,相對於if else的情況,我們希望將我們的程式碼盡量保持在同一列縮排(“push our code to the left”),像是:

func GetItem(extension string) (Item, error) {
    refIface, ok := db.ReferenceCache.Get(extension)
    if !ok {
        return EmptyItem, errors.New("reference not found in cache")
    }

    ref, ok := refIface.(string)
    if !ok {
        // return cast error on reference 
    }

    itemIface, ok := db.ItemCache.Get(ref)
    if !ok {
        // return no item found in cache by reference
    }

    item, ok := itemIface.(Item)
    if !ok {
        // return cast error on item interface
    }

    if !item.Active {
        // return no item active
    }

    return Item, nil
}

當我們重構完我們的函數,我們可以開始將我們的函數分割成更小的函數了,這裡有一個基本規則: 如果value, err:= 這句在一個函數中出現超過一次以上,就表示我們可以考慮將該函數進行切割。

func GetItem(extension string) (Item, error) {
    ref, ok := getReference(extension)
    if !ok {
        return EmptyItem, ErrReferenceNotFound
    }
    return getItemByReference(ref)
}

func getReference(extension string) (string, bool) {
    refIface, ok := db.ReferenceCache.Get(extension)
    if !ok {
        return EmptyItem, false
    }
    return refIface.(string)
}

func getItemByReference(reference string) (Item, error) {
    item, ok := getItemFromCache(reference)
    if !item.Active || !ok {
        return EmptyItem, ErrItemNotFound
    }
    return Item, nil
}

func getItemFromCache(reference string) (Item, bool) {
    if itemIface, ok := db.ItemCache.Get(ref); ok {
        return EmptyItem, false
    }
    return itemIface.(Item), true
}

如前面所說,縮排地獄會讓測試變得困難,但當我們將我們上述的GetItem函數分成幾個小的輔助函數,我們將更容易的追蹤bug及測試,跟一開始版本的code不同的是它包含了許多的if敘述在函數的作用域中,新版本的GetItem只有兩個分支需要考慮,而其他的輔助函數也好讀、好消化。

注意:對於生產環境的程式碼,應該要更特意的返回error type,而不是返回bool,因為這能幫助我們理解發生的甚麼錯及在哪裡發生。上述因為只是做為例子因此返回bool。

也許你已經發現,我們重購後的GetItem函數變得比原本的code更多行,但是,code本身變的讓”人”容易閱讀多了。它用一種洋蔥式(onion-style fashion)的層層排列,我們可以忽略我們不感興趣的層而專注於我們要處理的層;這樣的程式容易閱讀多了因為我們只要一次讀跟記憶3-5行程式碼。這個例子展示了我們不應該用行數來做為乾淨的程式碼的標準,一開始的程式明顯較少行,但它屬於特意的讓它少行而非常難以閱讀。在很多的情況下,乾淨的程式碼會增加整個程式的程式碼行數,但相對於凌亂、錯綜複雜的程式碼,我們願意多一些行數。如果你還有所懷疑的話,看看下面這個例子,它與上面的程式一樣的功能且只有兩行:

func GetItemIfActive(extension string) (Item, error) {
    if refIface,ok := db.ReferenceCache.Get(extension); ok {if ref,ok := refIface.(string); ok { if itemIface,ok := db.ItemCache.Get(ref); ok { if item,ok := itemIface.(Item); ok { if item.Active { return Item,nil }}}}} return EmptyItem, errors.New("reference not found in cache")
}

函數簽章(function signature)

如前所述,一個好的函數命名結構可以讓程式好讀且易於理解該函數的作用,一個短的函數也幫助我們易於理解該函數的邏輯,而接下來我們可以用改善我們的函數簽章中輸入變數的部分來讓我們的函數更易於閱讀,這邊有另一個容易遵守的規則:每個函數應該只有1到2個輸入變數。當然這不是一個強制的規定,在某些情況下,也許可以有3個變數,但此時也許就是我們可以考慮重構的時候,就像我們的函數長度通常在5-8行,也許一開始我們會覺得這樣有點極端,但我個人使用後覺得這很對,看看以下的例子(摘錄自:RabbitMQ’s introduction tutorial to its Go library)

q, err := ch.QueueDeclare(
  "hello", // name
  false,   // durable
  false,   // delete when unused
  false,   // exclusive
  false,   // no-wait
  nil,     // arguments
)

上述的函數有6個輸入變數,靠著註解的幫助,我們大概可以理解這段code在做甚麼,然而,要依靠註解本身就是個問題,比較好的情況應該是程式碼本身就能說明,畢竟,這段code不需要註解就能執行,有些人可能不寫這些註解,那麼這段code就會變成:

q, err := ch.QueueDeclare("hello", false, false, false, false, nil)

若你在讀此段程式的時候,請問第4個跟第5個變數的false表示甚麼?大概我們要回去找QueueDeclare函數原本的定義吧,有時候懶惰一點或有自信一點,我們以為大概是甚麼而沒有回頭去看定義,因此就造成了成本高昂的錯誤或bugs,而且還是很難debug的那種。而即使記得寫註解,也有可能寫錯,例如,把第四個跟第五個input的註解寫反了…,你各位有經驗的工程師大概可以想像之後的麻煩了。尤其,當過了一段時間回來修改這個程式,或是,讀別人code的時候看到這個…。那麼,我們在golang中要怎麼做呢?對於這樣有許多input的函數,我們可以考慮使用Options struct:

type QueueOptions struct {
    Name string
    Durable bool
    DeleteOnExit bool
    Exclusive bool
    NoWait bool
    Arguments []interface{} 
}

q, err := ch.QueueDeclare(QueueOptions{
    Name: "hello",
    Durable: false,
    DeleteOnExit: false,
    Exclusive: false,
    NoWait: false,
    Arguments: nil,
})

這樣的做法解決了上述的兩個問題: 忘記註解或註解的錯誤。當然我們還是可能犯錯,例如寫錯賦值,但至少這樣的錯誤比較容易被發現及改正,同時,也解除輸入變數的順序的限制,不再需要照順序寫輸入變數,最後,這個方式因為用了struct,也讓我們的輸入變數有了預設值(default value),因為在golang,當一個struct被宣告時,它的屬性變數(properties)會被初始化為該type的預設值,換句話說,我們上面的例子可以直接這樣呼叫:

q, err := ch.QueueDeclare(QueueOptions{
    Name: "hello",
})

其他的變數則因為struct宣告時已經初始化為false(另外,Arguments變數因為為interface所以預設值是nil),這樣的方式不只是更清楚,同時也更安全,同時,在這個例子中我們還可以寫更少的code,是一個對所有這個專案的人all-win的情況。

最後一個附加說明:並不是所有情況我們都能去修改函數簽章,在上述的例子中,我們其實並不能去修改QueueDeclare的函數簽章因為它來自別人的函示庫,但我們仍可以用包裹(wrap)的方式來達成,例如以下(注意:return中的參數需要照別人函式庫定義的順序):

type RMQChannel struct {
    channel *amqp.Channel
}

func (rmqch *RMQChannel) QueueDeclare(opts QueueOptions) (Queue, error) {
    return rmqch.channel.QueueDeclare(
        opts.Name,
        opts.Durable,
        opts.DeleteOnExit,
        opts.Exclusive,
        opts.NoWait,
        opts.Arguments, 
    )
} 

基本上,我們建立了一個新的struct (RMQChannel),它包含了amqp.Channel type的變數channel,channel有QueueDeclare這個方法(method),我們可以建立自己的QueueDeclare方法,有點類似OO中的繼承再覆寫(overriding),而在我們的QueueDeclare方法中就是呼叫舊的函數。這樣的作法基本上能達成我們上述的好處同時不需修改我們使用的函示庫。

在之後我們講述interface時,我們還會使用類似的包裝函數概念。

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