Go 程式碼塊與作用域,變數遮蔽問題詳解

2023-10-15 06:00:30

Go 程式碼塊與作用域詳解

一、引入

首先我們從一個 Go 變數遮蔽(Variable Shadowing)的問題說起。

什麼是變數遮蔽呢?

變數遮蔽(Variable Shadowing)是指在程式中一個作用域內的變數名(或識別符號)隱藏(遮蔽)了外部作用域中相同名稱的變數。這會導致在遮蔽內部作用域內,無法直接存取外部作用域的變數,因為編譯器或直譯器將優先選擇內部作用域的變數,而不是外部的。

我們來看下面這段範例程式碼:

package main

import "fmt"

var x = 10 // 包級作用域的變數

func main() {
    x := 5 // 函數內的區域性變數,遮蔽了包級作用域的 x
    fmt.Println(x) // 輸出:5
}

func anotherFunction() {
    fmt.Println(x) // 在這個函數中,外部包級作用域的 x 是可見的,輸出:10
}

你可以看到,在這段程式碼中,函數main內部有一個區域性變數 x,它遮蔽了包級作用域的 x。因此,在main函數內部,通過變數 x 存取的是區域性變數,而不是外部包級作用域的變數。然而,在anotherFunction中,沒有區域性變數 x,因此外部包級作用域的 x 是可見的。

二、程式碼塊 (Block)

2.1 程式碼塊介紹

在Go語言中,程式碼塊是包裹在一對大括號{} 包圍的宣告和語句序列。

2.2 顯式程式碼塊

這些程式碼塊是你在程式碼中明確可見的,由一對大括號 {} 包圍。比如函數的函數體、for迴圈的迴圈體、以及其他控制結構內部的程式碼塊。這些程式碼塊明確定義了它們的作用域,包括變數的可見性:

func Foo() {
    // 這裡是顯式程式碼塊,包裹在函數的函數體內
    // ...

    for {
        // 這裡是顯式程式碼塊,包裹在for迴圈體內
        // 該程式碼塊也是巢狀在函數體顯式程式碼塊內部的程式碼塊
        // ...
    }

    if true {
        // 這裡是顯式程式碼塊,包裹在if語句的true分支內
        // 該程式碼塊也是巢狀在函數體顯式程式碼塊內部的程式碼塊
        // ...
    }
}

2.3 隱式程式碼塊

隱式程式碼塊沒有顯式程式碼塊那樣的肉眼可見的配對大括號包裹,我們無法通過大括號來識別隱式程式碼塊。

雖然隱式程式碼塊身著「隱身衣」,但我們也不是沒有方法來識別它,因為 Go 語言規範對現存的幾類隱式程式碼塊做了明確的定義,我們可以看下這張圖:

我們按程式碼塊範圍從大到小,逐一說明:

  • 宇宙(Universe)程式碼塊:它囊括的範圍最大,所有 Go 原始碼都在這個隱式程式碼塊中,你也可以將該隱式程式碼塊想象為在所有 Go 程式碼的最外層加一對大括號,就像圖中最外層的那對大括號那樣。
  • 包程式碼塊:在宇宙程式碼塊內部巢狀了包程式碼塊(Package Block),每個 Go 包都對應一個隱式包程式碼塊,每個包程式碼塊包含了該包中的所有 Go 原始碼,不管這些程式碼分佈在這個包裡的多少個的原始檔中。
  • 檔案程式碼塊:在包程式碼塊的內部巢狀著若干檔案程式碼塊(File Block),每個 Go 原始檔都對應著一個檔案程式碼塊,也就是說一個 Go 包如果有多個原始檔,那麼就會有多個對應的檔案程式碼塊。
  • 再下一個級別的隱式程式碼塊就在控制語句層面了,包括 ifforswitch。我們可以把每個控制語句都視為在它自己的隱式程式碼塊裡。不過你要注意,這裡的控制語句隱式程式碼塊與控制語句使用大括號包裹的顯式程式碼塊並不是一個程式碼塊。你再看一下前面的圖,switch 控制語句的隱式程式碼塊的位置是在它顯式程式碼塊的外面的。
  • 最後,位於最內層的隱式程式碼塊是 switchselect 語句的每個 case/default 子句中,雖然沒有大括號包裹,但實質上,每個子句都自成一個程式碼塊。

2.4 空程式碼塊

如果一對大括號內部沒有任何宣告或其他語句,我們就把它叫做空程式碼塊

