Go語言核心36講(Go語言基礎知識五)–學習筆記

05 | 程序實體的那些事兒(中)

在前文中,我解釋過代碼塊的含義。Go 語言的代碼塊是一層套一層的,就像大圓套小圓。

一個代碼塊可以有若干個子代碼塊;但對於每個代碼塊,最多只會有一個直接包含它的代碼塊(後者可以簡稱為前者的外層代碼塊)。

這種代碼塊的劃分,也間接地決定了程序實體的作用域。我們今天就來看看它們之間的關係。

我先說說作用域是什麼?大家都知道,一個程序實體被創造出來,是為了讓別的代碼引用的。那麼,哪裡的代碼可以引用它呢,這就涉及了它的作用域。

我在前面說過,程序實體的訪問權限有三種:包級私有的、模塊級私有的和公開的。這其實就是 Go 語言在語言層面,依據代碼塊對程序實體作用域進行的定義。

包級私有和模塊級私有訪問權限對應的都是代碼包代碼塊,公開的訪問權限對應的是全域代碼塊。然而,這個顆粒度是比較粗的,我們往往需要利用代碼塊再細化程序實體的作用域。

比如,我在一個函數中聲明了一個變量,那麼在通常情況下,這個變量是無法被這個函數以外的代碼引用的。這裡的函數就是一個代碼塊,而變量的作用域被限制在了該代碼塊中。當然了,還有例外的情況,這部分內容,我留到講函數的時候再說。

總之,請記住,一個程序實體的作用域總是會被限制在某個代碼塊中,而這個作用域最大的用處,就是對程序實體的訪問權限的控制。對「高內聚,低耦合」這種程序設計思想的實踐,恰恰可以從這裡開始。

今天的問題是:如果一個變量與其外層代碼塊中的變量重名會出現什麼狀況?

我把此題的代碼存到了 demo10.go 文件中了。你可以在「Golang_Puzzlers」項目的puzzlers/article5/q1包中找到它。

package main

import "fmt"

var block = "package"

func main() {
  block := "function"
  {
    block := "inner"
    fmt.Printf("The block is %s.\n", block)
  }
  fmt.Printf("The block is %s.\n", block)
}

這個命令源碼文件中有四個代碼塊,它們是:全域代碼塊、main包代表的代碼塊、main函數代表的代碼塊,以及在main函數中的一個用花括號包起來的代碼塊。

我在後三個代碼塊中分別聲明了一個名為block的變量,並分別把字符串值”package”、”function”和”inner”賦給了它們。此外,我在後兩個代碼塊的最後分別嘗試用fmt.Printf函數打印出「The block is %s.」。這裡的「%s」只是為了佔位,程序會用block變量的實際值替換掉。

具體的問題是:該源碼文件中的代碼能通過編譯嗎?如果不能,原因是什麼?如果能,運行它後會打印出什麼內容?

典型回答

能通過編譯。運行後打印出的內容是:

The block is inner.
The block is function.

問題解析

初看這道題,你可能會認為它無法通過編譯,因為三處代碼都聲明了相同名稱的變量。的確,聲明重名的變量是無法通過編譯的,用短變量聲明對已有變量進行重聲明除外,但這只是對於同一個代碼塊而言的。

對於不同的代碼塊來說,其中的變量重名沒什麼大不了,照樣可以通過編譯。即使這些代碼塊有直接的嵌套關係也是如此,就像 demo10.go 中的main包代碼塊、main函數代碼塊和那個最內層的代碼塊那樣。

這樣規定顯然很方便也很合理,否則我們會每天為了選擇變量名而煩惱。但是這會導致另外一個問題,我引用變量時到底用的是哪一個?這也是這道題的第二個考點。

這其實有一個很有畫面感的查找過程。這個查找過程不只針對於變量,還適用於任何程序實體。如下面所示。

  • 首先,代碼引用變量的時候總會最優先查找當前代碼塊中的那個變量。注意,這裡的「當前代碼塊」僅僅是引用變量的代碼所在的那個代碼塊,並不包含任何子代碼塊。
  • 其次,如果當前代碼塊中沒有聲明以此為名的變量,那麼程序會沿着代碼塊的嵌套關係,從直接包含當前代碼塊的那個代碼塊開始,一層一層地查找。
  • 一般情況下,程序會一直查到當前代碼包代表的代碼塊。如果仍然找不到,那麼 Go 語言的編譯器就會報錯了。

好了,當你明白了上述過程之後,再去看 demo10.go 中的代碼。是不是感覺清晰了很多?

從作用域的角度也可以說,雖然通過var block = “package”聲明的變量作用域是整個main代碼包,但是在main函數中,它卻被那兩個同名的變量「屏蔽」了。

