2.7. 作用域

一個聲明語句將程序中的實體和一個名字關聯,比如一個函數或一個變量。聲明語句的作用域是指源代碼中可以有效使用這個名字的範圍。

不要將作用域和生命週期混為一談。聲明語句的作用域對應的是一個源代碼的文本區域;它是一個編譯時的屬性。一個變量的生命週期是指程序運行時變量存在的有效時間段,在此時間區域內它可以被程序的其他部分引用;是一個運行時的概念。

句法塊是由花括弧所包含的一系列語句,就像函數體或循環體花括弧包裹的內容一樣。句法塊內部聲明的名字是無法被外部塊訪問的。這個塊決定了內部聲明的名字的作用域範圍。我們可以把塊(block)的概念推廣到包括其他聲明的群組,這些聲明在代碼中並未顯式地使用花括號包裹起來,我們稱之為詞法塊。對全局的源代碼來說,存在一個整體的詞法塊,稱為全局詞法塊;對於每個包;每個for、if和switch語句,也都有對應詞法塊;每個switch或select的分支也有獨立的詞法塊;當然也包括顯式書寫的詞法塊(花括弧包含的語句)。

聲明語句對應的詞法域決定了作用域範圍的大小。對於內置的類型、函數和常量,比如int、len和true等是在全局作用域的,因此可以在整個程序中直接使用。任何在函數外部(也就是包級語法域)聲明的名字可以在同一個包的任何源文件中訪問的。對於導入的包,例如tempconv導入的fmt包,則是對應源文件級的作用域,因此只能在當前的文件中訪問導入的fmt包,當前包的其它源文件無法訪問在當前源文件導入的包。還有許多聲明語句,比如tempconv.CToF函數中的變量c,則是局部作用域的,它只能在函數內部(甚至只能是局部的某些部分)訪問。

控制流標號,就是break、continue或goto語句後面跟著的那種標號,則是函數級的作用域。

一個程序可能包含多個同名的聲明,只要它們在不同的詞法域就沒有關係。例如,你可以聲明一個局部變量,和包級的變量同名。或者是像2.3.3節的例子那樣,你可以將一個函數參數的名字聲明為new,雖然內置的new是全局作用域的。但是物極必反,如果濫用不同詞法域可重名的特性的話,可能導致程序很難閱讀。

當編譯器遇到一個名字引用時,它會對其定義進行查找,查找過程從最內層的詞法域向全局的作用域進行。如果查找失敗,則報告“未聲明的名字”這樣的錯誤。如果該名字在內部和外部的塊分別聲明過,則內部塊的聲明首先被找到。在這種情況下,內部聲明屏蔽了外部同名的聲明,讓外部的聲明的名字無法被訪問:

func f() {}

var g = "g"

func main() {
    f := "f"
    fmt.Println(f) // "f"; local var f shadows package-level func f
    fmt.Println(g) // "g"; package-level var
    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變量,稍後我們會解釋這個。(注意,後面的表達式與unicode.ToUpper並不等價。)

正如上面例子所示,並不是所有的詞法域都顯式地對應到由花括弧包含的語句;還有一些隱含的規則。上面的for語句創建了兩個詞法域:花括弧包含的是顯式的部分,是for的循環體部分詞法域,另外一個隱式的部分則是循環的初始化部分,比如用於迭代變量i的初始化。隱式的詞法域部分的作用域還包含條件測試部分和循環後的迭代部分(i++),當然也包含循環體詞法域。

下面的例子同樣有三個不同的x變量,每個聲明在不同的詞法域,一個在函數體詞法域,一個在for隱式的初始化詞法域,一個在for循環體詞法域;只有兩個塊是顯式創建的:

func main() {
    x := "hello"
    for _, x := range x {
        x := x + 'A' - 'a'
        fmt.Printf("%c", x) // "HELLO" (one letter per iteration)
    }
}

和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語句的每個分支也有類似的詞法域規則:條件部分為一個隱式詞法域,然後是每個分支的詞法域。

在包級別,聲明的順序並不會影響作用域範圍,因此一個先聲明的可以引用它自身或者是引用後面的一個聲明,這可以讓我們定義一些相互嵌套或遞歸的類型或函數。但是如果一個變量或常量遞歸引用了自身,則會產生編譯錯誤。

在這個程序中:

if f, err := os.Open(fname); err != nil { // compile error: unused: f
    return err
}
f.ReadByte() // compile error: undefined f
f.Close()    // compile error: undefined f

變量f的作用域只在if語句內,因此後面的語句將無法引入它,這將導致編譯錯誤。你可能會收到一個局部變量f沒有聲明的錯誤提示,具體錯誤信息依賴編譯器的實現。

通常需要在if之前聲明變量,這樣可以確保後面的語句依然可以訪問變量:

f, err := os.Open(fname)
if err != nil {
    return err
}
f.ReadByte()
f.Close()

你可能會考慮通過將ReadByte和Close移動到if的else塊來解決這個問題:

if f, err := os.Open(fname); err != nil {
    return err
} else {
    // f and err are visible here too
    f.ReadByte()
    f.Close()
}

但這不是Go語言推薦的做法,Go語言的習慣是在if中處理錯誤然後直接返回,這樣可以確保正常執行的語句不需要代碼縮進。

要特別注意短變量聲明語句的作用域範圍,考慮下面的程序,它的目的是獲取當前的工作目錄然後保存到一個包級的變量中。這本來可以通過直接調用os.Getwd完成,但是將這個從主邏輯中分離出來可能會更好,特別是在需要處理錯誤的時候。函數log.Fatalf用於打印日誌信息,然後調用os.Exit(1)終止程序。

var cwd string

func init() {
    cwd, err := os.Getwd() // compile error: unused: cwd
    if err != nil {
        log.Fatalf("os.Getwd failed: %v", err)
    }
}

雖然cwd在外部已經聲明過,但是:=語句還是將cwd和err重新聲明為新的局部變量。因為內部聲明的cwd將屏蔽外部的聲明,因此上面的代碼並不會正確更新包級聲明的cwd變量。

由於當前的編譯器會檢測到局部聲明的cwd並沒有使用,然後報告這可能是一個錯誤,但是這種檢測並不可靠。因為一些小的代碼變更,例如增加一個局部cwd的打印語句,就可能導致這種檢測失效。

var cwd string

func init() {
    cwd, err := os.Getwd() // NOTE: wrong!
    if err != nil {
        log.Fatalf("os.Getwd failed: %v", err)
    }
    log.Printf("Working directory = %s", cwd)
}

全局的cwd變量依然是沒有被正確初始化的,而且看似正常的日誌輸出更是讓這個BUG更加隱晦。

有許多方式可以避免出現類似潛在的問題。最直接的方法是通過單獨聲明err變量,來避免使用:=的簡短聲明方式:

var cwd string

func init() {
    var err error
    cwd, err = os.Getwd()
    if err != nil {
        log.Fatalf("os.Getwd failed: %v", err)
    }
}

我們已經看到包、文件、聲明和語句如何來表達一個程序結構。在下面的兩個章節,我們將探討數據的結構。