2.3. 變量

var 聲明可以創建一個特定類型的變量, 然後給變量附加一個名字, 併且設置變量的初始值. 變量聲明的一般語法:

var name type = 表達式

其中類型或 = 表達式 可以省略其中的一個. 如果省略的是類型信息, 那麽將根據初始化表達式類推導類型信息. 如果初始化表達式被省略, 那麽將用零值初始化變量. 數值類型變量的零值是0, 布爾類型變量的零值是 false, 字符串的零值是空字符串, 接口或引用類型(包括 切片, 字典, 通道 和 函數)的變量的零值是 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. 簡短變量聲明

在函數內部, 有一種稱爲簡短變量聲明的形式可用於聲明和初始化局部變量. 以 名字 := 表達式 方式聲明變量, 變量的類型根據表達式來推導. 這里函數中是三個簡短變量聲明語句(§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的地址)將産生一個指向整數變量的指針, 對應的數據類型是 *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"

因爲指針包含了一個變量的地址, 因此將指針作爲參數調用函數, 將可以在函數中通過指針更新變量的值. 例如這個通過指針來更新變量的值, 然後返迴更新後的值, 可用在一個表達式中:

func incr(p *int) int {
    *p++ // increments what p points to; does not change p
    return *p
}

v := 1
incr(&v)              // side effect: v is now 2
fmt.Println(incr(&v)) // "3" (and v is 3)

每次我們對變量取地址, 或者複製指針, 我們都創建了變量的新的别名. 例如, *p 是 變量 v 的别名. 指針特别有加載的地方在於我們可以不用名字而訪問一個變量, 但是這是一把雙刃劍: 要找到一個變量的所有訪問者, 我們必鬚知道變量全部的别名. 不僅僅是指針創建别名, 很多其他引用類型也會創建别名, 例如 切片, 字典和管道, 甚至結構體, 數組和接口都會創建所引用變量的别名.

指針是 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 用於創建一個字符串類型的標誌參數變量, 同樣包含參數名, 默認值, 和描述信息. 變量 sepn 是一個指向標誌參數變量的指針, 因此必鬚用 sep 和 n 的方式間接引用.

當程序運行時, 必鬚在標誌參數變量使用之前調用 flag.Parse 函數更新標誌參數變量的值(之前是默認值). 非標誌參數的普通類型參數可以用 flag.Args() 訪問, 對應一個 字符串切片. 如果 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 {                func newInt() *int {
    return new(int)                     var dummy int
}                                       return &dummy
                                    }

每次調用 new 都是返迴一個新的變量的地址, 因此下面兩個地址是不同的:

p := new(int)
q := new(int)
fmt.Println(p == q) // "false"

當然也有特殊情況: 如果兩個類型都是空的, 也就是説類型的大小是0, 例如 struct{}[0]int, 有可能有相同的地址(依賴具體的語言實現).

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

在每次循環的開始創建變量 t, 然後在每次循環迭代中創建 x 和 y.

那麽垃圾收集器是如何知道一個變量是何時可以被迴收的呢? 這里我們先避開完整的技術細節, 但是基本的思路是, 從每個包級的變量和每個當前運行函數的每一個局部變量開始, 通過指針或引用的路徑, 是否可以找到該變量. 如果不存在這樣的路徑, 那麽説明該變量是不可達的, 也就是説它併不會影響其餘的計算.

因爲一個變量的聲明週期隻取決於是否可達, 因此一個循環迭代內部的局部變量的生命週期可能超齣其局部作用域. 它可能在函數返迴之後依然存在.

編譯器會選擇在棧上還是在堆上分配局部變量的存儲空間, 但可能令人驚訝的是, 這個選擇併不是由 var 或 new 來決定的.

var global *int 

func f() {                 func g() { 
    var x int                  y := new(int) 
    x = 1                      *y = 1 
    global = &x            } 
}

這里的 x 必鬚在堆上分配, 因爲它在函數退齣後依然可以通過包的 global 變量找到, 雖然它是在函數內部定義的; 我們説這個 x 局部變量從 函數 f 中逃逸了. 相反, 當 g 函數返迴時, 變量 *y 將是不可達的, 也就是可以被迴收的. 因此, *y 併沒有從 函數 g 逃逸, 編譯器可以選擇在棧上分配 *y 的存儲空間, 雖然這里用的是 new 方式. 在任何時候, 你併不需爲了編寫正確的代碼而要考慮變量的逃逸行爲, 要記住的是, 逃逸的變量需要額外分配內存, 同時對性能的優化會産生一定的影響.

垃圾收集器對編寫正確的代碼是一個鉅大的幫助, 但併不是説你完全不用考慮內存了. 你雖然不需要顯式地分配和釋放內存, 但是要編寫高效的程序你還是需要知道變量的生命週期. 例如, 將指向短生命週期對象的指針保存到具有長生命週期的對象中, 特别是全局變量時, 會阻止對短生命週期對象的垃圾迴收.