标准格式

Go语言的变量声明的标准格式为:

var 变量名 变量类型

变量声明以关键字 var 开头,后置变量类型,行尾无须分号。

 

声明变量的一般形式是使用 var 关键字:

var name type

其中,var 是声明变量的关键字,name 是变量名,type 是变量的类型。

 

int* a, b;

 

var a, b *int 

当一个变量被声明之后,系统自动赋予它该类型的零值:int 为 0,float 为 0.0,bool 为 false,string 为空字符串,指针为 nil 等。所有的内存在 Go 中都是经过初始化的。

 

批量格式

觉得每行都用 var 声明变量比较烦琐?没关系,还有一种为懒人提供的定义变量的方法:

  1. var (
  2. a int
  3. b string
  4. c []float32
  5. d func() bool
  6. e struct {
  7. x int
  8. }
  9. )

使用关键字 var 和括号,可以将一组变量定义放在一起。

 

简短格式

除 var 关键字外,还可使用更加简短的变量定义和初始化语法。

名字 := 表达式

需要注意的是,简短模式(short variable declaration)有以下限制:

  • 定义变量,同时显式初始化。
  • 不能提供数据类型。
  • 只能用在函数内部。

 

和 var 形式声明语句一样,简短变量声明语句也可以用来声明和初始化一组变量:

i, j := 0, 1

下面通过一段代码来演示简短格式变量声明的基本样式。

  1. func main() {
  2. x:=100
  3. a,s:=1, "abc"
  4. }

因为简洁和灵活的特点,简短变量声明被广泛用于大部分的局部变量的声明和初始化。var 形式的声明语句往往是用于需要显式指定变量类型地方,或者因为变量稍后会被重新赋值而初始值无关紧要的地方。

 

每个变量会初始化其类型的默认值,例如:

  • 整型和浮点型变量的默认值为 0 和 0.0。
  • 字符串变量的默认值为空字符串。
  • 布尔型变量默认为 bool。
  • 切片、函数、指针变量的默认为 nil。

 

 

回顾C语言

在C语言中,变量在声明时,并不会对变量对应内存区域进行清理操作。此时,变量值可能是完全不可预期的结果。开发者需要习惯在使用C语言进行声明时要初始化操作,稍有不慎,就会造成不可预知的后果。

 

在网络上只有程序员才能看懂的“烫烫烫”和“屯屯屯”的梗,就来源于 C/C++ 中变量默认不初始化。

 

微软的 VC 编译器会将未初始化的栈空间以 16 进制的 0xCC 填充,而未初始化的堆空间使用 0xCD 填充,而 0xCCCC 和 0xCDCD 在中文的 GB2312 编码中刚好对应“烫”和“屯”字。

 

\0

变量初始化的标准格式

var 变量名 类型 = 表达式

例如,游戏中,玩家的血量初始值为100。可以这样写:

  1. var hp int = 100

这句代码中,hp 为变量名,类型为 int,hp 的初始值为 100。

 

上面代码中,100 和 int 同为 int 类型,int 可以认为是冗余信息,因此可以进一步简化初始化的写法。

编译器推导类型的格式

在标准格式的基础上,将 int 省略后,编译器会尝试根据等号右边的表达式推导 hp 变量的类型。

  1. var hp = 100

等号右边的部分在编译原理里被称做右值(rvalue)。

 

下面是编译器根据右值推导变量类型完成初始化的例子。

  1. var attack = 40
  2. var defence = 20
  3. var damageRate float32 = 0.17
  4. var damage = float32(attack-defence) * damageRate
  5. fmt.Println(damage)

代码说明如下:

  • 第 1 和 2 行,右值为整型,attack 和 defence 变量的类型为 int。
  • 第 3 行,表达式的右值中使用了 0.17。由于Go语言和C语言一样,编译器会尽量提高精确度,以避免计算中的精度损失。所以这里如果不指定 damageRate 变量的类型,Go语言编译器会将 damageRate 类型推导为 float64,我们这里不需要 float64 的精度,所以需要强制指定类型为 float32。
  • 第 4 行,将 attack 和 defence 相减后的数值结果依然为整型,使用 float32() 将结果转换为 float32 类型,再与 float32 类型的 damageRate 相乘后,damage 类型也是 float32 类型。
