Go 语言圣经 - 2. 程序结构

2.3. 变量

var 声明语句可以创建一个特定类型的变量,然后给变量附加一个名字,并且设置变量的初始值。变量声明的一般语法如下:

var 变量名字 类型 = 表达式

其中 类型= 表达式 两个部分可以省略其中的一个。如果省略的是类型信息,那么将根据初始化表达式来推导变量的类型信息。如果初始化表达式被省略,那么将用零值初始化该变量。 数值类型变量对应的零值是 0,布尔类型变量对应的零值是 false,字符串类型对应的零值是空字符串,接口或引用类型(包括 slice、指针、map、chan 和函数)变量对应的零值是 nil。数组或结构体等聚合类型对应的零值是每个元素或字段都是对应该类型的零值。

2.3.1. 简短变量声明

在函数内部,有一种称为简短变量声明语句的形式可用于声明和初始化局部变量。它以 名字 := 表达式 形式声明变量,变量的类型根据表达式来自动推导:

i := 0
j := 1.0
s := ""

这里有一个比较微妙的地方:简短变量声明左边的变量可能并不是全部都是刚刚声明的。如果有一些已经在相同的词法域声明过了,那么简短变量声明语句对这些已经声明过的变量就只有赋值行为了。

简短变量声明语句中必须至少要声明一个新的变量,下面的代码将不能编译通过:

f, err := os.Open(infile)
// ...
f, err := os.Create(outfile) // compile error: no new variables

简短变量声明语句只有对已经在同级词法域声明过的变量才和赋值操作语句等价,如果变量是在外部词法域声明的,那么简短变量声明语句将会在当前词法域重新声明一个新的变量。

2.3.2. 指针

如果用 var x int 声明语句声明一个 x 变量,那么 &x 表达式(取 x 变量的内存地址)将产生一个指向该整数变量的指针,指针对应的数据类型是 *int,指针被称之为 “指向 int 类型的指针”。

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 指向某个有效变量,那么 p != nil 测试为真。

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 名字重新定义为别的类型。例如下面的例子:

func delta(old, new int) int { return new - old }

由于 new 被定义为 int 类型的变量名,因此在 delta 函数内部是无法使用内置的 new 函数的。

2.3.4. 变量的生命周期

变量的生命周期指的是在程序运行期间变量有效存在的时间段。对于在包一级声明的变量来说,它们的生命周期和整个程序的运行周期是一致的。而相比之下,局部变量的生命周期则是动态的:每次从创建一个新变量的声明语句开始,直到该变量不再被引用为止,然后变量的存储空间可能被回收。函数的参数变量和返回值变量都是局部变量。它们在函数每次被调用的时候创建。

编译器会自动选择在栈上还是在堆上分配局部变量的存储空间,但可能令人惊讶的是,这个选择并不是由用 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 方式。

2.4. 赋值

2.4.2. 可赋值性

赋值语句是显式的赋值形式,但是程序中还有很多地方会发生隐式的赋值行为:函数调用会隐式地将调用参数的值赋值给函数的参数变量,一个返回语句会隐式地将返回操作的值赋值给结果变量,一个复合类型的字面量也会产生赋值行为。

不管是隐式还是显式地赋值,在赋值语句左边的变量和右边最终的求到的值必须有相同的数据类型。更直白地说,只有右边的值对于左边的变量是可赋值的,赋值语句才是允许的。

可赋值性的规则对于不同类型有着不同要求,对每个新类型特殊的地方我们会专门解释。对于目前我们已经讨论过的类型,它的规则是简单的:类型必须完全匹配,nil 可以赋值给任何指针或引用类型的变量。常量则有更灵活的赋值规则,因为这样可以避免不必要的显式的类型转换。

对于两个值是否可以用 ==!= 进行相等比较的能力也和可赋值能力有关系:对于任何类型的值的相等比较,第二个值必须是对第一个值类型对应的变量是可赋值的,反之亦然。

2.5. 类型

一个类型声明语句创建了一个新的类型名称,和现有类型具有相同的底层结构。新命名的类型提供了一个方法,用来分隔不同概念的类型,这样即使它们底层类型相同也是不兼容的。

type 类型名字 底层类型

例如下面这个例子:

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

我们在这个包声明了两种类型:CelsiusFahrenheit 分别对应不同的温度单位。它们虽然有着相同的底层类型 float64,但是它们是不同的数据类型,因此它们不可以被相互比较或混在一个表达式运算。

刻意区分类型,可以避免一些像无意中使用不同单位的温度混合计算导致的错误,因此需要一个类似 Celsius(t)Fahrenheit(t) 形式的显式转型操作才能将 float64 转为对应的类型。

比较运算符 ==< 也可以用来比较一个命名类型的变量和另一个有相同类型的变量,或有着相同底层类型的未命名类型的值之间做比较。但是如果两个值有着不同的类型,则不能直接进行比较:

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"!

2.6. 包和文件

通常一个包所在目录路径的后缀是包的导入路径;例如包 gopl.io/ch1/helloworld 对应的目录路径是 $GOPATH/src/gopl.io/ch1/helloworld

每个源文件的开头都是以包的声明语句开始,用来指明包的名字。

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 初始化函数

这样的 init 初始化函数除了不能被调用或引用外,其他行为和普通函数类似。在每个文件中的 init 初始化函数,在程序开始执行时按照它们声明的顺序被自动调用。

2.7. 作用域

声明语句的作用域是指源代码中可以有效使用这个名字的范围。

不要将作用域和生命周期混为一谈。声明语句的作用域对应的是一个源代码的文本区域;它是一个编译时的属性。一个变量的生命周期是指程序运行时变量存在的有效时间段,在此时间区域内它可以被程序的其他部分引用;是一个运行时的概念。

句法块是由花括弧所包含的一系列语句,就像函数体或循环体花括弧包裹的内容一样。句法块内部声明的名字是无法被外部块访问的。这个块决定了内部声明的名字的作用域范围。

我们可以把块(block)的概念推广到包括其他声明的群组,这些声明在代码中并未显式地使用花括号包裹起来,我们称之为词法块。对全局的源代码来说,存在一个整体的词法块,称为全局词法块;对于每个包;每个 for、if 和 switch 语句,也都有对应词法块;每个 switch 或 select 的分支也有独立的词法块;当然也包括显式书写的词法块(花括弧包含的语句)。

在包级别,声明的顺序并不会影响作用域范围,因此一个先声明的可以引用它自身或者是引用后面的一个声明,这可以让我们定义一些相互嵌套或递归的类型或函数。