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

變數作用域(Variable Scope)

接著,讓我們回頭來看看關於寫較短的函數,這個方式其實還有另一個好的副作用: 寫短的函數通常能消除依賴會洩漏到全域(global scope)的可變變數的情況,以下說明。

全域變數通常是有問題的且不屬於乾淨的程式碼;主要是它讓工程師難以確認它目前的狀態、值,如果一個變數是全域的且是可變的,照定義,它的值在整個應用程式的任何地方都可被改變,我們無法保證它在某個地方一定是某個值…這樣對於debug或是讀懂code都會有麻煩,同時也是一個當系統變大時會更加惡化的問題。

讓我們來看看一個簡短的例子,一個非全域但作用域很大的變數可以造成甚麼問題,這個例子同時也展示了名為變數隱藏(variable shadowing)的情況,請看下面這段從Golang scope issue文章中取得的例子:

func doComplex() (string, error) {
    return "Success", nil
}

func main() {
    var val string
    num := 32

    switch num {
    case 16:
    // do nothing
    case 32:
        val, err := doComplex()
        if err != nil {
            panic(err)
        }
        if val == "" {
            // do something else
        }
    case 64:
        // do nothing
    }

    fmt.Println(val)
}

這段code有甚麼問題嗎? 看起來變數 val 最後被印出來的值應該是Success,對嗎? 但其實並不是,原因是中間這句:

val, err := doComplex()

這句敘述在case 32: 的域(scope),它定義了val 跟err,因此這邊定義的val跟 main函數第一行中定義的var val string無關; doComplex函數賦值給val Success是在 case 32: 的作用域,而沒有賦值給main的val變數;當然,這本來就是golang的設計,但這題還有其他更糟的問題,這邊宣告”var val string”使val為一個可變、作用域大的變數是完全沒必要的,我們簡單重構一下就可以避免這個問題:

func getStringResult(num int) (string, error) {
    switch num {
    case 16:
    // do nothing
    case 32:
       return doComplex()
    case 64:
        // do nothing
    }
    return "", nil
}

func main() {
    val, err := getStringResult(32)
    if err != nil {
        panic(err)
    }
    if val == "" {
        // do something else
    }
    fmt.Println(val)
}

經過這樣的修改,val不會被重複定義,其作用域也減小了,每個函數也比較短(觀察重構前後main函數的改變),這是一個很小的例子,想像一下當類似前面例子重構前的寫程式風格在一個大型、複雜的專案時,val印出來的值這樣的bug會有多麼難發現,我們當然不希望這樣的事情發生,一方面我們不喜歡bug,另一方面,這樣同時也很不尊重我們同事及我們自己,造成我們浪費很多時間在除這樣的蟲,我們可以藉由良好的寫程式風格避免這樣的問題,而不是怪golang為什麼沒有處理這樣的情況。

另外一點,例子中//do something else 這裡面可以是另一個改變val的值的部分,我們應該另外寫成另一個自處包含(logic self contained)的函數,如下面的修改,這樣的做法能持續讓val的作用域一直維持小小的,而返回一個值給val。

func getVal(num int) (string, error) {
    val, err := getStringResult(num)
    if err != nil {
        return "", err
    }
    if val == "" {
        return NewValue() // pretend function
    }
}

func main() {
    val, err := getVal(32)
    if err != nil {
        panic(err)
    }
    fmt.Println(val)
}

變數宣告(Variable Declaration)

除了注意變數的作用域及可變性,我們也可以藉著”盡量在接近使用變數的地方才宣告”這樣的習慣,來改善我們的程式可讀性,這跟在C (C++)語言中的習慣不太一樣,我們通常在C語言的程式會看到如下例子的宣告風格:

func main() {
  var err error
  var items []Item
  var sender, receiver chan Item
  
  items = store.GetItems()
  sender = make(chan Item)
  receiver = make(chan Item)
  
  for _, item := range items {
    ...
  }
}

我們習慣將所有變數在一開頭進行宣告,並都在那裏宣告,但這樣的做法會碰到跟前面例子一樣的問題:變數的作用域變大,即使後面沒有再重覆定義變數或重新賦值,讀程式碼的人也需要重頭到尾保持警戒的注意,很累,我們的大腦有短期記憶量的限制,若要一直注意某些變數是否有改變、是否有被重新定義會讓讀程式碼更加困難、也更難去理解整段程式的邏輯,要在腦中模擬最後的返回值、變數值,當變數變多、程式變大,真的非常辛苦。因此,為了讓未來的自己及要讀我們寫的code的工程師簡單一點,建議如下的例子的方式,我們在要使用變數的時候再宣告:

func main() {
	var sender chan Item
	sender = make(chan Item)

	go func() {
		for {
			select {
			case item := <-sender:
				// do something
			}
		}
	}()
}

這樣還不夠好,我們可以在宣告後直接呼叫函數,這樣讓我們很清楚這個變數跟這個函數的邏輯的關係:

func main() {
  sender := func() chan Item {
    channel := make(chan Item)
    go func() {
      for {
        select { ... }
      }
    }()
    return channel
  }
}

如果匿名函數增加閱讀的難度,那我們可以稍微修改一下成為一般的函數:

func main() {
  sender := NewSenderChannel()
}

func NewSenderChannel() chan Item {
  channel := make(chan Item)
  go func() {
    for {
      select { ... }
    }
  }()
  return channel
}

這樣修改後,我們宣告了一個變數,也知道這個變數的處理邏輯在NewSenderChannel(),這個函數會返回一個channel給sender。這段code讀起來就會比一開始的例子容易讀;但是,當然sender還是一個可變的變數,後面也有可能被重覆定義,但是在golang這可能無法簡單解決,畢竟golang無法宣告const struct 或是 static 變數,我們只能在自己寫程式的時候注意不要去修改或重覆定義前面的變數。

golang中存在 const 修飾字,但只能用在 primitive type (例如int, bool etc..)

一個變通的辦法是限制一個變數的可變性在一個package中,利用建立一個struct並將變數包在裡面作為私有變數(private property),則這個私有變數只能藉由這個struct的其他方法(methods)來取得及修改,以上面的channel例子來說,類似如下這樣:

type Sender struct {
  sender chan Item
}

func NewSender() *Sender {
  return &Sender{
    sender: NewSenderChannel(),
  }
}

func (s *Sender) Send(item Item) {
  s.sender <- item
}

經過這樣的修改,程式無法在package外部去變動Sender struct裡面sender變數的值,sender不會被重新賦值或重覆定義了,因此也就避免了前面所講的那種很難debug的情況(粗心而重覆定義或不小心修改到賦值);這樣的做法感覺起來有點麻煩,但考慮到能避免的問題,這是值得的。

func main() {
  sender := NewSender()
  sender.Send(&Item{})
}

我們看看上面的例子,是不是很簡短而清楚呢? 我們將細節隱藏在package函數中,使用者及程式維護者仍可以很清楚的使用及理解我們的package,當我們初始化及使用Sender struct,我們可以不花心思在它的實作(implementation)上。這樣是一個比較鬆耦合的結構,我們減少了使用者對它的接觸點(會修改或使用的方式),而且既然我們的使用者不在意它的實作,我們可以比較自由的修改它,譬如,我們不想再用channel 來實作Sender,我們可以修改我們的package,對使用者沒有任何影響,不需修改使用者的程式碼也不會有問題(假設我們的修改仍保持方法的簽章(function signature)不變)。

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