Go语言编程笔记2:基础

Go语言编程笔记2:变量

img

图源:php.cn

声明

Go语言中,声明变量的方式是多种多样的,最普遍的形式是下面这种:

package main

func main() {
	var varInt int               //整型
	var varFloat float64         //浮点型
	var varArray [3]int          //整型数组
	var varMap map[string]bool   //映射
	var varBool bool             //布尔
	var varSlice []int           //切片
	var varStruct struct{}       //结构体
	var varInterface interface{} //接口
	var varChannel chan int      //通道
}

这段代码并不能真正执行,因为Go语言规定不能声明从未使用的变量。

像上面展示的那样,可以使用var+变量名+变量类型的方式来声明一个变量。

就像一些现代语言一样,Go会“智能”地在变量声明后自动初始化,我称之为“默认初始化”。初始化的结果会依据变量类型的不同有不同的结果,详细情况请阅读下一节“类型”中的相关内容。

当然,如果需要在创建变量的同时赋予一个初始值的话,可以在声明的同时初始化:

package main

import "fmt"

type people struct {
	Name string
	Age  int
}

func (*people) string() string {
	return fmt.Sprintf("%s is %d years old", people.Name, people.Age)
}

type stringer interface {
	string() string
}

func main() {
	var varInt int = 1                                                          //整型
	var varFloat float64 = 1.5                                                  //浮点型
	var varArray [3]int = [...]int{1, 2, 3}                                     //整型数组
	var varMap map[string]bool = map[string]bool{"apple": true, "xiaomi": true} //映射
	var varBool bool = true                                                     //布尔
	var varSlice []int = []int{1, 2, 3}                                         //切片
	var varStruct people = people{Name: "lalala", Age: 11}                      //结构体
	var varInterface stringer = &people{Name: "lalala", Age: 11}                //接口
	var varChannel chan int = make(chan int)                                    //通道
}

如果是在声明的同时初始化,可以省略变量后的类型,比如:

var varInt = 1  

此时编译期会自动依据赋值符号=右侧的表达式结果的类型来创建变量,这种机制叫做“类型推断”。

此外,在类型推断的基础上,有一种Go语言中挺常用的“短赋值”写法:

varInt := 1

这种写法连var关键字也省略掉了,不过需要将赋值符号=替换为:=

:=在Python中有个挺别致的称呼——“海象操作符”,原因是该符号挺像是一个海象的小眼睛+长牙。

虽然Go编程中大量使用短赋值的方式声明变量,尤其是不需要关心变量类型的时候。但需要注意的是,如果需要确切的类型,依然需要使用传统的方式来声明变量比较好。

此外,如果在声明多个变量时类型一致,也可以使用简略写法,比如:

package main

import "fmt"

func main() {
	var a, b, c int
	a = 1
	b = 2
	c = 3
	fmt.Println(a)
	fmt.Println(b)
	fmt.Println(c)
}

类型

和其它语言类似,Go语言中的变量类型大致可以分为两类,一部分是基础类型,或者也可以称为“原子类型”,另一部分是由原子类型组成的复合类型。

基础类型有intfloat64stringrune等,复合类型有数组结构切片等。

但其实这种区分方式用途不大,因为在Go语言中,所有类型都是可以添加方法的,甚至是基础类型。所以它们在使用方式上可能并没有太大区别。反而是另一种分类方式更为重要:传值类型和引用类型。

传值类型指的是在赋值和调用函数传参的时候,是通过拷贝变量值的方式传递的那类变量。引用类型也好理解,就是赋值和传参的时候传递的不是值,而是“变量引用”,更准确的说就是真实变量的指针。

这两类变量无论是在初始化还是函数形参的调用上都会表现出很大不同。

传值类型有:整型浮点型字符串bool数组结构等。

引用类型有:切片映射通道接口等。

这里边的切片其实很特殊,虽然表面上是引用类型,但实际上和其它的引用类型有一些区别,这是由其特殊的结构决定的,这点在之后介绍切片的时候会详细说明。

