這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
變數的範圍是指程式碼中可以有效使用這個變數的範圍。不要將範圍和生命期混在一起。範圍是代碼中的一塊地區,是一個編譯期的屬性;生命期是程式運行期間變數存活的時間段,在此時間段內,變數可以被程式的其它部分所引用,是運行期的概念。
文法塊是包含在花括弧內的一系列語句,例如函數體或者迴圈體。文法塊內部聲明的變數是無法被文法塊外部代碼訪問的。我們可以擴充局部文法塊的概念,在某些情境下,是不需要花括弧的,這種形式稱之為詞法塊。詞法塊分為幾種:全域詞法塊,包含所有原始碼;包 詞法塊,包含整個package;檔案詞法塊,包含整個檔案;for、if、switch語句的詞法塊;switch或select中的case分支的詞法塊;當然也包含之前提到的文法塊。
聲明語句的詞法塊決定了變數的範圍。Go語言的內建類型、內建函數、內建常量都是全域詞法塊,因此它們都是全域範圍的,例如int、len、true等,可以在整個程式直接使用;對於匯入的包,例如temconv匯入的fmt包,是檔案詞法塊,因此只能在當前檔案中訪問fmt包,這裡fmt是全檔案範圍的範圍;tempconv.CToF函數中的變數c,則是局部詞法塊(文法塊)的,因此它的範圍是函數的內部。
控制語句後面的標籤(label),例如break、continue或goto後的標籤,它們的範圍是在控制語句所在的函數內部。
一個程式可能會有多個相同的變數名,只要它們的聲明在不同的詞法塊就好。例如,你可以在函數內聲明一個局部變數x,同時再聲明一個包級的變數x,這是在函數內部,局部變數x就會替代後者,這裡稱之為shadow,意味著在函數範圍內局部變數將包變數隱藏了。
當編譯器遇到一個變數名的引用時,會去搜尋該變數的聲明語句,首先從最內部的詞法塊開始,然後直到全域詞法塊。如果編譯期找不到變數名的聲明語句,那麼就會報錯:undeclared name。如果變數名在內部的詞法塊和外部的詞法塊同時聲明,那麼根據編譯期的搜尋規則,內部的詞法塊會先找到。在這種情況下,內部的聲明會隱藏外部的聲明(shadow),此時,外部聲明的變數是無法訪問的:
func f() {}var g = "g"func main() { f := "f" fmt.Println(f) // "f"; 本地變數f隱藏了包級函數f fmt.Println(g) // "g"; 包級變數g fmt.Println(h) // compile error: undefined: h}
在函數內部,詞法塊可以嵌套任意層數,因此本地變數可以隱藏外部變數。大多數的文法塊(花括弧)是通過控制語句if、for等建立的,下面的程式有三個不同的x變數,每個變數都是聲明在不同的詞法塊中(這段代碼主要是為了說明範圍的規則,這種編碼風格並不提倡!):
func main() { x := "hello!" for i := 0; i < len(x); i++ { x := x[i] if x != '!' { x := x + 'A' - 'a' fmt.Printf("%c", x) // "HELLO" (one letter per iteration) } }}
運算式x[i]和x + 'A' - 'a' 分別引用了不同的x變數,後面會解釋。
就像之前提到的那樣,不是所有的詞法塊都有顯式的花括弧。上面的for迴圈建立了兩個詞法塊:帶花括弧的迴圈主體,顯式詞法塊;還有不帶花括弧的隱式詞法塊,例如for迴圈的條件陳述式中聲明一個變數i。這裡i的範圍包含for的條件陳述式和for的主體。
下面的例子也建立了三個變數x,每個都在不同的詞法塊中聲明,一個在函數主體中,一個在for的隱式詞法塊中,還有一個在顯式詞法塊-迴圈主體中,這其中只有1和3是顯式詞法塊:
func main() { x := "hello" for _, x := range x { x := x + 'A' - 'a' fmt.Printf("%c", x) // "HELLO" (每次迴圈一個字元) }}
就像for迴圈一樣,if語句和switch語句一樣會建立隱式詞法塊。下面的代碼在if-else鏈中說明了x和y的範圍:
if x := f(); x == 0 { fmt.Println(x)} else if y := g(x); x == y { fmt.Println(x, y)} else { fmt.Println(x, y)}fmt.Println(x, y) // compile error: x and y are not visible here
第二個if語句嵌套在第一個裡面,所以第一個if語句裡聲明的變數對第二個if語句是可見的。在switch中也有類似的規則:除了條件詞法塊外,每個case也有自己的詞法塊。
對於包級變數來說,聲明的順序和範圍是無關的,所有一個包級變數聲明時可以引用它自身也可以引用在它之後聲明的包級變數,然而,如果一個變數或者常量在聲明時引用了它自己,編譯器會報錯。
看下面的程式:
if f, err := os.Open(fname); err != nil { // compile error: unused: f return err}f.ReadByte() // compile error: undefined ff.Close() // compile error: undefined f
f的範圍僅僅是if語句,因此在if之外的詞法塊是不可訪問的,報編譯錯誤。
這裡可以更改代碼,提前聲明f變數:
f, err := os.Open(fname)if err != nil { return err}f.ReadByte()f.Close()
如果不想在外部詞法塊聲明變數,可以這麼寫:
if f, err := os.Open(fname); err != nil { return err} else { // f and err are visible here too f.ReadByte() f.Close()}
但是第三種不是Go推薦的寫法,第二種比較合適,將正常邏輯和錯誤處理分離。
短聲明變數的範圍是要特別注意的,考慮下面的程式,開始時會擷取當前的工作目錄,儲存在一個包級變數中。這個本來可以通過在main函數中調用os.Getwd來完成,但是用init函數將這塊兒邏輯從主邏輯中分離是一個更好的選擇,特別是因為擷取目錄的操作可能會是失敗的,這個時候需要處理返回的錯誤。函數log.Fatalf會列印一條資訊,然後調用os.Exit(1)終結程式:
var cwd stringfunc init() { cwd, err := os.Getwd() // compile error: unused: cwd if err != nil { log.Fatalf("os.Getwd failed: %v", err) }}
這裡cwd和err在init的詞法塊中都沒有聲明過,因此 := 語句會將它們兩聲明為本地變數。init內部的cwd聲明會隱藏外部的,因此這個程式沒有達到更新包級變數cwd的目的。
目前的版本,Go編譯器會檢測到本地變數cwd從未使用,因此會報錯,但是這種檢查並不是很嚴格,例如,如果在log.Fatalf中列印cwd的值(這時本地變數cwd會被使用),那麼這種錯誤就會被隱藏!!!
var cwd stringfunc init() { cwd, err := os.Getwd() // 注意這裡的包級cwd被本地隱藏了,但是編譯器沒有報錯! if err != nil { log.Fatalf("os.Getwd failed: %v", err) } log.Printf("Working directory = %s", cwd)}
這裡全域變數cwd沒有得到正確的初始化,同時,log函數使用了cwd本地變數,隱藏了這個bug。
有一些辦法可以處理這種潛在的錯誤,最直接的就是避免使用:=,通過var來聲明err變數:
var cwd stringfunc init() { var err error cwd, err = os.Getwd() if err != nil { log.Fatalf("os.Getwd failed: %v", err) }}
在這一章裡,我們簡單學習了包,檔案,聲明,語句等。在接下來的兩章,我們會學習資料結構。
PS. 這一章真心很難寫,足足用了3個小時。作為品質對比大家可以參見這篇文章Scope。
文章所有權:Golang隱修會 連絡人:孫飛,CTO@188.com!