提示:damage 变量的右值是一个复杂的表达式,整个过程既有 attack 和 defence 的运算还有强制类型转换。强制类型转换会在后面的章节中介绍。
  • 第 5 行,输出 damage 的值。

 

以上代码输出结果为:

3.4

短变量声明并初始化

var 的变量声明还有一种更为精简的写法,例如:

  1. hp := 100

这是Go语言的推导声明写法,编译器会自动根据右值类型推断出左值的对应类型。

:==
:=
  1. // 声明 hp 变量
  2. var hp int
  3. // 再次声明并赋值
  4. hp := 10

编译报错如下:

no new variables on left side of :=

意思是,在“:=”的左边没有新变量出现,意思就是“:=”的左边变量已经被声明了。

 

短变量声明的形式在开发中的例子较多,比如:

  1. conn, err := net.Dial("tcp","127.0.0.1:8080")

net.Dial 提供按指定协议和地址发起网络连接,这个函数有两个返回值,一个是连接对象(conn),一个是错误对象(err)。如果是标准格式将会变成:

  1. var conn net.Conn
  2. var err error
  3. conn, err = net.Dial("tcp", "127.0.0.1:8080")

因此,短变量声明并初始化的格式在开发中使用比较普遍。

 

注意:在多个短变量声明和赋值中,至少有一个新声明的变量出现在左值中,即便其他变量名可能是重复声明的,编译器也不会报错,代码如下:

  1. conn, err := net.Dial("tcp", "127.0.0.1:8080")
  2. conn2, err := net.Dial("tcp", "127.0.0.1:8080")

上面的代码片段,编译器不会报 err 重复定义。

 

 

编程最简单的算法之一,莫过于变量交换。交换变量的常见算法需要一个中间变量进行变量的临时保存。用传统方法编写变量交换代码如下:

  1. var a int = 100
  2. var b int = 200
  3. var t int
  4.  
  5. t = a
  6. a = b
  7. b = t
  8.  
  9. fmt.Println(a, b)

在计算机刚发明时,内存非常“精贵”。这种变量交换往往是非常奢侈的。于是计算机“大牛”发明了一些算法来避免使用中间变量:

  1. var a int = 100
  2. var b int = 200
  3.  
  4. a = a ^ b
  5. b = b ^ a
  6. a = a ^ b
  7.  
  8. fmt.Println(a, b)

这样的算法很多,但是都有一定的数值范围和类型要求。

 

到了Go语言时,内存不再是紧缺资源,而且写法可以更简单。使用 Go 的“多重赋值”特性,可以轻松完成变量交换的任务:

  1. var a int = 100
  2. var b int = 200
  3.  
  4. b, a = a, b
  5.  
  6. fmt.Println(a, b)

多重赋值时,变量的左值和右值按从左到右的顺序赋值。

 

多重赋值在Go语言的错误处理和函数返回值中会大量地使用。例如使用Go语言进行排序时就需要使用交换,代码如下:

  1. type IntSlice []int
  2.  
  3. func (p IntSlice) Len() int { return len(p) }
  4. func (p IntSlice) Less(i, j int) bool { return p[i] < p[j] }
  5. func (p IntSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] }

代码说明如下:

  • 第 1 行,将 IntSlice 声明为 []int 类型。
  • 第 3 行,为 IntSlice 类型编写一个 Len 方法,提供切片的长度。
  • 第 4 行,根据提供的 i、j 元素索引,获取元素后进行比较,返回比较结果。
  • 第 5 行,根据提供的 i、j 元素索引,交换两个元素的值。

 

Go语言匿名变量(没有名字的变量)

 

在编码过程中,可能会遇到没有名称的变量、类型或方法。虽然这不是必须的,但有时候这样做可以极大地增强代码的灵活性,这些变量被统称为匿名变量。

 