空程式碼塊在Go語言中是有效的,並且在某些情況下可以有一定的用途,尤其是在控制結構中,如if語句、for迴圈或switch語句的特定分支。它們充當了預留位置,允許你將來新增程式碼而不需要改變程式碼的結構。

以下是一個範例,演示了空程式碼塊的使用:

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 作用域介紹

作用域的概念是針對識別符號的,不侷限於變數。每個識別符號都有自己的作用域,而一個識別符號的作用域就是指這個識別符號在被宣告後可以被有效使用的原始碼區域。

顯然,作用域是一個編譯期的概念,也就是說,編譯器在編譯過程中會對每個識別符號的作用域進行檢查,對於在識別符號作用域外使用該識別符號的行為會給出編譯錯誤的報錯。

3.2 作用域劃定原則

我們可以使用程式碼塊的概念來劃定每個識別符號的作用域。一般劃定原則就是宣告於外層程式碼塊中的識別符號,其作用域包括所有內層程式碼塊。而且,這一原則同時適於顯式程式碼塊與隱式程式碼塊。

3.3 識別符號的作用域範圍

3.3.1 預定義識別符號作用域

首先,我們來看看位於最外層的宇宙隱式程式碼塊的識別符號。這一區域是 Go 語言預定義識別符號的自留地。你可以看看下面這張表是Go 語言當前版本定義裡的所有預定義識別符號:

由於這些預定義識別符號位於包程式碼塊的外層,所以它們的作用域是範圍最大的,對於開發者而言,它們的作用域就是原始碼中的任何位置。不過,這些預定義識別符號不是關鍵字,我們同樣可以在內層程式碼塊中宣告同名的識別符號。

3.3.2 包程式碼塊級作用域

包頂層宣告中的常數、型別、變數或函數(不包括方法)對應的識別符號的作用域是包程式碼塊。

不過,對於作用域為包程式碼塊的識別符號,我需要你知道一個特殊情況。那就是當一個包 A 匯入另外一個包 B 後,包 A 僅可以使用被匯入包包 B 中的匯出識別符號(Exported Identifier)。

按照 Go 語言定義,一個識別符號要成為匯出識別符號需同時具備兩個條件:一是這個識別符號宣告在包程式碼塊中,或者它是一個欄位名或方法名;二是它名字第一個字元是一個大寫的 Unicode 字元。這兩個條件缺一不可。

// 包 A
package A

import "B"

func SomeFunction() {
    // 可以存取包 B 中的匯出識別符號
    B.ExportFunction()
}

// 這裡無法存取包 B 中的非匯出識別符號

3.3.3 檔案程式碼塊作用域(包的匯入作用域)

在Go語言中,除了大多數在包頂層宣告的識別符號具有包程式碼塊範圍的作用域外,還有一個特殊情況,即匯入的包名。匯入的包名的作用域是檔案程式碼塊範圍,這意味著它在包含它的原始碼檔案中可見,但對其他原始檔不可見。

考慮以下範例,其中一個包A有兩個原始檔,它們都依賴包B中的識別符號:

// 檔案1:source1.go

package A

import "B"

func FunctionInSource1() {
    B.SomeFunctionFromB() // 可以使用匯入的包名 B
}

// 檔案2:source2.go

package A

import "B"

func FunctionInSource2() {
    B.AnotherFunctionFromB() // 可以使用匯入的包名 B
}

在這個範例中,兩個原始檔都匯入了包B,但每個檔案內的包名 B檔案級別可見。這意味著FunctionInSource1FunctionInSource2函數都可以存取B包中的匯出識別符號(以大寫字母開頭的識別符號),但對於其他包和原始檔而言,它們不可見。

3.3.4 函數體的作用域

函數體內的識別符號的作用域被限制在函數的開始和結束之間。這意味著函數體內的區域性變數只能在函數體內部存取。

func exampleFunction() {
    var localVar = 42
    fmt.Println(localVar) // 可以存取區域性變數 localVar
}

fmt.Println(localVar) // 這裡無法存取區域性變數 localVar

3.3.5 流程控制作用域

流程控制結構,如if語句、for迴圈和switch語句,也會引入新的作用域。在這些結構中宣告的區域性變數的作用域限制在結構內部,不會洩漏到外部。

if x := 10; x > 5 {
    // x 只能在 if 語句塊記憶體取
    fmt.Println(x)
}

fmt.Println(x) // 這裡無法存取 x

在上面的範例中,變數 x 在if語句內部有一個新的區域性作用域,因此它只在if語句塊內可見。

