# 第二章 程序結構
Go語言和其他編程語言一樣,一個大的程序是由很多小的基礎構件組成的。變量保存值,簡單的加法和減法運算被組合成較復雜的表達式。基礎類型被聚合為數組或結構體等更復雜的數據結構。然后使用if和for之類的控制語句來組織和控制表達式的執行流程。然后多個語句被組織到一個個函數中,以便代碼的隔離和復用。函數以源文件和包的方式被組織。
我們已經在前面章節的例子中看到了很多例子。在本章中,我們將深入討論Go程序基礎結構方面的一些細節。每個示例程序都是刻意寫的簡單,這樣我們可以減少復雜的算法或數據結構等不相關的問題帶來的干擾,從而可以專注于Go語言本身的學習。
### 2.1. 命名
Go語言中的函數名、變量名、常量名、類型名、語句標號和包名等所有的命名,都遵循一個簡單的命名規則:一個名字必須以一個字母(Unicode字母)或下劃線開頭,后面可以跟任意數量的字母、數字或下劃線。大寫字母和小寫字母是不同的:heapSort和Heapsort是兩個不同的名字。
Go語言中類似if和switch的關鍵字有25個;關鍵字不能用于自定義名字,只能在特定語法結構中使用。
~~~
break default func interface select
case defer go map struct
chan else goto package switch
const fallthrough if range type
continue for import return var
~~~
此外,還有大約30多個預定義的名字,比如int和true等,主要對應內建的常量、類型和函數。
~~~
內建常量: true false iota nil
內建類型: int int8 int16 int32 int64
uint uint8 uint16 uint32 uint64 uintptr
float32 float64 complex128 complex64
bool byte rune string error
內建函數: make len cap new append copy close delete
complex real imag
panic recover
~~~
這些內部預先定義的名字并不是關鍵字,你可以再定義中重新使用它們。在一些特殊的場景中重新定義它們也是有意義的,但是也要注意避免過度而引起語義混亂。
如果一個名字是在函數內部定義,那么它的就只在函數內部有效。如果是在函數外部定義,那么將在當前包的所有文件中都可以訪問。名字的開頭字母的大小寫決定了名字在包外的可見性。如果一個名字是大寫字母開頭的(譯注:必須是在函數外部定義的包級名字;包級函數名本身也是包級名字),那么它將是導出的,也就是說可以被外部的包訪問,例如fmt包的Printf函數就是導出的,可以在fmt包外部訪問。包本身的名字一般總是用小寫字母。
名字的長度沒有邏輯限制,但是Go語言的風格是盡量使用短小的名字,對于局部變量尤其是這樣;你會經常看到i之類的短名字,而不是冗長的theLoopIndex命名。通常來說,如果一個名字的作用域比較大,生命周期也比較長,那么用長的名字將會更有意義。
在習慣上,Go語言程序員推薦使用 **駝峰式** 命名,當名字有幾個單詞組成的時優先使用大小寫分隔,而不是優先用下劃線分隔。因此,在標準庫有QuoteRuneToASCII和parseRequestLine這樣的函數命名,但是一般不會用quote_rune_to_ASCII和parse_request_line這樣的命名。而像ASCII和HTML這樣的縮略詞則避免使用大小寫混合的寫法,它們可能被稱為htmlEscape、HTMLEscape或escapeHTML,但不會是escapeHtml。
### 2.2. 聲明
聲明語句定義了程序的各種實體對象以及部分或全部的屬性。Go語言主要有四種類型的聲明語句:var、const、type和func,分別對應變量、常量、類型和函數實體對象的聲明。這一章我們重點討論變量和類型的聲明,第三章將討論常量的聲明,第五章將討論函數的聲明。
一個Go語言編寫的程序對應一個或多個以.go為文件后綴名的源文件中。每個源文件以包的聲明語句開始,說明該源文件是屬于哪個包。包聲明語句之后是import語句導入依賴的其它包,然后是包一級的類型、變量、常量、函數的聲明語句,包一級的各種類型的聲明語句的順序無關緊要(譯注:函數內部的名字則必須先聲明之后才能使用)。例如,下面的例子中聲明了一個常量、一個函數和兩個變量:
*gopl.io/ch2/boiling*
~~~
// Boiling prints the boiling point of water.
package main
import "fmt"
const boilingF = 212.0
func main() {
var f = boilingF
var c = (f - 32) * 5 / 9
fmt.Printf("boiling point = %g°F or %g°C\n", f, c)
// Output:
// boiling point = 212°F or 100°C
}
~~~
其中常量boilingF是在包一級范圍聲明語句聲明的,然后f和c兩個變量是在main函數內部聲明的聲明語句聲明的。在包一級聲明語句聲明的名字可在整個包對應的每個源文件中訪問,而不是僅僅在其聲明語句所在的源文件中訪問。相比之下,局部聲明的名字就只能在函數內部很小的范圍被訪問。
一個函數的聲明由一個函數名字、參數列表(由函數的調用者提供參數變量的具體值)、一個可選的返回值列表和包含函數定義的函數體組成。如果函數沒有返回值,那么返回值列表是省略的。執行函數從函數的第一個語句開始,依次順序執行直到遇到renturn返回語句,如果沒有返回語句則是執行到函數末尾,然后返回到函數調用者。
我們已經看到過很多函數聲明和函數調用的例子了,在第五章將深入討論函數的相關細節,這里只簡單解釋下。下面的fToC函數封裝了溫度轉換的處理邏輯,這樣它只需要被定義一次,就可以在多個地方多次被使用。在這個例子中,main函數就調用了兩次fToC函數,分別是使用在局部定義的兩個常量作為調用函數的參數。
*gopl.io/ch2/ftoc*
~~~
// Ftoc prints two Fahrenheit-to-Celsius conversions.
package main
import "fmt"
func main() {
const freezingF, boilingF = 32.0, 212.0
fmt.Printf("%g°F = %g°C\n", freezingF, fToC(freezingF)) // "32°F = 0°C"
fmt.Printf("%g°F = %g°C\n", boilingF, fToC(boilingF)) // "212°F = 100°C"
}
func fToC(f float64) float64 {
return (f - 32) * 5 / 9
}
~~~
### 2.3. 變量
var聲明語句可以創建一個特定類型的變量,然后給變量附加一個名字,并且設置變量的初始值。變量聲明的一般語法如下:
~~~
var 變量名字 類型 = 表達式
~~~
其中“*類型*”或“*= 表達式*”兩個部分可以省略其中的一個。如果省略的是類型信息,那么將根據初始化表達式來推導變量的類型信息。如果初始化表達式被省略,那么將用零值初始化該變量。 數值類型變量對應的零值是0,布爾類型變量對應的零值是false,字符串類型對應的零值是空字符串,接口或引用類型(包括slice、map、chan和函數)變量對應的零值是nil。數組或結構體等聚合類型對應的零值是每個元素或字段都是對應該類型的零值。
零值初始化機制可以確保每個聲明的變量總是有一個良好定義的值,因此在Go語言中不存在未初始化的變量。這個特性可以簡化很多代碼,而且可以在沒有增加額外工作的前提下確保邊界條件下的合理行為。例如:
~~~
var s string
fmt.Println(s) // ""
~~~
這段代碼將打印一個空字符串,而不是導致錯誤或產生不可預知的行為。Go語言程序員應該讓一些聚合類型的零值也具有意義,這樣可以保證不管任何類型的變量總是有一個合理有效的零值狀態。
也可以在一個聲明語句中同時聲明一組變量,或用一組初始化表達式聲明并初始化一組變量。如果省略每個變量的類型,將可以聲明多個類型不同的變量(類型由初始化表達式推導):
~~~
var i, j, k int // int, int, int
var b, f, s = true, 2.3, "four" // bool, float64, string
~~~
初始化表達式可以是字面量或任意的表達式。在包級別聲明的變量會在main入口函數執行前完成初始化(§2.6.2),局部變量將在聲明語句被執行到的時候完成初始化。
一組變量也可以通過調用一個函數,由函數返回的多個返回值初始化:
~~~
var f, err = os.Open(name) // os.Open returns a file and an error
~~~
### 2.3.1. 簡短變量聲明
在函數內部,有一種稱為簡短變量聲明語句的形式可用于聲明和初始化局部變量。它以“名字 := 表達式”形式聲明變量,變量的類型根據表達式來自動推導。下面是lissajous函數中的三個簡短變量聲明語句(§1.4):
~~~
anim := gif.GIF{LoopCount: nframes}
freq := rand.Float64() * 3.0
t := 0.0
~~~
因為簡潔和靈活的特點,簡短變量聲明被廣泛用于大部分的局部變量的聲明和初始化。var形式的聲明語句往往是用于需要顯式指定變量類型地方,或者因為變量稍后會被重新賦值而初始值無關緊要的地方。
~~~
i := 100 // an int
var boiling float64 = 100 // a float64
var names []string
var err error
var p Point
~~~
和var形式聲明語句一樣,簡短變量聲明語句也可以用來聲明和初始化一組變量:
~~~
i, j := 0, 1
~~~
但是這種同時聲明多個變量的方式應該限制只在可以提高代碼可讀性的地方使用,比如for語句的循環的初始化語句部分。
請記住“:=”是一個變量聲明語句,而“=‘是一個變量賦值操作。也不要混淆多個變量的聲明和元組的多重賦值(§2.4.1),后者是將右邊各個的表達式值賦值給左邊對應位置的各個變量:
~~~
i, j = j, i // 交換 i 和 j 的值
~~~
和普通var形式的變量聲明語句一樣,簡短變量聲明語句也可以用函數的返回值來聲明和初始化變量,像下面的os.Open函數調用將返回兩個值:
~~~
f, err := os.Open(name)
if err != nil {
return err
}
// ...use f...
f.Close()
~~~
這里有一個比較微妙的地方:簡短變量聲明左邊的變量可能并不是全部都是剛剛聲明的。如果有一些已經在相同的詞法域聲明過了(§2.7),那么簡短變量聲明語句對這些已經聲明過的變量就只有賦值行為了。
在下面的代碼中,第一個語句聲明了in和err兩個變量。在第二個語句只聲明了out一個變量,然后對已經聲明的err進行了賦值操作。
~~~
in, err := os.Open(infile)
// ...
out, err := os.Create(outfile)
~~~
簡短變量聲明語句中必須至少要聲明一個新的變量,下面的代碼將不能編譯通過:
~~~
f, err := os.Open(infile)
// ...
f, err := os.Create(outfile) // compile error: no new variables
~~~
解決的方法是第二個簡短變量聲明語句改用普通的多重賦值語言。
簡短變量聲明語句只有對已經在同級詞法域聲明過的變量才和賦值操作語句等價,如果變量是在外部詞法域聲明的,那么簡短變量聲明語句將會在當前詞法域重新聲明一個新的變量。我們在本章后面將會看到類似的例子。
### 2.3.2. 指針
一個變量對應一個保存了變量對應類型值的內存空間。普通變量在聲明語句創建時被綁定到一個變量名,比如叫x的變量,但是還有很多變量始終以表達式方式引入,例如x[i]或x.f變量。所有這些表達式一般都是讀取一個變量的值,除非它們是出現在賦值語句的左邊,這種時候是給對應變量賦予一個新的值。
一個指針的值是另一個變量的地址。一個指針對應變量在內存中的存儲位置。并不是每一個值都會有一個內存地址,但是對于每一個變量必然有對應的內存地址。通過指針,我們可以直接讀或更新對應變量的值,而不需要知道該變量的名字(如果變量有名字的話)。
如果用“var x int”聲明語句聲明一個x變量,那么&x表達式(取x變量的內存地址)將產生一個指向該整數變量的指針,指針對應的數據類型是`*int`,指針被稱之為“指向int類型的指針”。如果指針名字為p,那么可以說“p指針指向變量x”,或者說“p指針保存了x變量的內存地址”。同時`*p`表達式對應p指針指向的變量的值。一般`*p`表達式讀取指針指向的變量的值,這里為int類型的值,同時因為`*p`對應一個變量,所以該表達式也可以出現在賦值語句的左邊,表示更新指針所指向的變量的值。
~~~
x := 1
p := &x // p, of type *int, points to x
fmt.Println(*p) // "1"
*p = 2 // equivalent to x = 2
fmt.Println(x) // "2"
~~~
對于聚合類型每個成員——比如結構體的每個字段、或者是數組的每個元素——也都是對應一個變量,因此可以被取地址。
變量有時候被稱為可尋址的值。即使變量由表達式臨時生成,那么表達式也必須能接受`&`取地址操作。
任何類型的指針的零值都是nil。如果`p != nil`測試為真,那么p是指向某個有效變量。指針之間也是可以進行相等測試的,只有當它們指向同一個變量或全部是nil時才相等。
~~~
var x, y int
fmt.Println(&x == &x, &x == &y, &x == nil) // "true false false"
~~~
在Go語言中,返回函數中局部變量的地址也是安全的。例如下面的代碼,調用f函數時創建局部變量v,在局部變量地址被返回之后依然有效,因為指針p依然引用這個變量。
~~~
var p = f()
func f() *int {
v := 1
return &v
}
~~~
每次調用f函數都將返回不同的結果:
~~~
fmt.Println(f() == f()) // "false"
~~~
因為指針包含了一個變量的地址,因此如果將指針作為參數調用函數,那將可以在函數中通過該指針來更新變量的值。例如下面這個例子就是通過指針來更新變量的值,然后返回更新后的值,可用在一個表達式中(譯注:這是對C語言中`++v`操作的模擬,這里只是為了說明指針的用法,incr函數模擬的做法并不推薦):
~~~
func incr(p *int) int {
*p++ // 非常重要:只是增加p指向的變量的值,并不改變p指針!!!
return *p
}
v := 1
incr(&v) // side effect: v is now 2
fmt.Println(incr(&v)) // "3" (and v is 3)
~~~
每次我們對一個變量取地址,或者復制指針,我們都是為原變量創建了新的別名。例如,`*p`就是是 變量v的別名。指針特別有價值的地方在于我們可以不用名字而訪問一個變量,但是這是一把雙刃劍:要找到一個變量的所有訪問者并不容易,我們必須知道變量全部的別名(譯注:這是Go語言的垃圾回收器所做的工作)。不僅僅是指針會創建別名,很多其他引用類型也會創建別名,例如slice、map和chan,甚至結構體、數組和接口都會創建所引用變量的別名。
指針是實現標準庫中flag包的關鍵技術,它使用命令行參數來設置對應變量的值,而這些對應命令行標志參數的變量可能會零散分布在整個程序中。為了說明這一點,在早些的echo版本中,就包含了兩個可選的命令行參數:`-n`用于忽略行尾的換行符,`-s sep`用于指定分隔字符(默認是空格)。下面這是第四個版本,對應包路徑為gopl.io/ch2/echo4。
*gopl.io/ch2/echo4*
~~~
// Echo4 prints its command-line arguments.
package main
import (
"flag"
"fmt"
"strings"
)
var n = flag.Bool("n", false, "omit trailing newline")
var sep = flag.String("s", " ", "separator")
func main() {
flag.Parse()
fmt.Print(strings.Join(flag.Args(), *sep))
if !*n {
fmt.Println()
}
}
~~~
調用flag.Bool函數會創建一個新的對應布爾型標志參數的變量。它有三個屬性:第一個是的命令行標志參數的名字“n”,然后是該標志參數的默認值(這里是false),最后是該標志參數對應的描述信息。如果用戶在命令行輸入了一個無效的標志參數,或者輸入`-h`或`-help`參數,那么將打印所有標志參數的名字、默認值和描述信息。類似的,調用flag.String函數將于創建一個對應字符串類型的標志參數變量,同樣包含命令行標志參數對應的參數名、默認值、和描述信息。程序中的`sep`和`n`變量分別是指向對應命令行標志參數變量的指針,因此必須用`*sep`和`*n`形式的指針語法間接引用它們。
當程序運行時,必須在使用標志參數對應的變量之前調用先flag.Parse函數,用于更新每個標志參數對應變量的值(之前是默認值)。對于非標志參數的普通命令行參數可以通過調用flag.Args()函數來訪問,返回值對應對應一個字符串類型的slice。如果在flag.Parse函數解析命令行參數時遇到錯誤,默認將打印相關的提示信息,然后調用os.Exit(2)終止程序。
讓我們運行一些echo測試用例:
~~~
$ go build gopl.io/ch2/echo4
$ ./echo4 a bc def
a bc def
$ ./echo4 -s / a bc def
a/bc/def
$ ./echo4 -n a bc def
a bc def$
$ ./echo4 -help
Usage of ./echo4:
-n omit trailing newline
-s string
separator (default " ")
~~~
### 2.3.3. new函數
另一個創建變量的方法是調用用內建的new函數。表達式new(T)將創建一個T類型的匿名變量,初始化為T類型的零值,然后返回變量地址,返回的指針類型為`*T`。
~~~
p := new(int) // p, *int 類型, 指向匿名的 int 變量
fmt.Println(*p) // "0"
*p = 2 // 設置 int 匿名變量的值為 2
fmt.Println(*p) // "2"
~~~
用new創建變量和普通變量聲明語句方式創建變量沒有什么區別,除了不需要聲明一個臨時變量的名字外,我們還可以在表達式中使用new(T)。換言之,new函數類似是一種語法糖,而不是一個新的基礎概念。
下面的兩個newInt函數有著相同的行為:
~~~
func newInt() *int {
return new(int)
}
func newInt() *int {
var dummy int
return &dummy
}
~~~
每次調用new函數都是返回一個新的變量的地址,因此下面兩個地址是不同的:
~~~
p := new(int)
q := new(int)
fmt.Println(p == q) // "false"
~~~
當然也可能有特殊情況:如果兩個類型都是空的,也就是說類型的大小是0,例如`struct{}`和 `[0]int`, 有可能有相同的地址(依賴具體的語言實現)(譯注:請謹慎使用大小為0的類型,因為如果類型的大小位0好話,可能導致Go語言的自動垃圾回收器有不同的行為,具體請查看`runtime.SetFinalizer`函數相關文檔)。
new函數使用常見相對比較少,因為對應結構體來說,可以直接用字面量語法創建新變量的方法會更靈活(§4.4.1)。
由于new只是一個預定義的函數,它并不是一個關鍵字,因此我們可以將new名字重新定義為別的類型。例如下面的例子:
~~~
func delta(old, new int) int { return new - old }
~~~
由于new被定義為int類型的變量名,因此在delta函數內部是無法使用內置的new函數的。
### 2.3.4. 變量的生命周期
變量的生命周期指的是在程序運行期間變量有效存在的時間間隔。對于在包一級聲明的變量來說,它們的生命周期和整個程序的運行周期是一致的。而相比之下,在局部變量的聲明周期則是動態的:從每次創建一個新變量的聲明語句開始,直到該變量不再被引用為止,然后變量的存儲空間可能被回收。函數的參數變量和返回值變量都是局部變量。它們在函數每次被調用的時候創建。
例如,下面是從1.4節的Lissajous程序摘錄的代碼片段:
~~~
for t := 0.0; t < cycles*2*math.Pi; t += res {
x := math.Sin(t)
y := math.Sin(t*freq + phase)
img.SetColorIndex(size+int(x*size+0.5), size+int(y*size+0.5),
blackIndex)
}
~~~
譯注:函數的有右小括弧也可以另起一行縮進,同時為了防止編譯器在行尾自動插入分號而導致的編譯錯誤,可以在末尾的參數變量后面顯式插入逗號。像下面這樣:
~~~
for t := 0.0; t < cycles*2*math.Pi; t += res {
x := math.Sin(t)
y := math.Sin(t*freq + phase)
img.SetColorIndex(
size+int(x*size+0.5), size+int(y*size+0.5),
blackIndex, // 最后插入的逗號不會導致編譯錯誤,這是Go編譯器的一個特性
) // 小括弧另起一行縮進,和大括弧的風格保存一致
}
~~~
在每次循環的開始會創建臨時變量t,然后在每次循環迭代中創建臨時變量x和y。
那么垃Go語言的自動圾收集器是如何知道一個變量是何時可以被回收的呢?這里我們可以避開完整的技術細節,基本的實現思路是,從每個包級的變量和每個當前運行函數的每一個局部變量開始,通過指針或引用的訪問路徑遍歷,是否可以找到該變量。如果不存在這樣的訪問路徑,那么說明該變量是不可達的,也就是說它是否存在并不會影響程序后續的計算結果。
因為一個變量的有效周期只取決于是否可達,因此一個循環迭代內部的局部變量的生命周期可能超出其局部作用域。同時,局部變量可能在函數返回之后依然存在。
編譯器會自動選擇在棧上還是在堆上分配局部變量的存儲空間,但可能令人驚訝的是,這個選擇并不是由用var還是new聲明變量的方式決定的。
~~~
var global *int
func f() {
var x int
x = 1
global = &x
}
func g() {
y := new(int)
*y = 1
}
~~~
f函數里的x變量必須在堆上分配,因為它在函數退出后依然可以通過包一級的global變量找到,雖然它是在函數內部定義的;用Go語言的術語說,這個x局部變量從函數f中逃逸了。相反,當g函數返回時,變量`*y`將是不可達的,也就是說可以馬上被回收的。因此,`*y`并沒有從函數g中逃逸,編譯器可以選擇在棧上分配`*y`的存儲空間(譯注:也可以選擇在堆上分配,然后由Go語言的GC回收這個變量的內存空間),雖然這里用的是new方式。其實在任何時候,你并不需為了編寫正確的代碼而要考慮變量的逃逸行為,要記住的是,逃逸的變量需要額外分配內存,同時對性能的優化可能會產生細微的影響。
Go語言的自動垃圾收集器對編寫正確的代碼是一個巨大的幫助,但也并不是說你完全不用考慮內存了。你雖然不需要顯式地分配和釋放內存,但是要編寫高效的程序你依然需要了解變量的生命周期。例如,如果將指向短生命周期對象的指針保存到具有長生命周期的對象中,特別是保存到全局變量時,會阻止對短生命周期對象的垃圾回收(從而可能影響程序的性能)。
### 2.4. 賦值
使用賦值語句可以更新一個變量的值,最簡單的賦值語句是將要被賦值的變量放在=的左邊,新值的表達式放在=的右邊。
~~~
x = 1 // 命名變量的賦值
*p = true // 通過指針間接賦值
person.name = "bob" // 結構體字段賦值
count[x] = count[x] * scale // 數組、slice或map的元素賦值
~~~
特定的二元算術運算符和賦值語句的復合操作有一個簡潔形式,例如上面最后的語句可以重寫為:
~~~
count[x] *= scale
~~~
這樣可以省去對變量表達式的重復計算。
數值變量也可以支持`++`遞增和`--`遞減語句(譯注:自增和自減是語句,而不是表達式,因此`x = i++`之類的表達式是錯誤的):
~~~
v := 1
v++ // 等價方式 v = v + 1;v 變成 2
v-- // 等價方式 v = v - 1;v 變成 1
~~~
### 2.4.1. 元組賦值
元組賦值是另一種形式的賦值語句,它允許同時更新多個變量的值。在賦值之前,賦值語句右邊的所有表達式將會先進行求值,然后再統一更新左邊對應變量的值。這對于處理有些同時出現在元組賦值語句左右兩邊的變量很有幫助,例如我們可以這樣交換兩個變量的值:
~~~
x, y = y, x
a[i], a[j] = a[j], a[i]
~~~
或者是計算兩個整數值的的最大公約數(GCD)(譯注:GCD不是那個敏感字,而是greatest common divisor的縮寫,歐幾里德的GCD是最早的非平凡算法):
~~~
func gcd(x, y int) int {
for y != 0 {
x, y = y, x%y
}
return x
}
~~~
或者是計算斐波納契數列(Fibonacci)的第N個數:
~~~
func fib(n int) int {
x, y := 0, 1
for i := 0; i < n; i++ {
x, y = y, x+y
}
return x
}
~~~
元組賦值也可以使一系列瑣碎賦值更加緊湊(譯注: 特別是在for循環的初始化部分),
~~~
i, j, k = 2, 3, 5
~~~
但如果表達式太復雜的話,應該盡量避免過度使用元組賦值;因為每個變量單獨賦值語句的寫法可讀性會更好。
有些表達式會產生多個值,比如調用一個有多個返回值的函數。當這樣一個函數調用出現在元組賦值右邊的表達式中時(譯注:右邊不能再有其它表達式),左邊變量的數目必須和右邊一致。
~~~
f, err = os.Open("foo.txt") // function call returns two values
~~~
通常,這類函數會用額外的返回值來表達某種錯誤類型,例如os.Open是用額外的返回值返回一個error類型的錯誤,還有一些是用來返回布爾值,通常被稱為ok。在稍后我們將看到的三個操作都是類似的用法。如果map查找(§4.3)、類型斷言(§7.10)或通道接收(§8.4.2)出現在賦值語句的右邊,它們都可能會產生兩個結果,有一個額外的布爾結果表示操作是否成功:
~~~
v, ok = m[key] // map lookup
v, ok = x.(T) // type assertion
v, ok = <-ch // channel receive
~~~
譯注:map查找(§4.3)、類型斷言(§7.10)或通道接收(§8.4.2)出現在賦值語句的右邊時,并不一定是產生兩個結果,也可能只產生一個結果。對于值產生一個結果的情形,map查找失敗時會返回零值,類型斷言失敗時會發送運行時panic異常,通道接收失敗時會返回零值(阻塞不算是失敗)。例如下面的例子:
~~~
v = m[key] // map查找,失敗時返回零值
v = x.(T) // type斷言,失敗時panic異常
v = <-ch // 管道接收,失敗時返回零值(阻塞不算是失敗)
_, ok = m[key] // map返回2個值
_, ok = mm[""], false // map返回1個值
_ = mm[""] // map返回1個值
~~~
和變量聲明一樣,我們可以用下劃線空白標識符`_`來丟棄不需要的值。
~~~
_, err = io.Copy(dst, src) // 丟棄字節數
_, ok = x.(T) // 只檢測類型,忽略具體值
~~~
### 2.4.2. 可賦值性
賦值語句是顯式的賦值形式,但是程序中還有很多地方會發生隱式的賦值行為:函數調用會隱式地將調用參數的值賦值給函數的參數變量,一個返回語句將隱式地將返回操作的值賦值給結果變量,一個復合類型的字面量(§4.2)也會產生賦值行為。例如下面的語句:
~~~
medals := []string{"gold", "silver", "bronze"}
~~~
隱式地對slice的每個元素進行賦值操作,類似這樣寫的行為:
~~~
medals[0] = "gold"
medals[1] = "silver"
medals[2] = "bronze"
~~~
map和chan的元素,雖然不是普通的變量,但是也有類似的隱式賦值行為。
不管是隱式還是顯式地賦值,在賦值語句左邊的變量和右邊最終的求到的值必須有相同的數據類型。更直白地說,只有右邊的值對于左邊的變量是可賦值的,賦值語句才是允許的。
可賦值性的規則對于不同類型有著不同要求,對每個新類型特殊的地方我們會專門解釋。對于目前我們已經討論過的類型,它的規則是簡單的:類型必須完全匹配,nil可以賦值給任何指針或引用類型的變量。常量(§3.6)則有更靈活的賦值規則,因為這樣可以避免不必要的顯式的類型轉換。
對于兩個值是否可以用`==`或`!=`進行相等比較的能力也和可賦值能力有關系:對于任何類型的值的相等比較,第二個值必須是對第一個值類型對應的變量是可賦值的,反之依然。和前面一樣,我們會對每個新類型比較特殊的地方做專門的解釋。
### 2.5. 類型
變量或表達式的類型定義了對應存儲值的屬性特征,例如數值在內存的存儲大小(或者是元素的bit個數),它們在內部是如何表達的,是否支持一些操作符,以及它們自己關聯的方法集等。
在任何程序中都會存在一些變量有著相同的內部結構,但是卻表示完全不同的概念。例如,一個int類型的變量可以用來表示一個循環的迭代索引、或者一個時間戳、或者一個文件描述符、或者一個月份;一個float64類型的變量可以用來表示每秒移動幾米的速度、或者是不同溫度單位下的溫度;一個字符串可以用來表示一個密碼或者一個顏色的名稱。
一個類型聲明語句創建了一個新的類型名稱,和現有類型具有相同的底層結構。新命名的類型提供了一個方法,用來分隔不同概念的類型,這樣即使它們底層類型相同也是不兼容的。
~~~
type 類型名字 底層類型
~~~
類型聲明語句一般出現在包一級,因此如果新創建的類型名字的首字符大寫,則在外部包也可以使用。
譯注:對于中文漢字,Unicode標志都作為小寫字母處理,因此中文的命名默認不能導出;不過國內的用戶針對該問題提出了不同的看法,根據RobPike的回復,在Go2中有可能會將中日韓等字符當作大寫字母處理。下面是RobPik在 [Issue763](https://github.com/golang/go/issues/5763) 的回復:
> A solution that's been kicking around for a while:
> For Go 2 (can't do it before then): Change the definition to “lower case letters and _ are package-local; all else is exported”. Then with non-cased languages, such as Japanese, we can write 日本語 for an exported name and _日本語 for a local name. This rule has no effect, relative to the Go 1 rule, with cased languages. They behave exactly the same.
為了說明類型聲明,我們將不同溫度單位分別定義為不同的類型:
*gopl.io/ch2/tempconv0*
~~~
// Package tempconv performs Celsius and Fahrenheit temperature computations.
package tempconv
import "fmt"
type Celsius float64 // 攝氏溫度
type Fahrenheit float64 // 華氏溫度
const (
AbsoluteZeroC Celsius = -273.15 // 絕對零度
FreezingC Celsius = 0 // 結冰點溫度
BoilingC Celsius = 100 // 沸水溫度
)
func CToF(c Celsius) Fahrenheit { return Fahrenheit(c*9/5 + 32) }
func FToC(f Fahrenheit) Celsius { return Celsius((f - 32) * 5 / 9) }
~~~
我們在這個包聲明了兩種類型:Celsius和Fahrenheit分別對應不同的溫度單位。它們雖然有著相同的底層類型float64,但是它們是不同的數據類型,因此它們不可以被相互比較或混在一個表達式運算。刻意區分類型,可以避免一些像無意中使用不同單位的溫度混合計算導致的錯誤;因此需要一個類似Celsius(t)或Fahrenheit(t)形式的顯式轉型操作才能將float64轉為對應的類型。Celsius(t)和Fahrenheit(t)是類型轉換操作,它們并不是函數調用。類型轉換不會改變值本身,但是會使它們的語義發生變化。另一方面,CToF和FToC兩個函數則是對不同溫度單位下的溫度進行換算,它們會返回不同的值。
對于每一個類型T,都有一個對應的類型轉換操作T(x),用于將x轉為T類型(譯注:如果T是指針類型,可能會需要用小括弧包裝T,比如`(*int)(0)`)。只有當兩個類型的底層基礎類型相同時,才允許這種轉型操作,或者是兩者都是指向相同底層結構的指針類型,這些轉換只改變類型而不會影響值本身。如果x是可以賦值給T類型的值,那么x必然也可以被轉為T類型,但是一般沒有這個必要。
數值類型之間的轉型也是允許的,并且在字符串和一些特定類型的slice之間也是可以轉換的,在下一章我們會看到這樣的例子。這類轉換可能改變值的表現。例如,將一個浮點數轉為整數將丟棄小數部分,將一個字符串轉為`[]byte`類型的slice將拷貝一個字符串數據的副本。在任何情況下,運行時不會發生轉換失敗的錯誤(譯注: 錯誤只會發生在編譯階段)。
底層數據類型決定了內部結構和表達方式,也決定是否可以像底層類型一樣對內置運算符的支持。這意味著,Celsius和Fahrenheit類型的算術運算行為和底層的float64類型是一樣的,正如我們所期望的那樣。
~~~
fmt.Printf("%g\n", BoilingC-FreezingC) // "100" °C
boilingF := CToF(BoilingC)
fmt.Printf("%g\n", boilingF-CToF(FreezingC)) // "180" °F
fmt.Printf("%g\n", boilingF-FreezingC) // compile error: type mismatch
~~~
比較運算符`==`和`<`也可以用來比較一個命名類型的變量和另一個有相同類型的變量,或有著相同底層類型的未命名類型的值之間做比較。但是如果兩個值有著不同的類型,則不能直接進行比較:
~~~
var c Celsius
var f Fahrenheit
fmt.Println(c == 0) // "true"
fmt.Println(f >= 0) // "true"
fmt.Println(c == f) // compile error: type mismatch
fmt.Println(c == Celsius(f)) // "true"!
~~~
注意最后那個語句。盡管看起來想函數調用,但是Celsius(f)是類型轉換操作,它并不會改變值,僅僅是改變值的類型而已。測試為真的原因是因為c和g都是零值。
一個命名的類型可以提供書寫方便,特別是可以避免一遍又一遍地書寫復雜類型(譯注:例如用匿名的結構體定義變量)。雖然對于像float64這種簡單的底層類型沒有簡潔很多,但是如果是復雜的類型將會簡潔很多,特別是我們即將討論的結構體類型。
命名類型還可以為該類型的值定義新的行為。這些行為表示為一組關聯到該類型的函數集合,我們稱為類型的方法集。我們將在第六章中討論方法的細節,這里值說寫簡單用法。
下面的聲明語句,Celsius類型的參數c出現在了函數名的前面,表示聲明的是Celsius類型的一個叫名叫String的方法,該方法返回該類型對象c帶著°C溫度單位的字符串:
~~~
func (c Celsius) String() string { return fmt.Sprintf("%g°C", c) }
~~~
許多類型都會定義一個String方法,因為當使用fmt包的打印方法時,將會優先使用該類型對應的String方法返回的結果打印,我們將在7.1節講述。
~~~
c := FToC(212.0)
fmt.Println(c.String()) // "100°C"
fmt.Printf("%v\n", c) // "100°C"; no need to call String explicitly
fmt.Printf("%s\n", c) // "100°C"
fmt.Println(c) // "100°C"
fmt.Printf("%g\n", c) // "100"; does not call String
fmt.Println(float64(c)) // "100"; does not call String
~~~
### 2.6. 包和文件
Go語言中的包和其他語言的庫或模塊的概念類似,目的都是為了支持模塊化、封裝、單獨編譯和代碼重用。一個包的源代碼保存在一個或多個以.go為文件后綴名的源文件中,通常一個包所在目錄路徑的后綴是包的導入路徑;例如包gopl.io/ch1/helloworld對應的目錄路徑是$GOPATH/src/gopl.io/ch1/helloworld。
每個包都對應一個獨立的名字空間。例如,在image包中的Decode函數和在unicode/utf16包中的 Decode函數是不同的。要在外部引用該函數,必須顯式使用image.Decode或utf16.Decode形式訪問。
包還可以讓我們通過控制哪些名字是外部可見的來隱藏內部實現信息。在Go語言中,一個簡單的規則是:如果一個名字是大寫字母開頭的,那么該名字是導出的(譯注:因為漢字不區分大小寫,因此漢字開頭的名字是沒有導出的)。
為了演示包基本的用法,先假設我們的溫度轉換軟件已經很流行,我們希望到Go語言社區也能使用這個包。我們該如何做呢?
讓我們創建一個名為gopl.io/ch2/tempconv的包,這是前面例子的一個改進版本。(我們約定我們的例子都是以章節順序來編號的,這樣的路徑更容易閱讀)包代碼存儲在兩個源文件中,用來演示如何在一個源文件聲明然后在其他的源文件訪問;雖然在現實中,這樣小的包一般只需要一個文件。
我們把變量的聲明、對應的常量,還有方法都放到tempconv.go源文件中:
gopl.io/ch2/tempconv
~~~
// Package tempconv performs Celsius and Fahrenheit conversions.
package tempconv
import "fmt"
type Celsius float64
type Fahrenheit float64
const (
AbsoluteZeroC Celsius = -273.15
FreezingC Celsius = 0
BoilingC Celsius = 100
)
func (c Celsius) String() string { return fmt.Sprintf("%g°C", c) }
func (f Fahrenheit) String() string { return fmt.Sprintf("%g°F", f) }
~~~
轉換函數則放在另一個conv.go源文件中:
~~~
package tempconv
// CToF converts a Celsius temperature to Fahrenheit.
func CToF(c Celsius) Fahrenheit { return Fahrenheit(c*9/5 + 32) }
// FToC converts a Fahrenheit temperature to Celsius.
func FToC(f Fahrenheit) Celsius { return Celsius((f - 32) * 5 / 9) }
~~~
每個源文件都是以包的聲明語句開始,用來指名包的名字。當包被導入的時候,包內的成員將通過類似tempconv.CToF的形式訪問。而包級別的名字,例如在一個文件聲明的類型和常量,在同一個包的其他源文件也是可以直接訪問的,就好像所有代碼都在一個文件一樣。要注意的是tempconv.go源文件導入了fmt包,但是conv.go源文件并沒有,因為這個源文件中的代碼并沒有用到fmt包。
因為包級別的常量名都是以大寫字母開頭,它們可以像tempconv.AbsoluteZeroC這樣被外部代碼訪問:
~~~
fmt.Printf("Brrrr! %v\n", tempconv.AbsoluteZeroC) // "Brrrr! -273.15°C"
~~~
要將攝氏溫度轉換為華氏溫度,需要先用import語句導入gopl.io/ch2/tempconv包,然后就可以使用下面的代碼進行轉換了:
~~~
fmt.Println(tempconv.CToF(tempconv.BoilingC)) // "212°F"
~~~
在每個源文件的包聲明前僅跟著的注釋是包注釋(§10.7.4)。通常,包注釋的第一句應該先是包的功能概要說明。一個包通常只有一個源文件有包注釋(譯注:如果有多個包注釋,目前的文檔工具會根據源文件名的先后順序將它們鏈接為一個包注釋)。如果包注釋很大,通常會放到一個獨立的doc.go文件中。
**練習 2.1:** 向tempconv包添加類型、常量和函數用來處理Kelvin絕對溫度的轉換,Kelvin 絕對零度是?273.15°C,Kelvin絕對溫度1K和攝氏度1°C的單位間隔是一樣的。
### 2.6.1. 導入包
在Go語言程序中,每個包都是有一個全局唯一的導入路徑。導入語句中類似"gopl.io/ch2/tempconv"的字符串對應包的導入路徑。Go語言的規范并沒有定義這些字符串的具體含義或包來自哪里,它們是由構建工具來解釋的。當使用Go語言自帶的go工具箱時(第十章),一個導入路徑代表一個目錄中的一個或多個Go源文件。
除了包的導入路徑,每個包還有一個包名,包名一般是短小的名字(并不要求包名是唯一的),包名在包的聲明處指定。按照慣例,一個包的名字和包的導入路徑的最后一個字段相同,例如gopl.io/ch2/tempconv包的名字一般是tempconv。
要使用gopl.io/ch2/tempconv包,需要先導入:
*gopl.io/ch2/cf*
~~~
// Cf converts its numeric argument to Celsius and Fahrenheit.
package main
import (
"fmt"
"os"
"strconv"
"gopl.io/ch2/tempconv"
)
func main() {
for _, arg := range os.Args[1:] {
t, err := strconv.ParseFloat(arg, 64)
if err != nil {
fmt.Fprintf(os.Stderr, "cf: %v\n", err)
os.Exit(1)
}
f := tempconv.Fahrenheit(t)
c := tempconv.Celsius(t)
fmt.Printf("%s = %s, %s = %s\n",
f, tempconv.FToC(f), c, tempconv.CToF(c))
}
}
~~~
導入語句將導入的包綁定到一個短小的名字,然后通過該短小的名字就可以引用包中導出的全部內容。上面的導入聲明將允許我們以tempconv.CToF的形式來訪問gopl.io/ch2/tempconv包中的內容。在默認情況下,導入的包綁定到tempconv名字(譯注:這包聲明語句指定的名字),但是我們也可以綁定到另一個名稱,以避免名字沖突(§10.4)。
cf程序將命令行輸入的一個溫度在Celsius和Fahrenheit溫度單位之間轉換:
~~~
$ go build gopl.io/ch2/cf
$ ./cf 32
32°F = 0°C, 32°C = 89.6°F
$ ./cf 212
212°F = 100°C, 212°C = 413.6°F
$ ./cf -40
-40°F = -40°C, -40°C = -40°F
~~~
如果導入了一個包,但是又沒有使用該包將被當作一個編譯錯誤處理。這種強制規則可以有效減少不必要的依賴,雖然在調試期間可能會讓人討厭,因為刪除一個類似log.Print("got here!")的打印語句可能導致需要同時刪除log包導入聲明,否則,編譯器將會發出一個錯誤。在這種情況下,我們需要將不必要的導入刪除或注釋掉。
不過有更好的解決方案,我們可以使用golang.org/x/tools/cmd/goimports導入工具,它可以根據需要自動添加或刪除導入的包;許多編輯器都可以集成goimports工具,然后在保存文件的時候自動運行。類似的還有gofmt工具,可以用來格式化Go源文件。
**練習 2.2:** 寫一個通用的單位轉換程序,用類似cf程序的方式從命令行讀取參數,如果缺省的話則是從標準輸入讀取參數,然后做類似Celsius和Fahrenheit的單位轉換,長度單位可以對應英尺和米,重量單位可以對應磅和公斤等。
### 2.6.2. 包的初始化
包的初始化首先是解決包級變量的依賴順序,然后安照包級變量聲明出現的順序依次初始化:
~~~
var a = b + c // a 第三個初始化, 為 3
var b = f() // b 第二個初始化, 為 2, 通過調用 f (依賴c)
var c = 1 // c 第一個初始化, 為 1
func f() int { return c + 1 }
~~~
如果包中含有多個.go源文件,它們將按照發給編譯器的順序進行初始化,Go語言的構建工具首先會將.go文件根據文件名排序,然后依次調用編譯器編譯。
對于在包級別聲明的變量,如果有初始化表達式則用表達式初始化,還有一些沒有初始化表達式的,例如某些表格數據初始化并不是一個簡單的賦值過程。在這種情況下,我們可以用一個特殊的init初始化函數來簡化初始化工作。每個文件都可以包含多個init初始化函數
~~~
func init() { /* ... */ }
~~~
這樣的init初始化函數除了不能被調用或引用外,其他行為和普通函數類似。在每個文件中的init初始化函數,在程序開始執行時按照它們聲明的順序被自動調用。
每個包在解決依賴的前提下,以導入聲明的順序初始化,每個包只會被初始化一次。因此,如果一個p包導入了q包,那么在p包初始化的時候可以認為q包必然已經初始化過了。初始化工作是自下而上進行的,main包最后被初始化。以這種方式,可以確保在main函數執行之前,所有依然的包都已經完成初始化工作了。
下面的代碼定義了一個PopCount函數,用于返回一個數字中含二進制1bit的個數。它使用init初始化函數來生成輔助表格pc,pc表格用于處理每個8bit寬度的數字含二進制的1bit的bit個數,這樣的話在處理64bit寬度的數字時就沒有必要循環64次,只需要8次查表就可以了。(這并不是最快的統計1bit數目的算法,但是它可以方便演示init函數的用法,并且演示了如果預生成輔助表格,這是編程中常用的技術)。
*gopl.io/ch2/popcount*
~~~
package popcount
// pc[i] is the population count of i.
var pc [256]byte
func init() {
for i := range pc {
pc[i] = pc[i/2] + byte(i&1)
}
}
// PopCount returns the population count (number of set bits) of x.
func PopCount(x uint64) int {
return int(pc[byte(x>>(0*8))] +
pc[byte(x>>(1*8))] +
pc[byte(x>>(2*8))] +
pc[byte(x>>(3*8))] +
pc[byte(x>>(4*8))] +
pc[byte(x>>(5*8))] +
pc[byte(x>>(6*8))] +
pc[byte(x>>(7*8))])
}
~~~
譯注:對于pc這類需要復雜處理的初始化,可以通過將初始化邏輯包裝為一個匿名函數處理,像下面這樣:
~~~
// pc[i] is the population count of i.
var pc [256]byte = func() (pc [256]byte) {
for i := range pc {
pc[i] = pc[i/2] + byte(i&1)
}
return
}()
~~~
要注意的是在init函數中,range循環只使用了索引,省略了沒有用到的值部分。循環也可以這樣寫:
~~~
for i, _ := range pc {
~~~
我們在下一節和10.5節還將看到其它使用init函數的地方。
**練習 2.3:** 重寫PopCount函數,用一個循環代替單一的表達式。比較兩個版本的性能。(11.4節將展示如何系統地比較兩個不同實現的性能。)
**練習 2.4:** 用移位算法重寫PopCount函數,每次測試最右邊的1bit,然后統計總數。比較和查表算法的性能差異。
**練習 2.5:** 表達式`x&(x-1)`用于將x的最低的一個非零的bit位清零。使用這個算法重寫PopCount函數,然后比較性能。
### 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)
}
}
~~~
我們已經看到包、文件、聲明和語句如何來表達一個程序結構。在下面的兩個章節,我們將探討數據的結構。
- Go語言程序設計
- Go語言圣經(中文版)
- 譯者序
- 前言
- 第一章 入門
- 第二章 程序結構
- 第三章 基礎數據類型
- 第四章 復合數據類型
- 第五章 函數
- 第六章 方法
- 第七章 接口
- 7.4. flag.Value接口
- 7.6. sort.Interface接口
- 7.7. http.Handler接口
- 第八章 Goroutines和Channels
- 第九章 基于共享變量的并發
- 9.2. sync.Mutex互斥鎖
- 第十章 包和工具
- 第十一章 測試
- 第十二章 反射
- 12.2. reflect.Type和reflect.Value
- 12.5. 通過reflect.Value修改值
- 第13章 底層編程
- 13.1. unsafe.Sizeof, Alignof 和 Offsetof
- 附錄