匿名变量的特点是一个下画线“_”,“_”本身就是一个特殊的标识符,被称为空白标识符。它可以像其他标识符那样用于变量的声明或赋值(任何类型都可以赋值给它),但任何赋给这个标识符的值都将被抛弃,因此这些值不能在后续的代码中使用,也不可以使用这个标识符作为变量对其它变量进行赋值或运算。使用匿名变量时,只需要在变量声明的地方使用下画线替换即可。例如:

  1. func GetData() (int, int) {
  2.     return 100, 200
  3. }
  4. func main(){
  5. a, _ := GetData()
  6. _, b := GetData()
  7. fmt.Println(a, b)
  8. }

代码运行结果:

100 200

GetData() 是一个函数,拥有两个整型返回值。每次调用将会返回 100 和 200 两个数值。

 

代码说明如下:

  • 第 5 行只需要获取第一个返回值,所以将第二个返回值的变量设为下画线(匿名变量)。
  • 第 6 行将第一个返回值的变量设为匿名变量。

 

匿名变量不占用内存空间,不会分配内存。匿名变量与匿名变量之间也不会因为多次声明而无法使用。

 

提示:在 Lua 等编程语言里,匿名变量也被叫做哑元变量。

 

Go语言变量的作用域

 

一个变量(常量、类型或函数)在程序中都有一定的作用范围,称之为作用域。

 

了解变量的作用域对我们学习Go语言来说是比较重要的,因为Go语言会在编译时检查每个变量是否使用过,一旦出现未使用的变量,就会报编译错误。如果不能理解变量的作用域,就有可能会带来一些不明所以的编译错误。

 

根据变量定义位置的不同,可以分为以下三个类型:

  • 函数内定义的变量称为局部变量
  • 函数外定义的变量称为全局变量
  • 函数定义中的变量称为形式参数

 

下面就来分别介绍一下。

局部变量

在函数体内声明的变量称之为局部变量,它们的作用域只在函数体内,函数的参数和返回值变量都属于局部变量。

 

局部变量不是一直存在的,它只在定义它的函数被调用后存在,函数调用结束后这个局部变量就会被销毁。

 

【示例】下面的 main() 函数中使用到了局部变量 a、b、c。

  1. package main
  2.  
  3. import (
  4. "fmt"
  5. )
  6.  
  7. func main() {
  8. //声明局部变量 a 和 b 并赋值
  9. var a int = 3
  10. var b int = 4
  11. //声明局部变量 c 并计算 a 和 b 的和
  12. c := a + b
  13. fmt.Printf("a = %d, b = %d, c = %d\n", a, b, c)
  14. }

运行结果如下所示:

a = 3, b = 4, c = 7

全局变量

在函数体外声明的变量称之为全局变量,全局变量只需要在一个源文件中定义,就可以在所有源文件中使用,当然,不包含这个全局变量的源文件需要使用“import”关键字引入全局变量所在的源文件之后才能使用这个全局变量。

 

全局变量声明必须以 var 关键字开头,如果想要在外部包中使用全局变量的首字母必须大写。

 

【示例】下面代码中,第 6 行定义了全局变量 c。

  1. package main
  2.  
  3. import "fmt"
  4.  
  5. //声明全局变量
  6. var c int
  7.  
  8. func main() {
  9. //声明局部变量
  10. var a, b int
  11.  
  12. //初始化参数
  13. a = 3
  14. b = 4
  15. c = a + b
  16.  
  17. fmt.Printf("a = %d, b = %d, c = %d\n", a, b, c)
  18. }

运行结果如下所示:

a = 3, b = 4, c = 7

Go语言程序中全局变量与局部变量名称可以相同,但是函数体内的局部变量会被优先考虑。

  1. package main
  2.  
  3. import "fmt"
  4.  
  5. //声明全局变量
  6. var a float32 = 3.14
  7.  
  8. func main() {
  9. //声明局部变量
  10. var a int = 3
  11.  
  12. fmt.Printf("a = %d\n", a)
  13. }

运行结果如下所示:

a = 3

形式参数

