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 方式. 在任何时候, 你并不需为了编写正确的代码而要考虑变量的逃逸行为, 要记住的是, 逃逸的变量需要额外分配内存, 同时对性能的优化会产生一定的影响.

垃圾收集器对编写正确的代码是一个巨大的帮助, 但并不是说你完全不用考虑内存了. 你虽然不需要显式地分配和释放内存, 但是要编写高效的程序你还是需要知道变量的生命周期. 例如, 将指向短生命周期对象的指针保存到具有长生命周期的对象中, 特别是全局变量时, 会阻止对短生命周期对象的垃圾回收.