Go?代碼塊作用域變量遮蔽問題解析
一、引入
首先我們從一個 Go 變量遮蔽(Variable Shadowing
)的問題說起。
什么是變量遮蔽呢?
變量遮蔽(Variable Shadowing)是指在程序中一個作用域內(nèi)的變量名(或標(biāo)識符)隱藏(遮蔽)了外部作用域中相同名稱的變量。這會導(dǎo)致在遮蔽內(nèi)部作用域內(nèi),無法直接訪問外部作用域的變量,因為編譯器或解釋器將優(yōu)先選擇內(nèi)部作用域的變量,而不是外部的。
我們來看下面這段示例代碼:
package main import "fmt" var x = 10 // 包級作用域的變量 func main() { x := 5 // 函數(shù)內(nèi)的局部變量,遮蔽了包級作用域的 x fmt.Println(x) // 輸出:5 } func anotherFunction() { fmt.Println(x) // 在這個函數(shù)中,外部包級作用域的 x 是可見的,輸出:10 }
你可以看到,在這段代碼中,函數(shù)main
內(nèi)部有一個局部變量 x
,它遮蔽了包級作用域的 x
。因此,在main
函數(shù)內(nèi)部,通過變量 x
訪問的是局部變量,而不是外部包級作用域的變量。然而,在anotherFunction
中,沒有局部變量 x
,因此外部包級作用域的 x
是可見的。
二、代碼塊 (Block)
2.1 代碼塊介紹
在Go語言中,代碼塊是包裹在一對大括號{}
包圍的聲明和語句序列。
2.2 顯式代碼塊
這些代碼塊是你在代碼中明確可見的,由一對大括號 {}
包圍。比如函數(shù)的函數(shù)體、for循環(huán)的循環(huán)體、以及其他控制結(jié)構(gòu)內(nèi)部的代碼塊。這些代碼塊明確定義了它們的作用域,包括變量的可見性:
func Foo() { // 這里是顯式代碼塊,包裹在函數(shù)的函數(shù)體內(nèi) // ... for { // 這里是顯式代碼塊,包裹在for循環(huán)體內(nèi) // 該代碼塊也是嵌套在函數(shù)體顯式代碼塊內(nèi)部的代碼塊 // ... } if true { // 這里是顯式代碼塊,包裹在if語句的true分支內(nèi) // 該代碼塊也是嵌套在函數(shù)體顯式代碼塊內(nèi)部的代碼塊 // ... } }
2.3 隱式代碼塊
隱式代碼塊沒有顯式代碼塊那樣的肉眼可見的配對大括號包裹,我們無法通過大括號來識別隱式代碼塊。
雖然隱式代碼塊身著“隱身衣”,但我們也不是沒有方法來識別它,因為 Go 語言規(guī)范對現(xiàn)存的幾類隱式代碼塊做了明確的定義,我們可以看下這張圖:
我們按代碼塊范圍從大到小,逐一說明:
- 宇宙(
Universe
)代碼塊:它囊括的范圍最大,所有 Go 源碼都在這個隱式代碼塊中,你也可以將該隱式代碼塊想象為在所有 Go 代碼的最外層加一對大括號,就像圖中最外層的那對大括號那樣。 - 包代碼塊:在宇宙代碼塊內(nèi)部嵌套了包代碼塊(Package Block),每個 Go 包都對應(yīng)一個隱式包代碼塊,每個包代碼塊包含了該包中的所有 Go 源碼,不管這些代碼分布在這個包里的多少個的源文件中。
- 文件代碼塊:在包代碼塊的內(nèi)部嵌套著若干文件代碼塊(File Block),每個 Go 源文件都對應(yīng)著一個文件代碼塊,也就是說一個 Go 包如果有多個源文件,那么就會有多個對應(yīng)的文件代碼塊。
- 再下一個級別的隱式代碼塊就在控制語句層面了,包括
if
、for
與switch
。我們可以把每個控制語句都視為在它自己的隱式代碼塊里。不過你要注意,這里的控制語句隱式代碼塊與控制語句使用大括號包裹的顯式代碼塊并不是一個代碼塊。你再看一下前面的圖,switch
控制語句的隱式代碼塊的位置是在它顯式代碼塊的外面的。 - 最后,位于最內(nèi)層的隱式代碼塊是
switch
或select
語句的每個case/default
子句中,雖然沒有大括號包裹,但實質(zhì)上,每個子句都自成一個代碼塊。
2.4 空代碼塊
如果一對大括號內(nèi)部沒有任何聲明或其他語句,我們就把它叫做空代碼塊。
空代碼塊在Go語言中是有效的,并且在某些情況下可以有一定的用途,尤其是在控制結(jié)構(gòu)中,如if語句、for循環(huán)或switch語句的特定分支。它們充當(dāng)了占位符,允許你將來添加代碼而不需要改變代碼的結(jié)構(gòu)。
以下是一個示例,演示了空代碼塊的使用:
func main() { x := 10 if x > 5 { // 非空代碼塊 fmt.Println("x 大于 5") } else { // 空代碼塊,什么都不做 } for i := 0; i < 5; i++ { // 空代碼塊,什么都不做 } }
2.5 支持嵌套代碼塊
Go 代碼塊支持嵌套,我們可以在一個代碼塊中嵌入多個層次的代碼塊,如下面示例代碼所示:
func foo() { //代碼塊1 { // 代碼塊2 { // 代碼塊3 { // 代碼塊4 } } } }
三、作用域 (Scope)
3.1 作用域介紹
作用域的概念是針對標(biāo)識符的,不局限于變量。每個標(biāo)識符都有自己的作用域,而一個標(biāo)識符的作用域就是指這個標(biāo)識符在被聲明后可以被有效使用的源碼區(qū)域。
顯然,作用域是一個編譯期的概念,也就是說,編譯器在編譯過程中會對每個標(biāo)識符的作用域進(jìn)行檢查,對于在標(biāo)識符作用域外使用該標(biāo)識符的行為會給出編譯錯誤的報錯。
3.2 作用域劃定原則
我們可以使用代碼塊的概念來劃定每個標(biāo)識符的作用域。一般劃定原則就是聲明于外層代碼塊中的標(biāo)識符,其作用域包括所有內(nèi)層代碼塊。而且,這一原則同時適于顯式代碼塊與隱式代碼塊。
3.3 標(biāo)識符的作用域范圍
3.3.1 預(yù)定義標(biāo)識符作用域
首先,我們來看看位于最外層的宇宙隱式代碼塊的標(biāo)識符。這一區(qū)域是 Go 語言預(yù)定義標(biāo)識符的自留地。你可以看看下面這張表是Go 語言當(dāng)前版本定義里的所有預(yù)定義標(biāo)識符:
由于這些預(yù)定義標(biāo)識符位于包代碼塊的外層,所以它們的作用域是范圍最大的,對于開發(fā)者而言,它們的作用域就是源代碼中的任何位置。不過,這些預(yù)定義標(biāo)識符不是關(guān)鍵字,我們同樣可以在內(nèi)層代碼塊中聲明同名的標(biāo)識符。
3.3.2 包代碼塊級作用域
包頂層聲明中的常量、類型、變量或函數(shù)(不包括方法)對應(yīng)的標(biāo)識符的作用域是包代碼塊。
不過,對于作用域為包代碼塊的標(biāo)識符,我需要你知道一個特殊情況。那就是當(dāng)一個包 A
導(dǎo)入另外一個包 B
后,包 A
僅可以使用被導(dǎo)入包包 B
中的導(dǎo)出標(biāo)識符(Exported Identifier)。
按照 Go 語言定義,一個標(biāo)識符要成為導(dǎo)出標(biāo)識符需同時具備兩個條件:一是這個標(biāo)識符聲明在包代碼塊中,或者它是一個字段名或方法名;二是它名字第一個字符是一個大寫的 Unicode 字符。這兩個條件缺一不可。
// 包 A package A import "B" func SomeFunction() { // 可以訪問包 B 中的導(dǎo)出標(biāo)識符 B.ExportFunction() } // 這里無法訪問包 B 中的非導(dǎo)出標(biāo)識符
3.3.3 文件代碼塊作用域(包的導(dǎo)入作用域)
在Go語言中,除了大多數(shù)在包頂層聲明的標(biāo)識符具有包代碼塊范圍的作用域外,還有一個特殊情況,即導(dǎo)入的包名。導(dǎo)入的包名的作用域是文件代碼塊范圍,這意味著它在包含它的源代碼文件中可見,但對其他源文件不可見。
考慮以下示例,其中一個包A有兩個源文件,它們都依賴包B中的標(biāo)識符:
// 文件1:source1.go package A import "B" func FunctionInSource1() { B.SomeFunctionFromB() // 可以使用導(dǎo)入的包名 B }
// 文件2:source2.go package A import "B" func FunctionInSource2() { B.AnotherFunctionFromB() // 可以使用導(dǎo)入的包名 B }
在這個示例中,兩個源文件都導(dǎo)入了包B,但每個文件內(nèi)的包名 B
在文件級別可見。這意味著FunctionInSource1
和FunctionInSource2
函數(shù)都可以訪問B
包中的導(dǎo)出標(biāo)識符(以大寫字母開頭的標(biāo)識符),但對于其他包和源文件而言,它們不可見。
3.3.4 函數(shù)體的作用域
函數(shù)體內(nèi)的標(biāo)識符的作用域被限制在函數(shù)的開始和結(jié)束之間。這意味著函數(shù)體內(nèi)的局部變量只能在函數(shù)體內(nèi)部訪問。
func exampleFunction() { var localVar = 42 fmt.Println(localVar) // 可以訪問局部變量 localVar } fmt.Println(localVar) // 這里無法訪問局部變量 localVar
3.3.5 流程控制作用域
流程控制結(jié)構(gòu),如if語句、for循環(huán)和switch語句,也會引入新的作用域。在這些結(jié)構(gòu)中聲明的局部變量的作用域限制在結(jié)構(gòu)內(nèi)部,不會泄漏到外部。
if x := 10; x > 5 { // x 只能在 if 語句塊內(nèi)訪問 fmt.Println(x) } fmt.Println(x) // 這里無法訪問 x
在上面的示例中,變量 x
在if語句內(nèi)部有一個新的局部作用域,因此它只在if語句塊內(nèi)可見。
四、避免變量遮蔽的原則
4.1 變量遮蔽的根本原因
變量是標(biāo)識符的一種,通過以上我們知道,一個變量的作用域起始于其聲明所在的代碼塊,并且可以一直擴(kuò)展到嵌入到該代碼塊中的所有內(nèi)層代碼塊,而正是這樣的作用域規(guī)則,成為了滋生“變量遮蔽問題”的土壤。
變量遮蔽問題的根本原因,就是內(nèi)層代碼塊中聲明了一個與外層代碼塊同名且同類型的變量,這樣,內(nèi)層代碼塊中的同名變量就會替代那個外層變量,參與此層代碼塊內(nèi)的相關(guān)計算,我們也就說內(nèi)層變量遮蔽了外層同名變量?,F(xiàn)在,我們先來看一下這個示例代碼,它就存在著多種變量遮蔽的問題:
... ... var a int = 2020 func checkYear() error { err := errors.New("wrong year") switch a, err := getYear(); a { case 2020: fmt.Println("it is", a, err) case 2021: fmt.Println("it is", a) err = nil } fmt.Println("after check, it is", a) return err } type new int func getYear() (new, error) { var b int16 = 2021 return new(b), nil } func main() { err := checkYear() if err != nil { fmt.Println("call checkYear error:", err) return } fmt.Println("call checkYear ok") }
這個變量遮蔽的例子還是有點復(fù)雜的,我們首先運行一下這個例子:
$go run complex.go it is 2021 after check, it is 2020 call checkYear error: wrong year
我們可以看到,第 20 行定義的 getYear 函數(shù)返回了正確的年份 (2021),但是 checkYear 在結(jié)尾卻輸出“after check, it is 2020”,并且返回的 err 并非為 nil,這顯然是變量遮蔽的“鍋”!
根據(jù)我們前面給出的變量遮蔽的根本原因,看看上面這段代碼究竟有幾處變量遮蔽問題(包括標(biāo)識符遮蔽問題)。
4.2 變量遮蔽問題分析
4.2.1 第一個問題:遮蔽預(yù)定義標(biāo)識符
面對上面代碼,我們一眼就看到了位于第 18 行的 new,這本是 Go 語言的一個預(yù)定義標(biāo)識符,但上面示例代碼呢,卻用 new 這個名字定義了一個新類型,于是 new 這個標(biāo)識符就被遮蔽了。如果這個時候你在 main 函數(shù)下方放上下面代碼:
p := new(int) *p = 11
你就會收到 Go 編譯器的錯誤提示:“type int is not an expression
”,如果沒有意識到 new
被遮蔽掉,這個提示就會讓你不知所措。不過,在上面示例代碼中,遮蔽 new
并不是示例未按預(yù)期輸出結(jié)果的真實原因,我們還得繼續(xù)往下看。
4.2.2 第二個問題:遮蔽包代碼塊中的變量
你看,位于第 7 行的 switch 語句在它自身的隱式代碼塊中,通過短變量聲明形式重新聲明了一個變量 a,這個變量 a 就遮蔽了外層包代碼塊中的包級變量 a,這就是打印“after check, it is 2020
”的原因。包級變量 a 沒有如預(yù)期那樣被 getYear
的返回值賦值為正確的年份 2021,2021 被賦值給了遮蔽它的 switch
語句隱式代碼塊中的那個新聲明的 a。
4.2.3 第三個問題:遮蔽外層顯式代碼塊中的變量
同樣還是第 7 行的 switch
語句,除了聲明一個新的變量 a 之外,它還聲明了一個名為 err
的變量,這個變量就遮蔽了第 4 行 checkYear 函數(shù)在顯式代碼塊中聲明的 err
變量,這導(dǎo)致第 12 行的 nil 賦值動作作用到了 switch
隱式代碼塊中的 err 變量上,而不是外層 checkYear
聲明的本地變量 err 變量上,后者并非 nil,這樣 checkYear
雖然從 getYear 得到了正確的年份值,但卻返回了一個錯誤給 main 函數(shù),這直接導(dǎo)致了 main 函數(shù)打印了錯誤:“call checkYear error: wrong year
”。
通過這個示例,我們也可以看到,短變量聲明與控制語句的結(jié)合十分容易導(dǎo)致變量遮蔽問題,并且很不容易識別,因此在日常 go 代碼開發(fā)中你要尤其注意兩者結(jié)合使用的地方。
五、利用工具檢測變量遮蔽問題
依靠肉眼識別變量遮蔽問題終歸不是長久之計,所以Go 官方提供了 go
vet
工具可以用于對 Go 源碼做一系列靜態(tài)檢查,在 Go 1.14 版以前默認(rèn)支持變量遮蔽檢查,Go 1.14 版之后,變量遮蔽檢查的插件就需要我們單獨安裝了,安裝方法如下:
go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest
安裝成功后,我們就可以通過 go vet 掃描代碼并檢查這里面有沒有變量遮蔽的問題了。我們檢查一下前面的示例代碼,看看效果怎么樣。執(zhí)行檢查的命令如下:
$go vet -vettool=$(which shadow) -strict complex.go ./complex.go:13:12: declaration of "err" shadows declaration at line 11
以上就是Go 代碼塊作用域變量遮蔽問題解析的詳細(xì)內(nèi)容,更多關(guān)于Go作用域變量遮蔽的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
golang基于errgroup實現(xiàn)并發(fā)調(diào)用的方法
這篇文章主要介紹了golang基于errgroup實現(xiàn)并發(fā)調(diào)用,本文通過實例代碼給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2022-09-09Golang打印復(fù)雜結(jié)構(gòu)體兩種方法詳解
在?Golang?語言開發(fā)中,我們經(jīng)常會使用結(jié)構(gòu)體類型,如果我們使用的結(jié)構(gòu)體類型的變量包含指針類型的字段,我們在記錄日志的時候,指針類型的字段的值是指針地址,將會給我們?debug?代碼造成不便2022-10-10使用Golang快速構(gòu)建出命令行應(yīng)用程序
在日常開發(fā)中,大家對命令行工具(CLI)想必特別熟悉了,如果說你不知道命令工具,那你可能是個假開發(fā)。每天都會使用大量的命令行工具,例如最常用的Git、Go、Docker等,這篇文章主要介紹了使用Golang快速構(gòu)建出命令行應(yīng)用程序,需要的朋友可以參考下2023-02-02Golang學(xué)習(xí)之反射機(jī)制的用法詳解
反射的本質(zhì)就是在程序運行的時候,獲取對象的類型信息和內(nèi)存結(jié)語構(gòu),反射是把雙刃劍,功能強大但可讀性差。本文將詳細(xì)講講Golang中的反射機(jī)制,感興趣的可以了解一下2022-06-06