第2章 程序结构
Go语言和其它语言一样,一个大的程序是由很多程序构件组成的。变量保存值,各种运算组成表达式,基础类型被聚合成为结构体、数组等更复杂的数据类型。然后使用for和if来组织和控制表达式的执行流程,然后多个语句被组织到一个函数中,以便代码的隔离和复用。函数以源文件和包的方式被组织
2.1 命名
Go语言中变量名、函数名、常量名、类型名、语句标号和包名等所有命名都遵循两条基本的准则:
以字母或者下划线开头,大写意味着可以导出,也就是说可以被外部包访问
区分大小写
Go语言中有25个关键字,关键字只能在特定的语法结构中使用,不能另作他用,这些关键字的用法,我们在后面都会讲到
除此之外,我们还有30个预定义的名字,比如int和true等,主要对用内建的常量、类型和函数
内建常量:true、false、iota、nil
内建类型:int、int8…; uint8,uint…;float32…; bool,byte,rune,string,error
内建函数:make 、len、cap、new、append、copy、close、delete、complex、real、imag、panic、recover
Go语言推荐使用驼峰式命名
2.2 声明
声明语句定义了程序的各种实体对象以及部分或者全部的属性。它主要有四种类型的声明:var、const、type和func,分别对应变量、常量、类型和函数实体
一个Go语言编写的程序对应一个或者多个.go结尾的源文件。每个源文件以包的声明语句开始,说明该源文件属于哪个包。包声明语句后是依赖包的导入,然后再是包一级别的变量、类型、函数、常量的声明语句,如下:
package main
import "fmt"
const boilingF = 212.0
func main() {
var f = boilingF
var c = (f-32)*5/9
fmt.Printf("boiling point = %v℃ or %vF\n",f,c)
}
分析代码:
我们先在包一级别声明了一个常量
const boilingF = 212.0
接着我们在函数体内部定义了两个变量,这种在局部定义的变量只能在函数内部调用
var f = boilingF
var c = (f-32)*5/9
函数的结构:函数的声明由函数名、参数列表(由函数的调用者提供参数变量的具体值)、一个可选的返回值列表、函数体组成。函数可以没有返回值,函数执行是按照函数体中语句顺序执行,遇到return会返回,如果没有返回语句,则执行到函数末尾,然后返回给函数调用者
func main() {
const freezingF, boilingF = 32.0,212.0
fmt.Printf("%v℉ = %v℃\n",freezingF,ftoC(freezingF))
fmt.Printf("%v℉ = %v℃\n",boilingF,ftoC(boilingF))
}
func ftoC(f float64)float64{
return (f-32)*5/9
}
我们可以定义一个函数(执行逻辑),然后在别处多次调用
2.3 变量
变量声明的语法如下:
//var 变量名字 类型 = 表达式
var a = 45
var b int
fmt.Println(a,b)//45 0
在声明的时候,= 和 类型 可以任选一种,若选择= ,则是初始化变量,若选择 int ,则选择改类型的默认值;数值类型的是0,字符串类型的是空字符串,布尔类型的是false,接口和引用类型(包括slice、指针、map、chan和函数)变量对应的零值是nil
同时声明一组相同的变量和不同的变量
var i,k,j int
var d,f,s = true, 3.2, "four"
fmt.Println(i,k,j,d,f,s)//0 0 0 true 3.2 four
变量的初始化也可以通过调用函数,让函数的返回值完成赋值
var f,err = os.Open(fileName)
2.3.1 简短变量声明
简短变量声明不使用var 关键字,用下列格式。因为简单方便,简短声明被广泛应用于局部变量的声明和初始化
变量名 := 表达式
anim := gif.GIF{LoopCount: nframes}
fred := rand.Float64()*3.0
t := 0.0
同时声明一组相同的变量和不同的变量
i , j := 0,1
a, b := 2, true
变量的初始化也可以通过调用函数,让函数的返回值完成赋值
f, err := os.Open(fileName)
if err != nil{
return err
}
f.Close()
2.3.2 指针
一个变量对应一个保存了变量对应类型值的内存空间。普通变量在声明语句创建时被绑定到一个变量名。
一个指针的值是另一个变量的地址。一个指针对应变量在内存中的存储位置。并不是每一个值都会有一个内存地址,但是对于每一个变量必然有对应的内存地址。指针可以直接读更新变量的值,而不需要知道该变量的名字(如果变量有名字的话)
变量x的指针为&x,指针对应的数据类型为*变量类型。如果指针名称为p,那么可以说“p指针指向变量x”,或者说“p保存了变量x的内存地址”,同时 *p表达式对应p指针指向的变量值
x := 1
p := &x
*p = 2
fmt.Println(p, *p) //0xc00001e090 2
对于聚合类型的每个成员,如结构体中的每个成员、或者数组的每个元素,也都是对应一个变量,因此可以被取地址
任何类型的指针的零值都是nil,并且可以进行相等测试
var x, y int
fmt.Println(x==y,&x==&y)//true false
在Go函数中,返回函数中局部变量的地址也是安全的,每次在main函数中调用就会重新创建一个变量v,如:
func main() {
fmt.Println(f())//0xc000100010
}
var p = f()
func f() *int{
v:=1
return &v
}
使用指针可以实现值的更新,事实上*p就是v的别名,除了指针,其他类型也会创建别名,slice、map和chan,甚至结构体、数组和接口都会创建所引用变量的别名
func main() {
v := 1
incr(&v)
fmt.Println(incr(&v))//3
}
func incr(p *int) int{
*p++
return *p
}
指针是实现标准库中flag包的关键技术,它使用命令行参数来设置对应变量的值,而这些命令行标志参数的变量可能会零散分布在整个程序中
2.3.3 new函数
另一个创建变量的方法是调用内建的new函数
表达式 new(T) 将创建一个T类型的匿名变量,初始化为T类型的零值,然后返回变量地址,返回的指针类型为*T,如下:
p := new(int)
var q int
x := &q
fmt.Println(p,q,*p,*x)//0xc0000b2008 0xc0000b2010 0 0
*p=2
*x = 4
fmt.Println(*p,*x)//2 4
使用new函数就省略了将非匿名变量的指针创建为一个新临时变量,相对于来说更加简单,如上比较:另外,我们可以在表达式中使用new(T),也就是说,new函数是一种语法糖,而不是一个新的基础概念,如下函数两个函数其实实现了同一种功能,并且每次调用都会创建新的值
func main() {
p := newInt1()
q := newInt2()
fmt.Println(p,q)//0xc00001e090 0xc00001e098
}
func newInt1() *int{
return new(int)
}
func newInt2() *int{
var dummy int
return &dummy
}
new函数通常使用还是比较少的,它只是一个预定义函数,并不是关键字,我们可以将new名字重新定义为别的类型,并且在如下函数中,因为new已经被定义成为了int类型,所以也无法在delta函数内部使用内置的new函数
func delta(old, new int) int {return new - old}
2.3.4 变量的生命周期
在包一级声明的变量它们的生命周期和整个陈旭运行周期是一致的,局部变量则是从它创建开始到不再被引用为止,然后变量的存储空间又可能被回收。函数参数和返回值都是局部变量,它们都是在函数每次被的调用时创建,前面几个例子大家也看到了
那么Go语言的自动垃圾收集器是如何知道一个变量是何时可以被回收的呢?让我们忽略技术细节,介绍一下思路:从每个包级的变量和每个当前运行函数的每一个局部变量开始,通过指针或者引用的访问路径遍历,是否可以找到该变量。如果路径不存在,那么说明该变量是不可达的,也就时说它的存在与否并不会影响程序的后续计算结果
因为一个变量的有效周期只取决于是否可达,因此一个循环迭代内部的局部变量的生命周期可能超出其作用域。同时,局部变量可能在函数返回之后依然存在
编译器会自动选择在栈上还是堆上分配局部变量的存储空间,但这个和用var还是new声明变量的方式无关
var global *int
func f() {
var x int
x= 1
global = &x
}
func g() {
y :=new(int)
*y = 1
}
f函数里的x变量必须在堆上分配,因为它在函数退出后依然可以通过包一级global变量找到,虽然它是内部定义的,但是它逃逸了,相反g函数中的变量*y可以被马上回收。逃逸的变量并不是说会导致程序不正确,而是会需要额外分配内存,对程序的性能优化会产生细微影响
所以尽管Go语言的垃圾自动回收机制对编写代码是一个巨大的帮助,但是并不是说你完全不用考虑内存了,这一点需要在了解了变量的声明周期之后注意
2.4 赋值
赋值即更新一个变量的值,最简单的赋值语句便是变量在 = 左边,新值的表达式在 = 右边
x = 1
*p = true
person.name = "bob"
count[x] = count[x]*scale
特定的二元算术运算符和赋值语句的复合操作有一个简介形式,如上面的最后语句可以写为
count[x] *= scale
数值变量也可以支持++和–,而 x = i++ 之类的表达式是错误的
2.4.1 元组赋值
元组赋值允许同时更新多个变量的值(这个其实我们在前面已经实例中有涉及了)
x,y,z = 2,3,5
x, y = y, x
a[i], a[j] = a[j], a[i]
//求最大公约数
func gcd(x,y int) int{
for y != 0{
x,y = y, x%y
}
return x
}
//计算斐波那契数列
func fib(n int) int{
x,y := 0,1
for i:=0;i<n;i++ {
x,y = y, x+y
}
return x
}
在将函数的返回值赋给变量时,通常会有多个返回值(有很多情况时一个操作成功值,一个错误值)
v,ok = m[key]//map查找
v,ok = x.(T)//类型断言
v,ok = <-ch//痛到接收
如果函数返回值有多个,但是我们却并不需要,可以使用 _ 丢弃
-, err = io.Copy(dst, src)
-, ok = x.(T)
2.4.2 可赋值性
赋值语句是显式的赋值形式,但是程序中还有很多地方会发生隐式的赋值行为:
函数调用会将调用参数的值赋值给函数参数变量
一个返回语句会隐式的将返回操作的值赋值给结果变量
一个复合类型的字面量也会产生赋值行为
medals := []string{ "gold","sliver","bronze"}
medals[0] = "gold"
medals[1] = "sliver"
medals[2] = "bronze"
map 和 chan 的元素,虽然不是普通的变量,但是也有类似的隐式赋值行为
不管是隐式还是显式地赋值,在赋值语句左边的变量和右边最终的求到的值必须有相同的数据类型
2.5 类型
变量或者表达式的类型定义了对应的存储值的属性特征,例如数值在内存的存储大小,它们在内部是如何表达的,是否支持一些操作符,以及它们自己关联的方法集等
不同程序或许内部结构相同,但是却表示完全不同的概念,变量更是如此
类型的声明一般在包一级,类型的首字母大写则意味着外部包也可以使用
type 类型名字 底层类型
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) }
虽然类型Celsius和Fahrenheit基础类型相同,但是他们是不同的数据类型,所以不可以比较或者混在一个表达式运算,但是两个基础类型相同的类型是可以相互显式转换的,底层数据类型决定了内部结构和表达方式,也决定是否可以像底层类型一样对内置运算符的支持,承接上面的案例:
func main() {
var c Celsius
var f Fahrenheit
fmt.Println(c == 0)//true
fmt.Println(f > 0)//false
fmt.Println(c==f)//mismatched types Celsius and Fahrenheit
fmt.Println(c==Celsius(f))//true
}
命名类型可以让书写更加方便;命名类型还可以为该类型的值定义新的行为(这些行为表示为一组关联到该类型的函数集合,我们称为类型方法集)如:
func (c Celsius) String() string {return fmt.Sprintf("%g˚C",c)}
许多类型都会定义一个String方法,使用fmt包打印
c := FToC(212.0)
fmt.Println(c.String())//100˚C
fmt.Printf("%v\n",c)//100˚C
2.6 包和文件
Go语言中的包和其他语言中的库或模块的概念类似,目的都是为了支持模块化、封装、单独编译和代码重用
每个包都有一个独立的名字空间
包可以让我们通过控制哪些名字是外部可见的来隐藏内部实现信息
我们来定义一个包,这个包的功能是实现温度转换
包名:tempconv,包里包含两个.go源文件,内容分别如下
package tempconv
import "fmt"
type Celsius float64
type Fahrenheit float64
const (
AbsoluteZeroC Celsius = -273.15
FreezingC Celsius = 0
BoilingC Celsius = 100
)
func (c Celsius) String() string { return fmt.Sprintf("%g˚C", c) }
func (f Fahrenheit) String() string { return fmt.Sprintf("%g˚F", f) }
func CToF(c Celsius) Fahrenheit { return Fahrenheit(c*9/5 + 32) }
func FToC(f Fahrenheit) Celsius { return Celsius((f - 32) * 5 / 9) }
在main函数中调用
package main
import (
"awesomeProject/src/tempconv"//这里可以看到我们定义的包的路径
"fmt"
)
func main() {
fmt.Printf("Brrrr! %v\n", tempconv.AbsoluteZeroC)//Brrrr! -273.15˚C//随便调用了tempconv包下的一个常量
}
2.6.1 导入包
在Go语言程序中,每一个包都是有一个全局唯一的导入路径,类似 “awesomeProject/src/tempconv” 的字符串对应包的导入路径,每个包都有名字,在声明时就已经指定
2.6.2 包的初始化
包的初始化首先是解决包级别变量的依赖顺序,然后按照包级别变量声明出现的顺序依次初始化
如果包中包含多个.go源文件,它们将按照发给编译器的顺序进行初始化,Go语言的构建工具首先会将.go文件根据文件名排序名,然后依次调用编译器编译器
对于在包级别生命的变量,如果有初始化表达式则用表达式初始化,还有一些没有初始化表达式,例如某些表格数据初始化并不是一个简单的赋值过程,在这种情况下,我们可以用一个特殊init初始化函数来简化初始化工作,每个文件都可以包含多个init函数
func init() {/* ... */}
这样的init函数除了不能被调用或者引用外,其它行为和普通函数类似
package popcount
var pc [256]byte
func init() {
for i := range pc {
pc[i] = pc[i/2] + byte(i&1)
}
}
func PopCount(x uint64) int {
return int(pc[byte(x>>(0*8))] +
pc[byte(x>>(1*8))] +
pc[byte(x>>(2*8))] +
pc[byte(x>>(3*8))] +
pc[byte(x>>(4*8))] +
pc[byte(x>>(5*8))] +
pc[byte(x>>(6*8))] +
pc[byte(x>>(7*8))])
}
对于pc这类需要复杂处理的初始化,可以通过将初始化逻辑包装为一个匿名函数处理,如下
var pc [256]byte = func() (pc [256]byte) {
for i := range pc {
pc[i] = pc[i/2] + byte(i&1)
}
return
}()
2.7 作用域
作用域和生命周期不同,它是值一块代码文本区域
句法块是由花括号包含的一系列语句,其内部声明无法被外部访问。一些声明在代码中未显式的使用花括号包裹起来叫词法块,对于全局代码来说,存在一个整体的词法块,称为全局词法块;对于每个包;每个for、if和switch语句,也都对应词法块,每个select 或者switch 分支也有独立的语法块,当然也包括显式书写的词法块
对于内置的类型、函数和常量是在全局作用域的,控制流标号:break、continue、goto,则是函数级作用域
一个程序可能包含多个同名声明,但只要它们在不同的词法域就没关系
编译器首先会从最内层的词法域像全局的作用域查找,内部的声明会屏蔽外部的声明,让其无法访问,词法域还可以深度嵌套
如下三个x,每个声明在不同的词法域,一个在函数体词法域,一个在初始化词法域,一个在循环体词法域
func main(){
x := "hello"
for _, x:= range x {
x:= x+ 'A' - 'a'
fmt.Printf("%c",x)
}
}
if和switch语句也会在条件部分创建隐式词法域
到此,我们已经看到包、文件、声明和语句如何来表达一个程序结构,接下来,我们会探讨数据结构