在定义函数时函数名后面括号中的变量叫做形式参数(简称形参)。形式参数只在函数调用时才会生效,函数调用结束后就会被销毁,在函数未被调用时,函数的形参并不占用实际的存储单元,也没有实际值。

 

形式参数会作为函数的局部变量来使用。

 

【示例】下面代码中第 21 行定义了形式参数 a 和 b。

  1. package main
  2.  
  3. import (
  4.     "fmt"
  5. )
  6.  
  7. //全局变量 a
  8. var a int = 13
  9.  
  10. func main() {
  11.     //局部变量 a 和 b
  12.     var a int = 3
  13.     var b int = 4
  14.  
  15.     fmt.Printf("main() 函数中 a = %d\n", a)
  16. fmt.Printf("main() 函数中 b = %d\n", b)
  17.     c := sum(a, b)
  18.     fmt.Printf("main() 函数中 c = %d\n", c)
  19. }
  20.  
  21. func sum(a, b int) int {
  22.     fmt.Printf("sum() 函数中 a = %d\n", a)
  23.     fmt.Printf("sum() 函数中 b = %d\n", b)
  24.     num := a + b
  25.     return num
  26. }

运行结果如下所示:

main() 函数中 a = 3

main() 函数中 b = 4

sum() 函数中 a = 3

sum() 函数中 b = 4

main() 函数中 c = 7

 

 

Go语言变量逃逸分析

 

在讨论变量生命周期之前,先来了解下计算机组成里两个非常重要的概念:堆和栈。变量的生命周期我们将在下一节《变量生命周期》中为大家讲解。

什么是栈

栈(Stack)是一种拥有特殊规则的线性表数据结构。

1) 概念

栈只允许从线性表的同一端放入和取出数据,按照后进先出(LIFO,Last InFirst Out)的顺序,如下图所示。

 

image

图:栈的操作及扩展

 

往栈中放入元素的过程叫做入栈。入栈会增加栈的元素数量,最后放入的元素总是位于栈的顶部,最先放入的元素总是位于栈的底部。

 

从栈中取出元素时,只能从栈顶部取出。取出元素后,栈的元素数量会变少。最先放入的元素总是最后被取出,最后放入的元素总是最先被取出。不允许从栈底获取数据,也不允许对栈成员(除了栈顶部的成员)进行任何查看和修改操作。

 

栈的原理类似于将书籍一本一本地堆起来。书按顺序一本一本从顶部放入,要取书时只能从顶部一本一本取出。

2) 变量和栈有什么关系

栈可用于内存分配,栈的分配和回收速度非常快。下面的代码展示了栈在内存分配上的作用:

  1. func calc(a, b int) int {
  2. var c int
  3. c = a * b
  4.  
  5. var x int
  6. x = c * 10
  7.  
  8. return x
  9. }

代码说明如下:

  • 第 1 行,传入 a、b 两个整型参数。
  • 第 2 行,声明整型变量 c,运行时,c 会分配一段内存用以存储 c 的数值。
  • 第 3 行,将 a 和 b 相乘后赋值给 c。
  • 第 5 行,声明整型变量 x,x 也会被分配一段内存。
  • 第 6 行,让 c 乘以 10 后赋值给变量 x。
  • 第 8 行,返回 x 的值。

 

上面的代码在没有任何优化的情况下,会进行变量 c 和 x 的分配过程。Go语言默认情况下会将 c 和 x 分配在栈上,这两个变量在 calc() 函数退出时就不再使用,函数结束时,保存 c 和 x 的栈内存再出栈释放内存,整个分配内存的过程通过栈的分配和回收都会非常迅速。

什么是堆

堆在内存分配中类似于往一个房间里摆放各种家具,家具的尺寸有大有小,分配内存时,需要找一块足够装下家具的空间再摆放家具。经过反复摆放和腾空家具后,房间里的空间会变得乱七八糟,此时再往这个空间里摆放家具会发现虽然有足够的空间,但各个空间分布在不同的区域,没有一段连续的空间来摆放家具。此时,内存分配器就需要对这些空间进行调整优化,如下图所示。

 

image

图:堆的分配及空间

 