传值类型和引用类型的第一个区别是默认初始化的不同,在只声明不初始化的情况下,传值类型会被默认初始化为0值,比如整型会被初始化为0,浮点型会被初始化为0.0,bool会被初始化为false等。

下面实际演示一下:

package main

import "fmt"

func main() {
	var varInt int
	var varFloat float64
	var varString string
	var varBool bool
	var varArray [3]int
	var varStruct struct{}
	fmt.Println(varInt)
	fmt.Println(varFloat)
	fmt.Println(varString)
	fmt.Println(varBool)
	fmt.Println(varArray)
	fmt.Println(varStruct)
}

// 0
// 0

// false
// [0 0 0]
// {}

这里的输出说明了上面的观点,此外,var varStruct struct{}这样的写法看上去有点怪异,实际上这里是声明了一个类型为匿名的空结构体struct{}的结构体变量,而该结构体的初始化值是一个空结构体,对应的字面量为{}

引用类型和传值类型不同,默认初始化值是nil,这也不难理解,对C++熟悉的人应该知道,空指针的值正是null,在Go语言中写作nil

下面我们看实际测试结果:

package main

import (
	"fmt"
	"go-notebook/ch2/formater"
)

func main() {
	var varSlice []int
	formater.PrintVariable(varSlice)
	fmt.Println(varSlice == nil)
	var varMap map[string]bool
	formater.PrintVariable(varMap)
	fmt.Println(varMap == nil)
	var varChannel chan int
	formater.PrintVariable(varChannel)
	fmt.Println(varChannel == nil)
	var varInterface interface{}
	formater.PrintVariable(varInterface)
	fmt.Println(varInterface == nil)
}

// []int(nil) []int []
// true
// map[string]bool(nil) map[string]bool map[]
// true
// (chan int)(nil) chan int <nil>
// true
// <nil> <nil> <nil>
// true

为了能正确观察到引用类型的nil值,我这里编写了一个辅助函数:

package formater

import "fmt"

func PrintVariable(variable interface{}) {
	fmt.Printf("%#v %T %v\n", variable, variable, variable)
}

这里借助格式化输出,分别使用格式化参数%#v%T%v输出Go语言中真实的值、变量类型、字符串形式输出的值。

关于更多Printf函数可以使用的格式化参数,可以阅读Go fmt.Sprintf 格式化字符串

可以看到这几种引用变量默认初始化后的值都是nil,只是表现形式有所不同,比如[]int(nil)就可以看做是将nil转化为切片类型。

此外,引用变量因为同样的原因,自然可以与nil进行比较,上面的示例中我同样进行了测试,结果均为true

除了默认初始化上的差异,两者更重要的是在作为参数传递后表现的不同:

package main

import "fmt"

func main() {
	var varArray = [...]int{1, 2, 3}
	var varSlice = []int{1, 2, 3}
	changeArray(varArray)
	changeSlice(varSlice)
	fmt.Println(varArray)
	fmt.Println(varSlice)
}

func changeArray(array [3]int) {
	array[0] = 99
	fmt.Println("after change:", array)
}

func changeSlice(slice []int) {
	slice[0] = 99
}

// after change: [99 2 3]
// [1 2 3]
// [99 2 3]

上面这个例子中,changeArray函数不能改变外部的varArray变量中的值,而changeSlice函数可以,这是因为前者传递的是数组,是传值类型,后者传递的是切片,是引用类型。当然,也可以修改为传递数组的指针而非数组本身,这样同样可以修改数组的内容。

其实切片中就包含了一个数组指针。

希望我通过这几个示例将值类型和引用类型的区别说明白了,这是一个其他语言转过来的程序员会迷糊的地方。

自定义类型

Go语言中可以自定义一个新的变量类型,方式是通过type关键字:

package main

import "fmt"

type Celsius float64 //摄氏温度
func (c Celsius) String() string {
	return fmt.Sprintf("%.1fC", c)
}

