2.3 变量
var声明创建一个具体类型的变量,然后给它附加一个名字,设置它的初始值。每一个声明有一个通用的形式:
类型和表达式部分可以省略一个,但是不能都省略。如果类型省略,它的类型将由初始化表达式决定;如果表达式省略,其初始值对应于类型的零值——对于数字是0,对于布尔值是false,对于字符串是"",对于接口和引用类型(slice、指针、map、通道、函数)是nil。对于一个像数组或结构体这样的复合类型,零值是其所有元素或成员的零值。
零值机制保障所有的变量是良好定义的,Go里面不存在未初始化变量。这种机制简化了代码,并且不需要额外工作就能感知边界条件的行为。例如:
输出空字符串,而不是一些错误或不可预料的行为。Go程序员经常花费精力来使复杂类型的零值有意义,以便变量一开始就处于一个可用状态。
可以声明一个变量列表,并选择使用对应的表达式列表对其初始化。忽略类型允许声明多个不同类型的变量。
初始值设定可以是字面量值或者任意的表达式。包级别的初始化在main开始之前进行(参考2.6.2节),局部变量初始化和声明一样在函数执行期间进行。
变量可以通过调用返回多个值的函数进行初始化:
2.3.1 短变量声明
在函数中,一种称作短变量声明的可选形式可以用来声明和初始化局部变量。它使用name:= expression的形式,name的类型由expression的类型决定。这里是lissajous函数(参考1.4节)中的三个短变量声明:
因其短小、灵活,故而在局部变量的声明和初始化中主要使用短声明。var声明通常是为那些跟初始化表达式类型不一致的局部变量保留的,或者用于后面才对变量赋值以及变量初始值不重要的情况。
与var声明一样,多个变量可以以短变量声明的方式声明和初始化:
只有当它们对于可读性有帮助的时候才使用多个初始化表达式来进行变量声明,例如短小且天然一组的for循环的初始化。
记住,:=表示声明,而=表示赋值。一个多变量的声明不能和多重赋值(参考2.4.1节)搞混,后者将右边的值赋给左边的对应变量:
与普通的var声明类似,短变量声明也可以用来调用像os.Open那样返回两个或多个值的函数:
一个容易被忽略但重要的地方是:短变量声明不需要声明所有在左边的变量。如果一些变量在同一个词法块中声明(参考2.7节),那么对于那些变量,短声明的行为等同于赋值。
在如下代码中,第一条语句声明了in和err。第二条语句仅声明了out,但向已有的err变量赋了值。
短变量声明最少声明一个新变量,否则,代码编译将无法通过:
第二个语句使用普通的赋值语句来修复这个错误。
只有在同一个词法块中已经存在变量的情况下,短声明的行为才和赋值操作一样,外层的声明将被忽略。我们在本章结尾的例子中将看到。
2.3.2 指针
变量是存储值的地方。借助声明创建的变量使用名字来区分,例如x,但是许多变量仅仅使用像x[i]或者x.f这样的表达式来区分。所有这些表达式读取一个变量的值,除非它们出现在赋值操作符的左边,这个时候是给变量赋值。
指针的值是一个变量的地址。一个指针指示值所保存的位置。不是所有的值都有地址,但是所有的变量都有。使用指针,可以在无须知道变量名字的情况下,间接读取或更新变量的值。
如果一个变量声明为var x int,表达式&x(x的地址)获取一个指向整型变量的指针,它的类型是整型指针(*int)。如果值叫作p,我们说p指向x,或者p包含x的地址。p指向的变量写成*p。表达式*p获取变量的值,一个整型,因为*p代表一个变量,所以它也可以出现在赋值操作符左边,用于更新变量的值。
每一个聚合类型变量的组成(结构体的成员或数组中的元素)都是变量,所以也有一个地址。
变量有时候使用一个地址化的值。代表变量的表达式,是唯一可以应用取地址操作符&的表达式。
指针类型的零值是nil。测试p!= nil,结果是true说明p指向一个变量。指针是可比较的,两个指针当且仅当指向同一个变量或者两者都是nil的情况下才相等。
函数返回局部变量的地址是非常安全的。例如下面的代码中,通过调用f产生的局部变量v即使在调用返回后依然存在,指针p依然引用它:
每次调用f都会返回一个不同的值:
因为一个指针包含变量的地址,所以传递一个指针参数给函数,能够让函数更新间接传递的变量值。例如,这个函数递增一个指针参数所指向的变量,然后返回此变量的新值,于是它可以在表达式中使用:
每次使用变量的地址或者复制一个指针,我们就创建了新的别名或者方式来标记同一变量。例如,*p是v的别名。指针别名允许我们不用变量的名字来访问变量,这一点是非常有用的,但是它是双刃剑:为了找到所有访问变量的语句,需要知道所有的别名。不仅指针产生别名,当复制其他引用类型(像slice、map、通道,甚至包含这里引用类型的结构体、数组和接口)的值的时候,也会产生别名。
指针对于flag包是很关键的,它使用程序的命令行参数来设置整个程序内某些变量的值。为了说明,下面这个变种的echo命令使用两个可选的标识参数:-n使echo忽略正常输出时结尾的换行符,-s sep使用sep替换默认参数输出时使用的空格分隔符。因为这是第4版,所以包名字叫作gopl.io/ch2/echo4。
flag.Bool函数创建一个新的布尔标识变量。它有三个参数:标识的名字("n"),变量的默认值(false),以及当用户提供非法标识、非法参数抑或-h或-help参数时输出的消息。同样地,flag.String也使用名字、默认值和消息来创建一个字符串变量。变量sep和n是指向标识变量的指针,它们必须通过*sep和*n来访问。
当程序运行时,在使用标识前,必须调用flag.Parse来更新标识变量的默认值。非标识参数也可以从flag.Args()返回的字符串slice来访问。如果flag.Parse遇到错误,它输出一条帮助消息,然后调用os.Exit(2)来结束程序。
让我们运行一些echo测试用例:
2.3.3 new函数
另外一种创建变量的方式是使用内置的new函数。表达式new(T)创建一个未命名的T类型变量,初始化为T类型的零值,并返回其地址(地址类型为*T)。
使用new创建的变量和取其地址的普通局部变量没有什么不同,只是不需要引入(和声明)一个虚拟的名字,通过new(T)就可以直接在表达式中使用。因此new只是语法上的便利,不是一个基础概念。
下面两个newInt函数有同样的行为。
每一次调用new返回一个具有唯一地址的不同变量:
这个规则有一个例外:两个变量的类型不携带任何信息且是零值,例如struct{}或[0]int,当前的实现里面,它们有相同的地址。
因为最常见的未命名变量都是结构体类型,它的语法(参考4.4.1节)比较复杂,所以new函数使用得相对较少。
new是一个预声明的函数,不是一个关键字,所以它可以重定义为另外的其他类型,例如:
自然,在delta函数内,内置的new函数是不可用的。
2.3.4 变量的生命周期
生命周期指在程序执行过程中变量存在的时间段。包级别变量的生命周期是整个程序的执行时间。相反,局部变量有一个动态的生命周期:每次执行声明语句时创建一个新的实体,变量一直生存到它变得不可访问,这时它占用的存储空间被回收。函数的参数和返回值也是局部变量,它们在其闭包函数被调用的时候创建。
例如,在1.4节中的lissajous示例程序中:
变量t在每次for循环的开始创建,变量x和y在循环的每次迭代中创建。
那么垃圾回收器如何知道一个变量是否应该被回收?说来话长,基本思路是每一个包级别的变量,以及每一个当前执行函数的局部变量,可以作为追溯该变量的路径的源头,通过指针和其他方式的引用可以找到变量。如果变量的路径不存在,那么变量变得不可访问,因此它不会影响任何其他的计算过程。
因为变量的生命周期是通过它是否可达来确定的,所以局部变量可在包含它的循环的一次迭代之外继续存活。即使包含它的循环已经返回,它的存在还可能延续。
编译器可以选择使用堆或栈上的空间来分配,令人惊奇的是,这个选择不是基于使用var或new关键字来声明变量。
这里,x一定使用堆空间,因为它在f函数返回以后还可以从global变量访问,尽管它被声明为一个局部变量。这种情况我们说x从f中逃逸。相反,当g函数返回时,变量*y变得不可访问,可回收。因为*y没有从g中逃逸,所以编译器可以安全地在栈上分配*y,即便使用new函数创建它。任何情况下,逃逸的概念使你不需要额外费心来写正确的代码,但要记住它在性能优化的时候是有好处的,因为每一次变量逃逸都需要一次额外的内存分配过程。
垃圾回收对于写出正确的程序有巨大的帮助,但是免不了考虑内存的负担。不需要显式分配和释放内存,但是变量的生命周期是写出高效程序所必需清楚的。例如,在长生命周期对象中保持短生命周期对象不必要的指针,特别是在全局变量中,会阻止垃圾回收器回收短生命周期的对象空间。