相似的,雖然main函數首先聲明的block的作用域,是整個main函數,但是在最內層的那個代碼塊中,它卻是不可能被引用到的。反過來講,最內層代碼塊中的block也不可能被該塊之外的代碼引用到,這也是打印內容的第二行是「The block is function.」的另一半原因。

你現在應該知道了,這道題看似簡單,但是它考察以及可延展的範圍並不窄。

知識擴展

不同代碼塊中的重名變量與變量重聲明中的變量區別到底在哪兒?

為了方便描述,我就把不同代碼塊中的重名變量叫做「可重名變量」吧。注意,在同一個代碼塊中不允許出現重名的變量,這違背了 Go 語言的語法。關於這兩者的表象和機理,我們已經討論得足夠充分了。你現在可以說出幾條區別?請想一想,然後再看下面的列表。

  • 變量重聲明中的變量一定是在某一個代碼塊內的。注意,這裡的「某一個代碼塊內」並不包含它的任何子代碼塊,否則就變成了「多個代碼塊之間」。而可重名變量指的正是在多個代碼塊之間由相同的標識符代表的變量。
  • 變量重聲明是對同一個變量的多次聲明,這裡的變量只有一個。而可重名變量中涉及的變量肯定是有多個的。
  • 不論對變量重聲明多少次,其類型必須始終一致,具體遵從它第一次被聲明時給定的類型。而可重名變量之間不存在類似的限制,它們的類型可以是任意的。
  • 如果可重名變量所在的代碼塊之間,存在直接或間接的嵌套關係,那麼它們之間一定會存在「屏蔽」的現象。但是這種現象絕對不會在變量重聲明的場景下出現。

image

以上 4 大區別中的第 3 條需要你再注意一下。既然可重名變量的類型可以是任意的,那麼當它們之間存在「屏蔽」時你就更需要注意了。

不同類型的值大都有着不同的特性和用法。當你在某一種類型的值上施加只有在其他類型值上才能做的操作時,Go 語言編譯器一定會告訴你:「這不可以」。

具體到不同類型的可重名變量的問題上,讓我們先來看一下puzzlers/article5/q2包中的源碼文件 demo11.go。它是一個很典型的例子。

package main

import "fmt"

var container = []string{"zero", "one", "two"}

func main() {
  container := map[int]string{0: "zero", 1: "one", 2: "two"}
  fmt.Printf("The element is %q.\n", container[1])
}

在 demo11.go 中,有兩個都叫做container的變量,分別位於main包代碼塊和main函數代碼塊。main包代碼塊中的變量是切片(slice)類型的,另一個是字典(map)類型的。在main函數的最後,我試圖打印出container變量的值中索引為1的那個元素。

如果你熟悉這兩個類型肯定會知道,在它們的值上我們都可以施加索引表達式,比如container[0]。只要中括號里的整數在有效範圍之內(這裡是[0, 2]),它就可以把值中的某一個元素取出來。

如果container的類型不是數組、切片或字典類型,那麼索引表達式就會引發編譯錯誤。這正是利用 Go 語言語法,幫我們約束程序的一個例子;但是當我們想知道 container 確切類型的時候,利用索引表達式的方式就不夠了。

總結

我們先討論了代碼塊,並且也談到了它與程序實體的作用域,以及訪問權限控制之間的巧妙關係。Go 語言本身對程序實體提供了相對粗粒度的訪問控制。但我們自己可以利用代碼塊和作用域精細化控制它們。

如果在具有嵌套關係的不同代碼塊中存在重名的變量,那麼我們應該特別小心,它們之間可能會發生「屏蔽」的現象。這樣你在不同代碼塊中引用到變量很可能是不同的。具體的鑒別方式需要參考 Go 語言查找(代表了程序實體的)標識符的過程。

另外,請記住變量重聲明與可重名變量之間的區別以及它們的重要特徵。其中最容易產生隱晦問題的一點是,可重名變量可以各有各的類型。這時候我們往往應該在真正使用它們之前先對其類型進行檢查。利用 Go 語言的語法、規範和命令做輔助的檢查是很好的辦法,但有些時候並不充分。

思考題

我們在討論 Go 語言查找標識符時的範圍的時候,提到過import . XXX這種導入代碼包的方式。這裡有個思考題:

如果通過這種方式導入的代碼包中的變量與當前代碼包中的變量重名了,那麼 Go 語言是會把它們當做「可重名變量」看待還是會報錯呢?

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發佈,但務必保留文章署名 鄭子銘 (包含鏈接: //www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可發佈。