type Fahrenheit float64 //华氏温度
func (f Fahrenheit) String() string {
	return fmt.Sprintf("%.1fF", f)
}

func main() {
	zero := Celsius(0)
	fmt.Println(zero)
	fmt.Println(changeC2F(zero))
	boil := Celsius(100)
	fmt.Println(boil)
	fmt.Println(changeC2F(boil))
}

func changeC2F(c Celsius) Fahrenheit {
	return Fahrenheit(9*c/5 + 32)
}

func changeF2C(f Fahrenheit) Celsius {
	c := Celsius(5 * (f - 32) / 9)
	return c
}

这个示例中定义了两个新的类型:摄氏温度Celsius与华氏温度Fahrenheit,两者底层类型都是float64。此外我们还定义了两个转换函数changeC2FchangeF2C,用于将两种类型的变量进行转换。

此外,为了方便格式化输出,还为其定义了String方法以满足fmt.Stringer接口,关于方法和接口的内容将在之后的笔记中进行说明。

关于摄氏温度和华氏温度的更多知识,请阅读华氏温度和摄氏温度如何换算

不止基础类型可以用来定义新类型,事实上我们定义结构体和接口都是通过类似的方式,不过是定义的内容更多罢了。

作用域

变量的作用域在Go语言中相当重要,因为Go的编译器在用变量名访问变量时,会先检索当前作用域中有没有该名称变量的声明,如果没有,就向上检索外边一层的作用域中有没有声明该变量,如果没有就继续向上检索,一直到最外层的包作用域。而声明变量的唯一限制是当前作用域内没有同名变量声明过。

在一般情况下,有过强类型语言编程经验的开发者应该不会遇到作用域的相关问题,但有一些情况就很微妙和特殊,比如下边这段代码:

package main

import "fmt"

func main() {
	var numbers []*int
	for i := 0; i < 10; i++ {
		func() {
			numbers = append(numbers, &i)
		}()
	}
	for _, number := range numbers {
		fmt.Printf("%d ", *number)
	}
}

// 10 10 10 10 10 10 10 10 10 10

这里func(){...}()的写法是定义并执行了一个匿名函数,匿名函数是可以访问外层变量的,所以这里通过匿名函数将循环变量i的指针加入了切片numbers,最后输出的结果是10个10,事实上切片里的10个int型指针都是指向一个循环变量i,该变量在for循环执行完毕后是10,所以输出的结果是10个10。

如果这里稍微修改一下:

package main

import "fmt"

func main() {
	var numbers []*int
	for i := 0; i < 10; i++ {
		i := i
		func() {
			numbers = append(numbers, &i)
		}()
	}
	for _, number := range numbers {
		fmt.Printf("%d ", *number)
	}
}

// 0 1 2 3 4 5 6 7 8 9

这里仅仅在循环体中加入了一行i := i,结果就大为不同了。

相信很多开发者乍一看到类似的写法都会迷惑,为什么会有同一个变量赋值自己这种写法,况且这个变量是已经声明过的循环用的变量。

事实上这里的深层原因是for语句在Go语言中会分割出三个不同的变量作用域,分别是:

  1. for语句外部的作用域。
  2. for语句本身的作用域,即for i := 0; i < 10; i++这段代码的范围,这个示例中循环变量i处于这个作用域中。当然该作用域是被包含在外部作用域中的。
  3. 循环体作用域,即for exp {...}语句中{...}所包含的部分,该作用域是被包含在for语句本身的作用域中的。

理解了for的作用域区分,就可以理解上边i:=i这样的语句,该赋值语句的右侧其实是for语句本身作用域中的循环变量i,而左侧则是循环体中的局部变量i,是一个新声明的变量。

而匿名函数获取外部变量i的规则采取“就近原则”,在这个示例中自然是获取到了循环体中的变量i。而每一次迭代,编译器会将循环体中的代码压入不同层级的代码栈中,循环体中的循环变量自然也处于不同的区域,简单的说,每一次迭代时,循环体变量i都是新创建的,各不相同的变量,所以最后的结果会是0,1,2,3...这样。