四、避免變數遮蔽的原則

4.1 變數遮蔽的根本原因

變數是識別符號的一種,通過以上我們知道,一個變數的作用域起始於其宣告所在的程式碼塊,並且可以一直擴充套件到嵌入到該程式碼塊中的所有內層程式碼塊,而正是這樣的作用域規則,成為了滋生「變數遮蔽問題」的土壤。

變數遮蔽問題的根本原因,就是內層程式碼塊中宣告了一個與外層程式碼塊同名且同型別的變數,這樣,內層程式碼塊中的同名變數就會替代那個外層變數,參與此層程式碼塊內的相關計算,我們也就說內層變數遮蔽了外層同名變數。現在,我們先來看一下這個範例程式碼,它就存在著多種變數遮蔽的問題:

... ...
 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")
 }

這個變數遮蔽的例子還是有點複雜的,我們首先執行一下這個例子:

$go run complex.go
it is 2021
after check, it is 2020
call checkYear error: wrong year

我們可以看到,第 20 行定義的 getYear 函數返回了正確的年份 (2021),但是 checkYear 在結尾卻輸出「after check, it is 2020」,並且返回的 err 並非為 nil,這顯然是變數遮蔽的「鍋」!

根據我們前面給出的變數遮蔽的根本原因,看看上面這段程式碼究竟有幾處變數遮蔽問題(包括識別符號遮蔽問題)。

4.2 變數遮蔽問題分析

4.2.1 第一個問題:遮蔽預定義識別符號

面對上面程式碼,我們一眼就看到了位於第 18 行的 new,這本是 Go 語言的一個預定義識別符號,但上面範例程式碼呢,卻用 new 這個名字定義了一個新型別,於是 new 這個識別符號就被遮蔽了。如果這個時候你在 main 函數下方放上下面程式碼:

p := new(int)
*p = 11

你就會收到 Go 編譯器的錯誤提示:「type int is not an expression」,如果沒有意識到 new 被遮蔽掉,這個提示就會讓你不知所措。不過,在上面範例程式碼中,遮蔽 new 並不是範例未按預期輸出結果的真實原因,我們還得繼續往下看。

4.2.2 第二個問題:遮蔽包程式碼塊中的變數

你看,位於第 7 行的 switch 語句在它自身的隱式程式碼塊中,通過短變數宣告形式重新宣告了一個變數 a,這個變數 a 就遮蔽了外層包程式碼塊中的包級變數 a,這就是列印「after check, it is 2020」的原因。包級變數 a 沒有如預期那樣被 getYear 的返回值賦值為正確的年份 2021,2021 被賦值給了遮蔽它的 switch 語句隱式程式碼塊中的那個新宣告的 a。

4.2.3 第三個問題:遮蔽外層顯式程式碼塊中的變數

同樣還是第 7 行的 switch 語句,除了宣告一個新的變數 a 之外,它還宣告了一個名為 err 的變數,這個變數就遮蔽了第 4 行 checkYear 函數在顯式程式碼塊中宣告的 err 變數,這導致第 12 行的 nil 賦值動作作用到了 switch 隱式程式碼塊中的 err 變數上,而不是外層 checkYear 宣告的本地變數 err 變數上,後者並非 nil,這樣 checkYear 雖然從 getYear 得到了正確的年份值,但卻返回了一個錯誤給 main 函數,這直接導致了 main 函數列印了錯誤:「call checkYear error: wrong year」。

通過這個範例,我們也可以看到,短變數宣告與控制語句的結合十分容易導致變數遮蔽問題,並且很不容易識別,因此在日常 go 程式碼開發中你要尤其注意兩者結合使用的地方。

五、利用工具檢測變數遮蔽問題

依靠肉眼識別變數遮蔽問題終歸不是長久之計,所以Go 官方提供了 go vet 工具可以用於對 Go 原始碼做一系列靜態檢查,在 Go 1.14 版以前預設支援變數遮蔽檢查,Go 1.14 版之後,變數遮蔽檢查的外掛就需要我們單獨安裝了,安裝方法如下:

go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest

安裝成功後,我們就可以通過 go vet 掃描程式碼並檢查這裡面有沒有變數遮蔽的問題了。我們檢查一下前面的範例程式碼,看看效果怎麼樣。執行檢查的命令如下:

$go vet -vettool=$(which shadow) -strict complex.go 
./complex.go:13:12: declaration of "err" shadows declaration at line 11