堆分配内存和栈分配内存相比,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。

变量逃逸(Escape Analysis)——自动决定变量分配方式,提高运行效率

堆和栈各有优缺点,该怎么在编程中处理这个问题呢?在 C/C++ 语言中,需要开发者自己学习如何进行内存分配,选用怎样的内存分配方式来适应不同的算法需求。比如,函数局部变量尽量使用栈,全局变量、结构体成员使用堆分配等。程序员不得不花费很长的时间在不同的项目中学习、记忆这些概念并加以实践和使用。

 

Go语言将这个过程整合到了编译器中,命名为“变量逃逸分析”。通过编译器分析代码的特征和代码的生命周期,决定应该使用堆还是栈来进行内存分配。

1) 逃逸分析

通过下面的代码来展现Go语言如何使用命令行来分析变量逃逸,代码如下:

  1. package main
  2.  
  3. import "fmt"
  4.  
  5. // 本函数测试入口参数和返回值情况
  6. func dummy(b int) int {
  7.  
  8. // 声明一个变量c并赋值
  9. var c int
  10. c = b
  11.  
  12. return c
  13. }
  14.  
  15. // 空函数, 什么也不做
  16. func void() {
  17. }
  18.  
  19. func main() {
  20.  
  21. // 声明a变量并打印
  22. var a int
  23.  
  24. // 调用void()函数
  25. void()
  26.  
  27. // 打印a变量的值和dummy()函数返回
  28. fmt.Println(a, dummy(0))
  29. }

代码说明如下:

  • 第 6 行,dummy() 函数拥有一个参数,返回一个整型值,用来测试函数参数和返回值分析情况。
  • 第 9 行,声明变量 c,用于演示函数临时变量通过函数返回值返回后的情况。
  • 第 16 行,这是一个空函数,测试没有任何参数函数的分析情况。
  • 第 23 行,在 main() 中声明变量 a,测试 main() 中变量的分析情况。
  • 第 26 行,调用 void() 函数,没有返回值,测试 void() 调用后的分析情况。
  • 第 29 行,打印 a 和 dummy(0) 的返回值,测试函数返回值没有变量接收时的分析情况。

 

接着使用如下命令行运行上面的代码:

go run -gcflags "-m -l" main.go

使用 go run 运行程序时,-gcflags 参数是编译参数。其中 -m 表示进行内存分配分析,-l 表示避免程序内联,也就是避免进行程序优化。

 

运行结果如下:

# command-line-arguments

./main.go:29:13: a escapes to heap

./main.go:29:22: dummy(0) escapes to heap

./main.go:29:13: main ... argument does not escape

0 0

程序运行结果分析如下:

  • 第 2 行告知“代码的第 29 行的变量 a 逃逸到堆”。
  • 第 3 行告知“dummy(0) 调用逃逸到堆”。由于 dummy() 函数会返回一个整型值,这个值被 fmt.Println 使用后还是会在 main() 函数中继续存在。
  • 第 4 行,这句提示是默认的,可以忽略。

 

上面例子中变量 c 是整型,其值通过 dummy() 的返回值“逃出”了 dummy() 函数。变量 c 的值被复制并作为 dummy() 函数的返回值返回,即使变量 c 在 dummy() 函数中分配的内存被释放,也不会影响 main() 中使用 dummy() 返回的值。变量 c 使用栈分配不会影响结果。

2) 取地址发生逃逸

下面的例子使用结构体做数据,来了解结构体在堆上的分配情况,代码如下:

  1. package main
  2.  
  3. import "fmt"
  4.  
  5. // 声明空结构体测试结构体逃逸情况
  6. type Data struct {
  7. }
  8.  
  9. func dummy() *Data {
  10. // 实例化c为Data类型
  11. var c Data
  12.  
  13. //返回函数局部变量地址
  14. return &c
  15. }
  16.  
  17. func main() {
  18. fmt.Println(dummy())
  19. }

