Go修炼手册——程序结构
关于笔者学习Go语言中零零散散做的一些笔记。我用的参考资料是《Go语言圣经》,附上项目地址Go语言圣经 (golang-china.github.io)。感兴趣的话可以自行阅读。
程序结构
声明
有四种类型的声明语句:var、const、type和func,分别对应变量、常量、类型和函数实体对象的声明。
每个源文件中以包的声明语句开始,说明该源文件是属于哪个包。包声明语句之后是import语句导入依赖的其它包,然后是包一级的类型、变量、常量、函数的声明语句,包一级的各种类型的声明语句的顺序无关紧要
一个函数的声明由一个函数名字、参数列表(由函数的调用者提供参数变量的具体值)、一个可选的返回值列表和包含函数定义的函数体组成。如果函数没有返回值,那么返回值列表是省略的。
比如声明一个函数。
func fToC(f float64) float64{ |
顺便一提在Go语言中无须以分号结尾,事实上每个go文件在编译时都会被自动格式化而在每一行的末尾自动加上分号。
变量
变量的声明语法一般如下
var varietyName Type = expression //变量名 类型 表达式 |
实际上实际上“类型”或者“=表达式”两个部分可以省略其中的一个。如果省略类型,那么将根据初始化表达式来推导变量的类型信息。如果初始化表达式被省略,那么将用零值初始化该变量。
数值类型变量对应的零值是0,布尔类型变量对应的零值是false,字符串类型对应的零值是空字符串,接口或引用类型(包括slice、指针、map、chan和函数)变量对应的零值是nil。数组或结构体等聚合类型对应的零值是每个元素或字段都是对应该类型的零值。
具体的例子可以参考如下。
var i,j,k int //int,int,int 一组变量只声明类型 |
或者使用“简短变量声明”来声明和初始化局部变量。
t := 0.0 |
使用简短变量声明必须要声明一个新的变量。对于已经声明过的变量,简短语句声明和多重赋值操作等价。
指针
和C概念类似,用形如*int
来声明一个指针变量。
var x = 1 |
任何类型的指针零值都是nil
。
在Go语言中,返回函数中局部变量的地址也是安全的。
例如下面的代码,调用f函数时创建局部变量v,在局部变量地址被返回之后依然有效,因为指针p依然引用这个变量。用Go语言的术语来说,这个变量从函数f中逃逸了。
var p = f() |
对于在包一级声明的变量来说,它们的生命周期和整个程序的运行周期是一致的。而相比之下,局部变量的生命周期则是动态的:每次从创建一个新变量的声明语句开始,直到该变量不再被引用为止,然后变量的存储空间可能被回收。函数的参数变量和返回值变量都是局部变量。它们在函数每次被调用的时候创建。
Go语言的自动垃圾收集器是如何知道一个变量是何时可以被回收的呢?这里我们可以避开完整的技术细节,基本的实现思路是,从每个包级的变量和每个当前运行函数的每一个局部变量开始,通过指针或引用的访问路径遍历,是否可以找到该变量。如果不存在这样的访问路径,那么说明该变量是不可达的,也就是说它是否存在并不会影响程序后续的计算结果。
因为一个变量的有效周期只取决于是否可达,因此一个循环迭代内部的局部变量的生命周期可能超出其局部作用域。同时,局部变量可能在函数返回之后依然存在。
编译器会自动选择在栈上还是在堆上分配局部变量的存储空间,但可能令人惊讶的是,这个选择并不是由用var还是new声明变量的方式决定的。也就是说,你并不需为了编写正确的代码而要考虑变量的逃逸行为,要记住的是,逃逸的变量需要额外分配内存,同时对性能的优化可能会产生细微的影响
new函数
另一个创建变量的方法是调用内建的new函数。表达式new(T)将创建一个T类型的匿名变量,初始化为T类型的零值,然后返回变量地址,返回的指针类型为 *T 。
用new函数创建变量和普通变量声明没有区别,这是一个语法糖。
语法糖(Syntactic Sugar)是一种编程术语,它指的是在编程语言中添加的某种语法,这种语法对语言的功能并没有影响,但是可以使代码更易读或更易写。换句话说,语法糖让代码更加“甜”,更加愉快和方便地去编写和理解。
类型
在Go语言中,可以用type
关键字定义新的类型。一个类型声明语句创建了一个新的类型名称,和现有类型具有相同的底层结构。新命名的类型提供了一个方法,用来分隔不同概念的类型,这样即使它们底层类型相同也是不兼容的。
类型声明语句一般出现在包一级,因此如果新创建的类型名字的首字符大写,则在包外部也可以使用。
Go语言中的type
关键字和C语言中的typedef
关键字有一些相似之处,都可以用来定义新的类型。但是,Go语言的type
关键字功能更强大,因为它不仅可以定义基础类型的别名,还可以定义结构体、接口等复杂类型。
在C语言中,typedef
关键字常常用来为复杂的数据类型定义别名,使代码更简洁、更易读。例如,你可以使用typedef
定义一个结构体类型的别名:
typedef struct { |
在这个例子中,Person
是一个新的类型,它是一个结构体类型,包含了name
和age
两个字段。
然而,C语言的typedef
只能定义别名,不能创建新的类型。在上面的例子中,Person
实际上和匿名的struct {char* name; int age;}
是同一种类型。但在Go语言中,使用type
关键字定义的新类型是真正的新类型,而不仅仅是别名。例如:
type MyInt int |
在这个例子中,MyInt
是一个新的类型,它的底层类型是int
。虽然你可以像使用int
一样使用MyInt
,但是它们是不同的类型,不能直接互相赋值。
所以,尽管Go的type
关键字和C的typedef
关键字在某些方面有相似之处,但Go的type
关键字更强大,可以创建真正的新类型。
转型操作
对于每一个类型T,都有一个对应的类型转换操作T(x),用于将x转为T类型(译注:如果T是指针类型,可能会需要用小括弧包装T,比如 (*int)(0) )。只有当两个类型的底层基础类型相同时,才允许这种转型操作,或者是两者都是指向相同底层结构的指针类型,这些转换只改变类型而不会影响值本身。如果x是可以赋值给T类型的值,那么x必然也可以被转为T类型,但是一般没有这个必要。
自定义方法集
在 Go 语言中,我们可以为自定义类型(包括基本类型的别名、结构体类型等)定义方法,这样这个类型就有了一组相关的行为,这一点在很多面向对象的语言中,例如 Java 或 C++,是通过类来实现的。然而,Go 并没有类的概念,它通过在类型上定义方法来实现面向对象编程的一些特性。
在 Go 语言中,一个类型的方法集就是所有附加在该类型上的方法。例如,如果我们有一个自定义类型 MyInt
:
type MyInt int |
我们可以为 MyInt
定义一个方法 Add
:
func (m MyInt) Add(other MyInt) MyInt { |
在这个例子中,Add
是 MyInt
类型的一个方法,它接受一个 MyInt
类型的参数,返回两个 MyInt
类型的值的和。现在,MyInt
类型的方法集包含了 Add
方法。
这样,我们就可以像这样使用 MyInt
:
var a MyInt = 10 |
这里的 Add
就是 MyInt
类型的一个方法,它定义了 MyInt
类型的一种行为。
这种方式使得 Go 语言可以实现一些面向对象编程的特性,如封装、多态等,而无需引入类的概念。
(简直是typedef和class的结合体)
包和文件
Go语言中的包和其他语言的库或模块的概念类似,目的都是为了支持模块化、封装、单独编译和代码重用。
一个包的源代码保存在一个或多个以.go为文件后缀名的源文件中,通常一个包所在目录路径的后缀是包的导入路径;例如包gopl.io/ch1/helloworld对应的目录路径是$GOPATH/src/gopl.io/ch1/helloworld。
每个包都对应一个独立的名字空间。例如,在image包中的Decode函数和在unicode/utf16包中的Decode函数是不同的。要在外部引用该函数,必须显式使用image.Decode或utf16.Decode形式访问。
包还可以让我们通过控制哪些名字是外部可见的来隐藏内部实现信息。在Go语言中,一个简单的规则是:如果一个名字是大写字母开头的,那么该名字是导出的。
在每个源文件的包声明前紧跟着的注释是包注释(§10.7.4)。通常,包注释的第一句应该先是包的功能概要说明。一个包通常只有一个源文件有包注释(译注:如果有多个包注释,目前的文档工具会根据源文件名的先后顺序将它们链接为一个包注释)。如果包注释很大,通常会放到一个独立的doc.go文件中。
包的初始化
包的初始化首先是解决包级变量的依赖顺序,然后按照包级变量声明出现的顺序依次初始化:
var a = b + c // a 第三个初始化, 为 3 |
如果包中含有多个.go源文件,它们将按照发给编译器的顺序进行初始化,Go语言的构建工具首先会将.go文件根据文件名排序,然后依次调用编译器编译。
Go语言使用一个叫做"初始化队列"的机制来管理这个过程。在编译时,Go语言会创建一个初始化队列,其中包含了所有的全局变量和它们的依赖关系。然后,在运行时,Go语言会按照这个队列的顺序来初始化全局变量。如果一个变量的依赖还没有被初始化,那么Go语言会先初始化那个依赖。
这是一个非常强大的特性,因为它使得开发者可以在全局变量的初始化代码中使用复杂的逻辑,而不需要担心初始化的顺序。
对于在包级别声明的变量,如果有初始化表达式则用表达式初始化,还有一些没有初始化表达式的,例如某些表格数据初始化并不是一个简单的赋值过程。在这种情况下,我们可以用一个特殊的init初始化函数来简化初始化工作。每个文件都可以包含多个init初始化函数
func init() { /* ... */ } |
这样的init初始化函数除了不能被调用或引用外,其他行为和普通函数类似。在每个文件中的init初始化函数,在程序开始执行时按照它们声明的顺序被自动调用。(有点像php中的__construct()魔术方法?)
每个包在解决依赖的前提下,以导入声明的顺序初始化,每个包只会被初始化一次。因此,如果一个p包导入了q包,那么在p包初始化的时候可以认为q包必然已经初始化过了。初始化工作是自下而上进行的,main包最后被初始化。以这种方式,可以确保在main函数执行之前,所有依赖的包都已经完成初始化工作了。
在 C 语言中,
#include
指令会直接将被包含的文件内容复制到当前位置,如果一个头文件被多次包含,那么它的内容就会被多次复制,可能会导致重复定义的问题。为了避免这个问题,C 语言的头文件通常会使用预处理宏来防止重复包含:
// ... header file content ...在这个例子中,
HEADER_FILE
是一个预处理宏,如果它没有被定义,那么头文件的内容就会被包含,否则就会被忽略。这样就可以避免重复包含的问题。Python 语言的模块导入机制也设计得很好,每个模块在每个程序中只会被导入一次。如果一个模块被多次导入,那么在第一次导入后,Python 就会把它缓存起来,后续的导入操作只会返回缓存的模块,而不会再次执行模块的代码。这样也避免了重复导入的问题。
Go 语言的包导入机制也是类似的,每个包只会被初始化一次,而且这个初始化过程是在程序启动时自动完成的,不需要程序员手动进行。这种设计确实避免了 C 语言中可能出现的重复包含(include)的问题,也避免了 Python 中可能出现的递归导入的问题。(说如果出现循环依赖,Go还是会和Python一样报错中断运行)
那么第一讲就此完结吧。