gopl-zh.github.com/ch2/ch2-07.md
2015-12-28 15:59:28 +08:00

8.4 KiB
Raw Blame History

2.7. 作用域

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

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

語法塊是由花括弧所包含的一繫列語句就像函數體或循環體花括弧對應的語法塊那樣。語法塊內部聲明的名字是無法被外部語法塊訪問的。語法決定了內部聲明的名字的作用域范圍。我們可以這樣理解語法塊可以包含其他類似組批量聲明等沒有用花括弧包含的代碼我們稱之爲語法塊。有一個語法塊爲整個源代碼稱爲全局語法塊然後是每個包的包語法決每個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)
	}
}

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