代码说明如下:

  • 第 6 行,声明一个空的结构体做结构体逃逸分析。
  • 第 9 行,将 dummy() 函数的返回值修改为 *Data 指针类型。
  • 第 11 行,将变量 c 声明为 Data 类型,此时 c 的结构体为值类型。
  • 第 14 行,取函数局部变量 c 的地址并返回。
  • 第 18 行,打印 dummy() 函数的返回值。

 

执行逃逸分析:

go run -gcflags "-m -l" main.go

# command-line-arguments

./main.go:15:9: &c escapes to heap

./main.go:12:6: moved to heap: c

./main.go:20:19: dummy() escapes to heap

./main.go:20:13: main ... argument does not escape

&{}

注意第 4 行出现了新的提示:将 c 移到堆中。这句话表示,Go 编译器已经确认如果将变量 c 分配在栈上是无法保证程序最终结果的,如果这样做,dummy() 函数的返回值将是一个不可预知的内存地址,这种情况一般是 C/C++ 语言中容易犯错的地方,引用了一个函数局部变量的地址。

 

Go语言最终选择将 c 的 Data 结构分配在堆上。然后由垃圾回收器去回收 c 的内存。

3) 原则

在使用Go语言进行编程时,Go语言的设计者不希望开发者将精力放在内存应该分配在栈还是堆的问题上,编译器会自动帮助开发者完成这个纠结的选择,但变量逃逸分析也是需要了解的一个编译器技术,这个技术不仅用于Go语言,在 Java 等语言的编译器优化上也使用了类似的技术。

 

编译器觉得变量应该分配在堆和栈上的原则是:

  • 变量是否被取地址;
  • 变量是否发生逃逸。

 

 

Go语言变量的生命周期

 

变量的生命周期指的是在程序运行期间变量有效存在的时间间隔。

 

变量的生命周期与变量的作用域有着不可分割的联系:

  • 全局变量:它的生命周期和整个程序的运行周期是一致的;
  • 局部变量:它的生命周期则是动态的,从创建这个变量的声明语句开始,到这个变量不再被引用为止;
  • 形式参数和函数返回值:它们都属于局部变量,在函数被调用的时候创建,函数调用结束后被销毁。
  1. for t := 0.0; t < cycles*2*math.Pi; t += res {
  2. x := math.Sin(t)
  3. y := math.Sin(t*freq + phase)
  4. img.SetColorIndex(
  5. size+int(x*size+0.5), size+int(y*size+0.5),
  6. blackIndex, // 最后插入的逗号不会导致编译错误,这是Go编译器的一个特性
  7. ) // 小括号另起一行缩进,和大括号的风格保存一致
  8. }
}

 

栈的概念在上一节《变量逃逸》中介绍过,它和堆的区别在于:

{ }

 

在程序的编译阶段,编译器会根据实际情况自动选择在栈或者堆上分配局部变量的存储空间,不论使用 var 还是 new 关键字声明变量都不会影响编译器的选择。

  1. var global *int
  2. func f() {
  3. var x int
  4. x = 1
  5. global = &x
  6. }
  7. func g() {
  8. y := new(int)
  9. *y = 1
  10. }

上述代码中,函数 f 里的变量 x 必须在堆上分配,因为它在函数退出后依然可以通过包一级的 global 变量找到,虽然它是在函数内部定义的。用Go语言的术语说,这个局部变量 x 从函数 f 中逃逸了。

 

相反,当函数 g 返回时,变量 *y 不再被使用,也就是说可以马上被回收的。因此,*y 并没有从函数 g 中逃逸,编译器可以选择在栈上分配 *y 的存储空间,也可以选择在堆上分配,然后由Go语言的 GC(垃圾回收机制)回收这个变量的内存空间。

 

在实际的开发中,并不需要刻意的实现变量的逃逸行为,因为逃逸的变量需要额外分配内存,同时对性能的优化可能会产生细微的影响。

 

虽然Go语言能够帮助我们完成对内存的分配和释放,但是为了能够开发出高性能的应用我们任然需要了解变量的声明周期。例如,如果将局部变量赋值给全局变量,将会阻止 GC 对这个局部变量的回收,导致不必要的内存占用,从而影响程序的性能。