在C或者C++中,像上面这种写法是不行的,因为局部变量是静态的,是随代码在栈中创建的,会随着迭代的结束从栈中释放。所以是没法记录循环体中局部变量指针后输出的,因为那时候所有变量已释放。但是Go语言有一个特性,会依据需要在堆或栈中创建局部变量,比如上边示例中的循环体变量i,因为被匿名函数使用,并关联到外部的numbers切片中,所以编译器在编译时会在堆而不是栈中创建该变量,自然也就不会存在循环结束后被释放的问题。这种现象在Go语言中被称为变量逃逸。逃逸后的变量位于堆,会像Java中的垃圾回收机制那样,利用引用计数之类的技术进行追踪是否可以被垃圾回收,如果应当被回收,Go语言的垃圾回收器会自动回收。

除了for语句外,if语句也类似,会分割出三个不同的作用域:

package main

import "fmt"

var test = "package var"

func main() {
	fmt.Println(test)
	var test = "main function var"
	if test := "if syntax var"; true {
		fmt.Println(test)
		test := "if body block var"
		fmt.Println(test)
	}
	fmt.Println(test)
}

// package var
// if syntax var
// if body block var
// main function var

这里我通过图来说明上边这段代码所包含的作用域:

image-20211107142633766

这几种作用域之间是嵌套关系,这样解释应该就能理解我前面说的for作用域的内容了,本质上是相同的。

赋值

除了一般性的赋值语句,Go语言还支持多赋值:

package main

import "fmt"

func main() {
	var a, b, c int = 1, 2, 3
	fmt.Println(a, b, c)
	a, b, c = 4, 5, 6
	fmt.Println(a, b, c)
}

// 1 2 3
// 4 5 6

熟悉Python的人应该不陌生,Python中也可以使用类似的写法,但两者本质上有所区别,Python中a,b,c=1,2,3这样的赋值语句,实际上=右侧的是一个元组或列表这样的序列,左侧则是多个变量,解释器会自动将右侧序列解包后赋值给左侧。

一般来说这么做没有什么歧义,但如果使用短赋值,则可能有一些问题需要注意:

package main

import "fmt"

func main() {
	var a = 1
	a, b := 2, 3
	fmt.Println(a, b)
}

这样是没问题的,因为在使用:=进行声明并赋值的时候,只要左侧多个变量中有一个是之前没有声明过的新变量,这样写就是合法的,解释器会自动将其它已经声明过的变量进行赋值操作,而非声明后赋值。

当然,如果:=左侧都声明过,是不能通过编译的,比如:

package main

import "fmt"

func main() {
	var a = 1
	var b int
	a, b := 2, 3
	fmt.Println(a, b)
}

// Build Error: go build -o C:\Users\70748\AppData\Local\Temp\__debug_bin3053900173.exe -gcflags all=-N -l .\variable9.go
// # command-line-arguments
// .\variable9.go:8:7: no new variables on left side of := (exit status 2)

这里的错误提示说的很明白,:=符号左侧没有新的变量。

但如果在一个里层的作用域里多个变量短赋值的时候,其中一个变量与外侧作用域中的变量同名,编译器会改变外侧作用域变量的值还是声明一个新的局部变量?

package main

import "fmt"

func main() {
	var a = "a in main function"
	if true {
		a, b := "a in if body", "b in if body"
		fmt.Println(a, b)
	}
	fmt.Println(a)
}

// a in if body b in if body
// a in main function

这里的实验说明,无论外部作用域中是否有同名变量,只要当前作用域中还没有声明过该变量,短赋值语句就会声明并赋值变量。

以上就是本篇笔记的全部内容,本来还打算将函数的内容一并介绍,发现介绍的内容已经很多了,就这样吧,谢谢阅读。

往期内容

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值