程序结构
程序结构
命名
Go
语言中的函数名, 变量名, 常量名, 类型名, 语句标号和包名等所有的命名, 都遵循一个简单的命名规则: 一个名字必须以一个字母(Unicode
字母)或下划线开头, 后面可以跟任意数量的字母, 数字或下滑线. 大写字母和小写字母是不同的: heapSort
和HeapSort
是两个不同的名字. 名字的长度没有逻辑限制. 在习惯上, Go
语言程序员推荐使用驼峰式命名, 同时缩略词的中每个字母大小写保持一致.
Go
语言中关键字有25个:
1 | break default func interface select |
Go
大约有30多个预定义的名字:
1 | 内建常量: true false iota nil |
如果一个名字是在函数内部定义, 那么它就只能在函数内部有效. 如果是在函数外部定义, 那么将在当前包的所有文件中都可以访问. 名字的开头字母的大小写决定了名字在包外的可见性. 如果一个名字是大写字母开头的, 那么它将是导出的, 也就是说可以被外部的包访问.
声明
Go
语言主要有四种类型的声明语句: var
, const
, type
和func
, 分别对应变量, 常量, 类型和函数实体对象的声明. 程序中包一级的声明一般按照类型, 变量, 常量, 函数的声明语句.
变量
var
声明语句可以创建要给特定类型的变量. 一般声明语法如下:
1 | var 变量名称 类型 = 表达式 |
如果省略的是类型信息, 那么将根据初始化表达式来推导变量的类型信息. 如果初始化表达式被省略, 那么将用零值初始化该变量. 数值类型变量对应的零值是0
, 布尔类型变量对应的零值是false
, 字符串类型对应的零值是空字符串, 接口或引用类型(包括slice
, map
, chan
和函数)变量对应的零值是nil
. 数组或结构体等聚合类型对应的零值是每个元素或字段都是对应该类型的零值.
在包级别声明的变量会在main
入口函数执行前完成初始化, 局部变量将在声明语句被执行到的时候完成初始化.
短变量声明
在函数内部, 有一种称为短变量声明语句的形式可用于声明和初始化局部变量. 它以名字:=表达式
形式声明变量, 变量的类型根据表达式来自动推导.
因为简单和灵活的特点, 短变量声明被广泛用于局部变量的声明和初始化. var
形式的声明语句往往是用于需要显式指定变量类型的地方, 或者因为变量稍后会被重新赋值而初始值无关紧要的地方.
如果有一些已经在相同的词法域声明过了, 那么短变量声明语句对这些已经声明过的变量就只有赋值行为. 短变量声明语句中至少要声明一个新的变量.
指针
一个指针的值是是一个变量的地址. 并不是每一个值都会有一个内存地址, 但是对于每个变量必然有对应的内存地址. 通过指针, 我们可以直接读或更新对应变量的值, 而不需要知道变量的名字.
如果指针对应的数据类型是*int
, 指针被称之为”指向int
类型的指针”. 如果指针名字为p
, 那么可以说”p
指针指向变量x
“ , 或者说”p
指针保存了x
变量的内存地址”.
任何类型的指针的零值都是nil
. 如果p != nil
测试为真, 那么p
是指向某个有效变量. 指针之间也是可以进行相等测试的, 只有当它们指向同一个变量或全部都是nil
时才相等.
在Go
语言中, 返回函数中局部变量的地址也是安全的.
new
函数
另一个创建变量的方式是调用内建的new
函数. 表达式new(T)
将创建要给T
类型的匿名变量, 初始为T
类型的零值, 然后返回变量地址, 返回的指针类型为*T
.
用new
创建变量和普通变量声明语句方式创建变量没有什么区别, 除了不需要声明一个临时变量.
请谨慎使用大小为0
的类型, 因为如果类型的大小为0
的话, 可能导致Go
语言的自动垃圾回收器有不同的行为.
变量的生命周期
变量的生命周期指的是在程序运行期间变量有效存在的时间间隔. 对于在包一级声明的变量来说, 它们的生命周期和整个程序的运行周期是一致的. 而相比之下, 在局部变量的生命周期则是动态的: 从每次创建一个新变量的声明语句开始, 直到该变量不再被引用为止, 然后变量的存储空间可能被回收. 函数的参数变量和返回值都是局部变量. 它们在函数每次被调用的时候创建.
函数的有右小括弧也可以另起一行缩进, 同时为了防止编译器在行尾自动插入分号而导致的编译错误, 可以在末尾的参数变量后面显式插入逗号.
Go
语言的自动垃圾收集器是如何知道一个变量时何时可以被回收的呢? 基本的实现思路是, 从每个包级的变量和每个当前运行函数的每一个局部变量开始, 通过指针或引用的访问路径遍历, 是否可以找到该变量. 如果不存在这样的访问路径, 那么说明该变量时不可达的.
编译器会自动选择在栈上还是在堆上分配局部变量的存储空间, 这个选择并不是由用var
还是new
声明变量的方式决定的.
1 | var global *int |
f
函数里的x
变量必须在堆上分配, 因为它在函数退出后依然可以通过包一级的global
变量找到, 虽然它是在函数内部定义的; 用Go
语言的术语来说, 这个x
局部变量从函数f
中逃逸了. 相反, 当g
函数返回时, 变量*y
将是不可达的, 也就是说可以马上被回收. 因此, *y
并没有从函数g
中逃逸, 编译器可以选择在栈上分配*y
的存储空间(译注: 也可以选择在堆上分配, 然后由Go
语言的GC
回收这个变量的内存空间), 虽然这里用的是new
方式. 其实在任何时候, 你并不需要为了编写正确的代码而要考虑变量的逃逸行为, 要记住的是, 逃逸的变量需要额外分配内存, 同时对性能的优化可能产生细微的影响.
Go
语言的自动垃圾收集器对编写正确的代码是一个巨大的帮助, 但也不是说你完全不用考虑内存了. 你虽然不需要显式地分配和释放内存, 但是要编写高效的程序你依然需要了解变量的生命周期. 例如, 如果将指向短生命周期对象保存到具有长生命周期的对象中, 特别是保存到全局变量时, 会阻止对短生命周期对象的垃圾回收(从而可能影响程序的性能).
赋值
使用赋值语句可以更新一个变量的值.
元组赋值
元组赋值是另一种形式的赋值语句, 它允许同时更新多个变量的值. 在赋值之前, 赋值语句右边的所有表达式将会先进行求值, 然后再统一更新左边对应变量的值. 这对于处理有些同时出现在元组赋值语句左右两边的变量很有帮助, 例如我们可以这样交换两个变量的值:
1 | x, y = y, x |
有些表达式会产生多个值, 比如调用一个有多个返回值的函数. 当这样一个函数调用出现在元组赋值右边的表达式中时(右边不能再有其它表达式), 左边变量的数目必须和右边一致.
通常, 这类函数会用额外的返回值来表达某种错误类型, 例如os.Open
是用额外的返回值返回一个error
类型的错误, 还有一些是用来返回布尔值, 通常被称为ok
.
和变量声明一样, 我们可以用下划线空白标识符_
来丢弃不需要的值.
可赋值性
赋值语句是显式的赋值语句, 但是程序中还有很多地方会发生隐式的赋值行为: 函数调用会隐式地将调用参数的值赋值给函数的参数变量, 一个返回语句将隐式地将返回操作的值赋值给结果变量, 一个复合类型的字面字面量也会产生赋值行为.
不管是隐式还是显式地赋值, 在赋值语句左边的变量和右边最终求得的值必须有相同的数据类型. 更直白地说, 只有右边的值对于左边地变量时可赋值的, 赋值语句才是允许的.
对于两个值是否可以用==
或!=
进行相等比较的能力也和可赋值能力有关系: 对于任何类型的值的相等比较, 第二个值必须是对第一个变量值类型对应的变量是可赋值的, 反之亦然.
类型
变量或表达式的类型定义了对应存储值的属性特征, 例如数值在内存中的存储大小(或者是元素的bit
个数), 它们在内部是如何表达的, 是否支持一些操作符, 以及它们自己关联的方法集等.
一个类型声明语句创建了一个新的类型名称, 和现有类型就有相同的底层结构. 新命名提供了一种方式, 用来分隔不同概念的类型, 这样即使它们底层类型相同也是不兼容的.
1 | type 类型名称 底层类型 |
由于对于中文汉字, Unicode
标志都是作为小写字母处理, 因此中文的命名默认不能导出.
对于每一个类型T
, 都有一个对应的类型转换操作T(x)
, 用于将x
转为T
类型(如果T
是指针类型, 可能会需要用小括弧包括T
, 比如(*int)(0)
). 只有当两个类型的底层基础类型相同时, 才能允许这种转型操作, 或者是两者都是指向相同底层结构的指针类型, 这些转换只改变类型而不会影响值本身.
底层数据类型决定了内部结构和表达方式, 也决定是否可以像底层类型一样对内置运算符的支持.
通过==
和<
之类的比较操作符, 命名类型的值可以与其相同类型的值或者底层类型相同的未命名类型的值相比较.
包和文件
Go
语言中的包和其他语言的库或模块的概念类似, 目的都是为了支持模块化, 封装, 单独编译和代码重用. 每个包都对应一个独立的命名空间.
在每个源文件的包声明前紧跟着的注释是包注释. 通常, 包注释的第一句应该是包的功能概要说明. 一个包通常只有一个源文件有包注释.
导入包
在Go
程序语言中, 每个包都是有一个全局唯一的导入路径. 导入语句中类似gopl.io/ch2/tempconv
的字符串对应包的导入路径. Go
语言的规范并没有定义这些字符串的具体含义或包来自哪里, 它们是由构建工具来解释的. 当使用Go
语句自带的go
工具箱时, 一个导入路径代表一个目录中的一个或多个Go
源文件.
除了包的导入路径, 每个包还有一个包名, 包名一般是短小的名字(并不要求包名是唯一的), 包名在包的声明处指定. 按照惯例, 一个包的名字和包的导入路径的最后一个字段相同.
导入语句将导入的包绑定到一个短小的名字, 然后通过该短小的名字就可以引用包中导出的内容. 在默认情况下, 导入的包绑定到包声明语句指定的名字, 但是我们也可以绑定到另一个名字, 以避免名字冲突.
如果导入了一个包, 但是又没有使用该包将被当作一个编译错误处理.
包的初始化
包的初始化首先是解决包级变量的依赖顺序, 然后按照包级变量声明出现的顺序依次初始化.
如果包中包含多个.go
源文件, 它们将按照发给编译的顺序进行初始化, Go
语言的构建工具首先会将.go
文件根据文件名排序, 然后依次调用编译器编译.
对于在包级别声明的变量, 如果有初始化表达式则用表达式初始化, 还有一些没有初始化表达式的. 在这种情况下, 我们可以用一个特殊的init
初始化函数来简化初始化工作. 每个文件都可以包含多个init
初始化函数
1 | func init() { |
这样的init
初始化函数除了不能被调用或引用之外, 其他行为和普通函数类似. 在每个文件中的init
初始化函数, 在程序开始执行时按照它们声明的顺序被自动调用.
每个包在解决依赖的前提下, 以导入声明的顺序初始化, 每个包只会被初始化一次. 初始化工作是自下而上进行的, main
包最后被初始化. 以这种形式, 可以确保在main
函数执行之前, 所有依赖的包都已经完成初始化工作了.
1 | // 循环的一种写法 |
作用域
一个声明语句将程序中的实体和一个名字关联, 比如一个函数或一个变量. 声明语句的作用域是指源代码中可以有效使用这个名字的范围.
不要将作用域和生命周期混为一谈. 声明语句的作用域对应的是一个源代码的文本区域; 它是一个编译时的属性. 一个变量的生命周期是指程序运行时变量存在的有效时间段, 在此时区域内它可以被程序的其他部分引用; 是一个运行时的概念.
声明语句对应的词法域决定了作用域范围的大小. 对于内置的类型, 函数和常量, 比如int
, len
和true
等是在全局作用域的, 因此可以在整个程序中直接使用. 任何在函数外部(也就是包级语法域)声明的名字可以在同一个包的任何源文件中访问. 对于导入的包, 则是对应源文件级的作用域, 因此只能在当前的文件中访问导入的包, 当前包的其他源文件无法访问在当前源文件导入的包. 还有许多声明语句, 例如函数中的变量, 则是局部作用域, 它只能在函数内部(甚至只能是局部的某些部分)访问.
当编译器遇到一个名字引用时, 将从最内层的词法域向全局的作用域查找其声明. 如果查找失败, 则报告undeclared name
错误; 如果在内层和外层块都存在这个声明, 内层的将先被找到. 在这种情况下, 内层声明将覆盖外部声明, 使它不可访问.
在包级别, 声明的顺序和它们的作用域没有关系, 所以一个声明可以它自己或者跟在它后面的其他声明, 使我们可以声明递归或相互递归的类型和函数. 如果常量或变量声明引用它自己, 则编译器会报错.
常用的函数
1 | 读入命令行参数 |