近年来在典型的互联网服务领域,Go相关技术栈已经普及度非常高,公司很多互联网服务属性的部门基本都已经切换到Go,抛弃了我司长久以来的C++技术栈来开发业务逻辑,最近新的游戏项目也尝试全面拥抱云原生,技术栈全面切换到了Go,所以最近在工作之余在看Go的一些基础和设计思想,这里以一个写了5,6年C++的人的角度来记录一下Go的一些基础知识,主要是通过阅读《Go程序设计语言》一书和官方文档,在做笔记的同时其中夹杂一些和C/C++的对比;本篇主要是一些基础知识,记录一下。
1. 前言
Go发布于2009年,当年就当选了TIOBE的年度语言,2012年推出了1.0版本,发展迅速,比如容器软件Docker就是Go编写的,etcd,Kubernetes软件架构也是基于Go编写的,数据库领域TiDB,influxDB也是Go编写的。
那是什么情况下诞生了Go语言呢,我们知道任何语言的诞生都是为了解决一些领域新出现的问题,这些问题用现有的语言要么无法完成,要么实现起来过于复杂和低效;
- 在基础架构软件层面,最早有C语言,后来又有了C++,在高性能的情况下,C++让我们可以以面向对象的工程思想来驾驭规模更大更复杂的项目,例如MySQL,MongoDB都是通过C++编写的。尽管C++很强大,但是它并没有很好的解决代码的易用性和健状性平衡的问题;
- 为了解决代码的易用性和健状性平衡的问题,后面出现了很多基于Java的基础架构软件,例如Hadoop生态;
- 在业务上,随着高并发需求的日益增多,C、C++等老旧语言,不能很简单的使用,于是有了,Erlang(代表作RabbitMQ),Scala(代表作Apache Spark),Rust,以及本文的Golang;
Go的起源:
- 继承了C语言的很多特性,号称”21世纪C语言”,Go继承了C的表达式语法,控制流,基本数据类型,形参的按值传递,指针。最重要的是继承了C的要点:程序需要编译成高效的机器码,直接使用操作系统所提供的API;
- Pascal分支中,从Modula-2中借鉴了包的概念;从Oberon总借鉴了消除模块接口文件和模块实现文件的差异,从Oberon-2中借鉴了包,导入和声明的语法
- 从CSP分支中,启发了Goroutine的设计;
Go语言诞生于复杂项目积重难返,在业务发展过程中,不断的增加系统功能,配置,简单性往往被忽视了,但从软件设计上好的设计要保证概念完整性(引用《人月神话》),即系统的设计要具有一致性,只反映唯一的设计理念,只有设计上的简单性,系统才能在增长的过程中保持稳定,安全和自治;
简单性在Go中的具体体现为:有GC,包系统,first-class function,作用域,系统调用接口等相对简单的语言特性,并且不太会增加新的语言特性了,Go里面没有隐式类型强制转换,没有构造析构函数,没有运算符重载,没有参数默认值,没有继承,没有泛型,没有异常,没有宏等,
这里特别要说明的是Go类型系统,虽然是强类型的语言,但是程序设计风格上更像是弱类型的语言,减少了程序的复杂性和程序员的负担,且提供了只有强类型系统才有的安全性和运行时性能;
Go被称为Batteries-included,自带电池的语言,Go标准库提供了用于I/O,文本处理,图形,加密,网络,分布式API等,可以让开发者直接使用,不需要安装各种依赖;
2. Go语法基础
2.1 名称
Go标识符命名开头以字母(Unicode任意字母)或者下划线开头,后面跟任意数量的字符,数字和下划线,区分大小写;可见如下定义:
1 | newline = /* the Unicode code point U+000A */ |
Unicode编码将所有的字符分为两大类:Scripts和符号,标点符号;
包级别的实体的第一个字母的大小写决定其可见性是否跨包;标识符名称长度本身没有限制,但是Go的编程风格倾向于使用短名称,特别是作用域较小的局部变量,作用域越大就使用越长且更有意义的名称,但《Go程序设计语言》中有一句话”你更喜欢看到一个变量叫”i”而不是theLoopIndex“,我不太认同,i的名称没有任何实际意义,代码标识上也没有可读性,我觉得任何时候都应该避免使用,即使在很小的局部作用域中;
2.2 数据类型
Go的数据类型分为四大类:
基础类型:整数,浮点数,复数,布尔型就,字符串;
聚合类型:数组,结构体;
引用类型:指针,slice,map,function,channel;
接口类型:interface;
2.2.1 基础数据类型
1 | uint8 the set of all unsigned 8-bit integers (0 to 255) |
int和uint类型是大小相同的类型,具体的位数和硬件平台以及编译器都有关系;**Go标准要求它是32位或者是64位**,目前在64位的架构下,gc和gccgo两个官方编译器中,int和uint都是64位的;
尽管Go具备无符号整数和相关算术运算,在项目开发过程中,也尽管某些数值不可能为负,但是我们还是往往采用有符号整数来进行数据类型的设计,例如标准库中对于数组长度类型int的设计,这是因为无符号整数在运算过程中比较容易反向溢出,衍生出很严重的内存越界问题;例如,如下倒序遍历数组元素,如果len返回uint将会出现问题;
1 | data :=[]int{1,2,3} |
所以无符号整数往往只用于位运算和特定的算术运算;
布尔值(bool),这里需要提一下:if和for语句的条件就是布尔值,由于C/C++中隐式类型转换的存在,我们可能没有意识到,但是在Go里面由于严格的类型限制,不存在隐式类型转换,所以if和for语句要求的bool,必须传入bool值;
字符串,这里也需要说一下,Go的字符串是不可变的字节序列;和Python的字符串很像,虽然可以将一个新的串赋值给字符串变量,但是字符串值无法改变;这一特性的好处是:两个相同的字符串可以安全的共用同一段内存空间,使得复制任何长度的字符串的开销都很低廉;
Go的实现中,string只有一个内存的指针,这样做的好处是string变得非常轻量,可以很方便的进行传递而不用担心内存拷贝,C++的设计也是如此。只不过Go中string通常指向的字符串字面量存储位置是只读段,所以才有了string不可修改的约定。如下是Go的string结构
1 | // src/runtime/string.go |
下面这段代码的实际存储空间分布如下:
1 | s := "hello, world" |
上述是按照差量的方式构建字符串,没有什么开销,如果是增量的方式的话,会导致多次的内存分配和复制,这种情况下bytes.Buffer类型会更高效;
原生的字符串字面值是通过反引号`来表示的,而不是双引号,通过反引号包裹的字符串就是纯原生字符串,不会对里面的转义符号进行处理;
上面基础类型就像原子一样,构成了语言的世界,那么复合类型就是由基础数据类型组合而成的,类似于分子一样;
2.2.2 数组和slice
数组和结构体这两种聚合类型的结构的共性就是内存长度是固定的;区别在于,数组的元素都是相同类型,而结构体的元素可以是不同的类型;此特性和C/C++一样,可称之为静态结构;
由于Go数组长度固定,所以Go中很少直接使用,都是用slice,但是数组作为slice的底层实现,还是要了解的,Go的数组的定义:
1 | var identifier [len]type // 完整声明 |
如果在短声明中,数组长度位置由...
省略号替代,那么数组的长度由初始化元素个数决定;数组字面量的初始化语法中可以同时指定索引下标和索引值;其中索引可以按照任意顺序出现,且可以省略,没有指定的为元素的零值,如下:定义了一个长度100的数组,第11个元素为1,最后一个元素为-1,其他的都是零值;
1 | arr := [...]int{10: 1, 99: -1} |
数组长度是数组类型的一部分;所以[3]int
和[4]int
是两种不同的数组类型,所以是不可以比较的;数组是否可比较不仅要看数组类型是否一致,还取决于其元素是否可比较;
slice相比于数组,是一个拥有相同类型元素的可变长的序列;它的类型为[]T
;slice的底层实现还是一个数组,用来存储元素,但是这个数组可以对应多个slice;多个slice可以引用数组的任何位置,彼此之间可以重叠;
1 | // src/runtime/slice.go |
slice有三个属性:
- 指针:指向底层数组第一个slice的元素;注意这个元素可能不是底层数组的第一个元素,因为次底层数组可能由多个slice共用;
- 长度:slice的元素个数;可以用内置函数
len()
获得; - 容量:slice的起始元素到底层数组最后一个元素之间的长度,可以用内置函数
cap()
获得;
可以将slice理解为c++的vector,动态扩充容量,但是Go 的slice由于会复用底层的数据,所以会更加高效;
1 | months :=[...]string{1: "January", /*...*/, 12: "December"} |
我们可以看到对slice和数组可以进行“引用”操作,即:
,这个在Python中叫做切片操作;引用操作下标语法为[left, right)
左闭右开,对整个数组引用可以直接用[:]
;
可以从一个数组或者slice中生成一个新的slice,生成的新的slice共用底层数组,如果引用操作超过了被引用对象的容量,被引用的为slice,那么会导致宕机,如果被引用的是数组,那么就会编译错误,如果slice的引用超过了被引用slice的长度但没有超过cap,那么最终生成的slice会比原slice长;
这里slice的引用操作和字符串的子串操作在语法上和底层实现方式都很相似,都是常量时间操作,唯一区别就是字符串的子串操作返回的是一个字符串,slice的引用操作返回的是slice;
slice的实现是包含指向底层数组元素的指针,所以slice在函数传递的时候,虽然是按值传递,但是是指向统一底层数组的slice,可以在函数内部进行修改;同样对于数组进行slice操作想当于为数组创建别名;
slice和数组的初始化表达式很类似,只不过有没有指定长度的区别;这种区别的结果是:数组是创建有固定长度的数组,slice是创建数组和指向该数组的slice;数组和slice的底层实现?
和数组不一样的是,slice无法比较;原因有二:
- slice的元素是非直接的,其元素也可能是slice,slice内部只包含指向底层元素数组的指针;这种很难有效的统一进行处理;
- slice元素是非直接的,同一个slice不同时间可能会拥有不同元素,Go map只对key做浅拷贝,所以要求key必须保持不变,因为slice需要深比较,所以map不能用slice作为key;
slice、唯一支持的比较是和nil比较;slice的零值是nil;
slice的创建可以通过
- 内置函数make,创建指定元素类型,长度和容量的slice;底层其实make创建了一个无名数组,并返回它的一个slice;
- append函数,为slice动态创建底层数组元素;每次append都会检查slice的是否容量够,否则则进行扩容(2倍扩容?),然后进行底层数组元素的移动;类似于c++ STL中的vector容器的实现;由于append操作是否会导致一次内存的分配是不确定的,所以通常我们将append调用结果再次赋值给传入append的slice,以获得最新的slice;
1 | s0 := []int{0, 0} |
2.2.3 map
Go中的map是一个哈希表;可以提供常量时间的键值操作;map的key需要是可比较的数据类型;
Go的map无法对应的value进行取地址操作,因为map的rehash过程会造成元素被存储到其他位置,C++其实就不会,rehash的过程只会对value进行移动,并不会改变其内存地址;所以Go中禁止对value进行取地址操作,如下:
1 | _ = &ages["bob"] //编译错误,无法获取map元素的地址 |
需要注意的一点:map中的元素的迭代顺序是不固定的,即每次迭代的输出结果都是不同的;这个是Go有意为之,原因是为了使程序在不同的散列算法实现下变得更健壮;如果需要保证map的遍历顺序,就需要显示的对map中的key进行排序,然后依赖key的稳定,来保证通过key遍历map结果的稳定;
map和slice一样,都是不可比较的,唯一合法的比较的就是和nil的比较;
2.2.4 结构体
Go的结构体和C/C++很类似,用来组合多个任意类型的命名变量;成员变量的首字母大小写决定该变量是否可导出,即是否包外可见;
没有任何成员的结构体称为空结构体,写作struct{}
,它没有长度,也不携带任何信息,但有时候会有用,例如可以用来在map中当作value值,这种用途是把map当作集合来使用,仅key有意义,其实这种方式节省的内存还是比较少的,但会带来相对复杂的语法;
1 | find := make(map[string]struct{}) |
结构体初始化可以通过字面量进行,结构体字面量也有两种格式:
按照结构体成员的顺序,为每个成员指定一个值;这种方式会造成代码的可读性和可维护性比较差,在结构体成员发生重排时是灾难性的;但对于结构体类型名中有特殊约定的就可以用这种方式,例如:
color.RGBA{red, green, blue, alpha}
;通过指定成员变量名字和值的方式来进行初始化;
1 | type Point struct {X, Y int} |
结构体是否可比较取决于其所有成员变量是否都可以比较;
Go结构体提供了一种通过结构体嵌套时以匿名成员的方式来进行结构体组合的机制;Go的匿名成员是不带名称的结构体成员,只需要制定结构体类型即可;
1 | type Point struct {X, Y int} |
匿名成员其实是有隐式的名字的,其名字就是结构体类型名;只是这些名字在访问最内层的成员变量是是可选的;所以成员变量的名字不能和匿名成员的类型名发生冲突,否则会编译错误;用结构体字面量来初始化含有匿名成员的结构体时,需要指定匿名成员名;
1 | circle := &Circle{Point:Point{X:1, Y:2},Radius:3} |
如果结构体中成名变量名和同级别的匿名成员中的成名变量同名,那么访问最内层的同名成员变量时,需要带上匿名成员的类型名,否则只是读取外层的成员变量,如下:
1 | type Point struct {X, Y int} |
外围结构体类型获取的不仅是匿名成员的内部变量,还有其相关的方法,Go这种机制从简单类型对象组合成复杂的复合类型的方式是Go面向对象编程方式的核心;
- 封装
Go中的变量和方法的权限控制叫做封装,Go中封装的方式是通过命名的方式来进行控制的,定义的时候首字母是大写的表示包中是可导出的,小写的表示不导出的,决定了此对象是否可以在包外进行访问;所以这里封装的单位是包,而不是一个类型;
2.3 声明
声明是用来给程序实体命名,并设定其属性;四个主要的声明方式:
- 变量声明:var;
- 常量声明:const;
- 类型声明:type;
- 函数声明:func;
变量的声明有两种方式:完整声明方式和短变量声明,如下:
1 | var name type = expression // 完整变量声明 |
类型和表达式部分可以参略其一,但不能都省略,如果初始表达式省略了,其初始值为该类型的默认零值;零值机制是Go的一个很重要的特性,Go里面不存在未初始化的变量,零值机制保证了所有的变量都有一个初始值;零值机制简化了代码设计,防止了未初始化导致的异常行为,不需要额外的工作就能感知到边界条件行为;这就是简化带来更多的健壮性的好设计;
各种数据类型的零值为:
- 数值类型:0,例如int系列,
- 布尔值:false;
- 字符串:””;
- 接口和引用类型:nil,例如slice,指针,map,channel,func;
- 复合类型:零值是所有成员的零值,例如:array,struct;
var关键字声明变量通常是为了那些初始化表达式类型不一致的变量,或者变量后面才会赋值的情况,大部分情况Go中都建议使用短变量声明的方式来声明一个变量;重要的一点:短变量声明不需要声明所有左边的变量,部分变量可以已经声明过(必须同样是通过短变量声明过的),此时等同于赋值,但是短变量声明最少需要声明一个变量,否则编译不过;
类型声明时候的type可以是对应类型加上*
,即var name *type = expression
,表示指针类型的变量;
Go继承了C的指针的设计,用来指向变量的地址;但是C里面指针基本没有任何操作的限制,这单来很多的安全隐患,例如指针写坏了其他对象的数据;而在有些语言中指针被引用替换,除了进行传递外不能进行其他操作;Go里面做了一个折中:指针显示可见,可以获取和修改指针指向的变量的值,但是指针不支持算术运算;这种设计可以很大程度上避免类似在C/C++中,因为指针操作导致的coredump问题。
创建变量的方式还有一种:使用内置的new()
函数,new(T)
返回一个未命名的T类型变量,初始化为T的零值,并返回其地址(类型为*T),如下:
1 | p := new(int) |
new()
创建的变量和取地址的普通变量没有什么不同,只是不需要以引入一个名字,可以直接在表达式中使用;
2.4 生命周期
和C/C++变量生命周期的含义一样,但Go对变量生命周期的管理有很大不同,包级别的变量生命周期还是整个程序执行周期的,但是局部变量有一个动态的生命周期:这个生命周期存在的原则是:创建的实体,一直生存到它变得不可访问,这时其存储空间才会被回收;所以函数内部返回局部变量的地址在Go里面是非常安全的,这就是Go作为GC类型语言的特性;
首先要知道的是Go中的变量的是在哪里分配的,堆上,还是栈上;
在C/C++中我们声明一个变量的时候就能明确知道其是在栈上还是在堆上分配的,明确知道其生命周期,但是Go语言中,变量分配在哪里,不是由关键字var或者new来决定的,是由编译器根据变量的生命周期决定的;如下:
1 | var global *int |
上述代码中,变量x就是在堆上分配的,且其生命周期在f()调用返回后,仍然存活,这种情况就是Go所谓的变量逃逸;每次变量逃逸都需要一次额外的内存分配过程,所以理解变量逃逸对能写出更高效的代码是有好处的,例如在长生命周期中保持短生命周期的对象的指针,特别是在全局变量中,这会阻止GC对短生命周期对象的回收;
2.5 表达式
Go里面多重赋值表达式的右边表达式会在更新前全部完成推演计算;
Go里表达式的运算符按优先级有以下几种,优先级依次降低:
1 | 优先级 运算符 |
Go中常量是一种表达式,其是编译阶段就确定的值,所有的常量本质都属于基本类型:布尔,字符串或者数字;常量的声明格式如下:
1 | const pi = 3.14 |
常量的声明可以使用一个叫做常量生成器的特性:iota,它可以创建一系列的值,从0开始,递增加1,对比其他语言,iota相当于枚举类型;
1 | const ( |
前面说过Go的类型设计时,我们知道Go没有所谓类型的隐式转换,但是Go对于字面常量即无类型常量的设计是比较灵活的;在代码中的字面常量是不从属于某个具体类型的,是无类型的(具体编译器怎么存储的???),如果无类型的常量赋值给一个类型确定的变量时,则常量会隐式转换成该变量的类型;如果变量类型没有显示指定,那么无类型常量会隐式转换成该变量的默认类型;
1 | i := 0 // 无类型整数->int(0) |
不论显示还是隐式,常量从一种类型转换成其他类型时,都必须目标类型能够显示原值,否则会编译错误;
1 | var a int32 = 0xdeadbeef; // 编译错误:constant 3735928559 overflows int32 |
2.6 类型声明
前面介绍了变量声明,即所谓的定义变量,那Go里面同样也有C/C++中的类型别名的概念,即类型声明type
:用来命名一个已有类型的别名,它和已有类型使用同样的底层类型;使用如下:
1 | type name underlying-type |
类型声明很多时候用在简化复杂类型的使用,提供编码上的便利,但其在程序通用性上也是很不错的选择;
1 | type FAlias1 int64 // 类型int64的别名, |
任意类型T,都有一个对应的类型转换操作T(x)
,用来将值x转换成类型T,如果两个类型具有相同的底层类型,则两者是可以转换的。Go中不支持类型的隐式转换,所以不同类型间的变量是不能进行比较的,
对于相同底层类型的不同命名类型变量,如果需要进行表达式操作,则需要显示的进行转换为同种类型后,再进行操作;命名类型的底层类型决定了它的结构和表达方式,以及它支持的内部操作集合;
2.7 流程语句
循环控制语句:Go里面唯一的循环控制语句就是for,使用格式如下:
1 | for initialization; condition; post { |
for语句的执行逻辑和C/C++是一致的。for语句的三部分都是可选的,可以都省略掉,所以可以通过for语句来实现传统的while循环或者是无限循环;
1 | // while循环效果 |
对于for语句,结合range可以对slice/map进行迭代,对slice会产生一对值:索引和索引处的元素值;如下:
1 | var a [10]string |
空标识符即’_’(下划线),可以用在任何语法上需要变量名,但是程序逻辑又不需要使用的地方;
条件控制语句:有if和switch case两种,这里说一下switch:
1 | switch expression { |
switch首先会计算表达式的值,然后逐个和各个条件进行对比,相同的条件会进行语句的执行,执行完后会自动结束switch语句,这里不像C/C++,需要每个case后面都需要break;来阻止匹配成功后的贯穿执行;这里如果condition都是整数,在case多的情况下会优化为折半查找,而不是逐个从上而下进行低效的顺序推演;
switch支持无标签模式的语法,switch的expression可以为空,每个case语句都是一个布尔表达式,如下:
1 | switch { |
if控制语句语法格式如下:
1 | if condition1 { |
通常Go的做法是if语句块中处理错误然后返回,这样成功的路径不会变的太支离破碎;其实我在C/C++代码中也是采用此原则;
注释:
在声明任何函数前,写一段注释来说明其用途,这个约定很有帮助,因为可以通过godoc工具来生成文档描述;
2.8 函数和方法
Go的函数声明包括:函数名,形参列表,可选的返回列表和函数体,如下:
1 | func name(parameter-list)(reture-list){ |
Go函数的返回值也可以和形参列表一样进行命名,称为命名返回值;每个命名的返回值都会声明为一个函数作用域内的局部变量,并初始化为对应类型的零值;针对返回值是命名类型的,可以直接return语句返回,不需要再携带变量进行返回,称之为裸返回;
裸返回的好处是可以消除重复代码,但是如果没有某种约定,会是代码难于理解,也难以维护,所以应该保守使用裸返回;
Go函数的类型称为函数签名;签名相同的函数定义是拥有相同的形参列表和返回列表;至于参数名和函数名不会影响函数签名;
Go函数没有默认参数值的设计;Go支持变长参数,如果函数的最后一个参数是采用 ...type
的形式,那么这个函数就可以处理一个变长的参数,这个长度可以为 0,这样的函数称为变参函数。变长参数的实现是一个对应类型的slice,
1 | func Func(a, b, arg ...type) {} |
变参函数的变长参数可以作为一个参数传递给其他函数进行调用,由于变长参数传入函数后,实现上已经变长了一个slice,所有把变长参数传递给其他函数调用时,需要在声明上做一下标示,如下示例:
1 | func F1(s ...string) { |
Go函数的实参是按值传递的,所以函数收到的是每个实参的拷贝副本,所以修改形参的值并不会影响到调用处的实参变量;但对于引用类型的实参,例如指针,slice,map,function,channel,函数内部修改这些类型的形参,会间接的对实参变量进行修改;
Go语言的实现使用了可变长度的栈,栈的大小会随着使用而增长,可达1GB左右(大小由来???)的上限;这使得我们可以安全的使用递归而不用担心溢出问题;
Go函数可以返回多个返回值,不像C/C++的单返回值设计;很多标准包的函数都会返回两个值,一个计算结果和一个错误值或者调用是否正确的布尔值;
2.8.1 错误处理
错误处理是包的API设计的重要组成部分,发生错误只是许多预料行为的一种而已,Go语言中处理错误的方法有几种:
- 如果错误只有一种情况,结果通常设置为布尔类型;例如查询key是否存在Cache中的操作;
- 针对错误多种多样的情况,调用者需要一些详细的错误信息,此时错误类型通常是error;当一个函数返回一个非空的错误时,它的其他结果都是未定义的且应该忽略;但有些函数在调用出错的情况下,部分结果是有用的,例如Read,这时候文档的说明就很重要了;
针对在函数调用时出现错误,调用者的处理策略对于业务设计的可维护性十分重要的,通常的处理方式:
- 最常见的情形是将错误传递下去;
1 | resp, err := http.Get(url) |
错误传递有两种方式,
- 直接返回被调用者传递的错误,
- 进行追加额外的上下文信息;如上面代码在
html.Parse
失败后,为原始错误信息添加额外的url信息,标志解析错误的url地址,以便高效的定位错误的整个链路的上下文;因为错误信息要串联起来,所以消息字符串的首字母不应该大写(golint会识别这种建议)且避免换行,错误结果可能会很长,但是使用grep工具能找到我们想要的信息;这点在NASA的事故调查中有一个经典的例子:
1 | genesis: crashed: no parachute: G-swith failed: bad relay orientation |
- 对于不固定的错误或不可预测的错误,在短暂间隔后可以进行重试,超出一定的重试次数和限定时间后再报错退出;
- 如果重试也不能顺利进行下去,调用者输出错误然后优雅的停止程序;
- 一些错误,只需要记录错误,然后继续运行;
其实现实业务中,如果错误都是通过追加额外信息进行向上传递,最终由最外层输出,其实在调试的时候并不是很直观,无法快速的定位到代码,现实中通过追加错误信息上下文和就地打印错误信息两种结合的方式以便进行问题的定位;
2.8.2 函数变量
函数变量就是指向函数体的变量,它可以进行赋值和传递,函数变量可以像函数一样进行调用;函数变量的零值是nil,除了和nil进行比较外,函数变量本身不可进行比较,所以不能作为map的key;
按道理,函数变量也就包括函数类型和指向函数的指针,在C/C++中都是可以比较的,但Go的函数变量为啥不能进行比较呢???
Go的函数不仅仅包含代码还可以拥有状态,隐藏的变量引用就是导致Go函数变量不能比较的原因;你可能会说C/C++也有函数作用域的静态变量,它不也是拥有状态吗?Go的函数的状态是和函数变量绑定的,而C/C++的状态是和函数本身其实是独立的;C/C++里面函数只是代码段;
Go的命名函数只能是包级别作用域进行申明,Go支持匿名函数,通过函数字面量来指定函数变量,如下:
1 | func squares() func() int { |
Go中的变量作用域规则有时候会带来很大的陷阱,例如一个常见的警告:捕获迭代变量;在返回匿名函数的时候如果捕获了迭代变量会造成与预期不同的结果;如下:
1 | var rmdirs []func() |
迭代变量dir是一个共享的变量,存放的是一个可访问的存储地址,而不是一个值;所以这里最后的rmdirs中的函数变量中的dir都是最后一个dir的值;
2.8.3 延迟函数调用
语法上,defer语句就是一个普通的函数或方法调用,在调用之前加上关键字defer;函数和参数表达式会在语句执行时求值,但是无论是正常情况下(执行return语句或函数执行完毕),还是不正常情况下(例如宕机),实际的调用会推迟到包含的defer的语句的函数结束后才执行;defer语句没有限制使用次数,执行的时候按照调用defer的顺序倒叙执行;
defer语句通常用于成对的操作,例如文件的打开和关闭,连接和断开,加锁和解锁;
defer延迟执行的函数在调用函数的return语句之后执行,并且可以更新函数的结果变量;
1 | package main |
2.8.4 宕机和恢复
Go的宕机和其他程序的处理基本相似,一个正常的宕机发生时,程序会终止执行,goroutine中的所有延迟函数会执行,然后程序会异常退出,并打印一条宕机日志信息,包含:宕机的值,函数的栈追踪信息;
宕机作为程序的一种自我保护措施,一般用在发生了“不可能发生”的情况;此时选择宕机再好不过;鲁棒性好的强壮的代码都会优雅的处理一些预期的错误,除非发生影响程序本身稳定性的的错误,此时才会选择宕机来保护数据的正常;但是有些特定的情况下,宕机是可以进行自我恢复的;
Go提供了一个自我恢复的接口,通过内置函数recover()
在延迟函数的内部调用,当调用defer语句的函数发生宕机后,defer的执行,通过执行recover()
会终止当前的宕机状态,并且返回宕机的值;需要提出的是:recover()
函数只能在defer语句中调用,其他情况下执行时没有任何作用的;
对于宕机的无差别恢复是没有意义的,因为宕机包内变量的状态往往没有清晰的定义和解释,可能是对某个关键数据结构的更新错误,或者获得了锁,没有释放,长此以往,程序的状态会变的混乱,Bug更难进行定位;
一般的原则是:从同一个包内发生的宕机进行恢复,有助化简化处理复杂和未知的错误,但不应该尝试去恢复另一个包内发生的宕机;
2.8.5 方法
尽管没有统一的面向对象的定义,但是一般来说对象就是一个简单的值或者变量,并且拥有其方法;方法就是特定类型的函数;
Go方法的声明和普通函数类型,不过需要在函数名字签名多加一个参数,称之为接收者,这样的结果是把这个函数绑定到这个接收者参数对应的类型上,称之为方法;
1 | func (reciver Type) Name(parameter_list) (return_value_list) { ... } |
Go语言不像C++或者Python那样使用特殊的接收者即对象名字:this或self这种;我们可以自定义接收者的名字,像参数的变量名一样;
这里接收者的名字在一些书籍建议和官方的库中都使用简短的名字,比较常用的做法是取类型名的首字母;取简短的接受者的名字没有问题,但是只取首字母的做法在业务逻辑开发过程中,特别是方法体相对长的时候,在可读性上和容错性上都有不好的体验,所以我自己的方式是,在方法体超过一定长度时,接受者不建议使用单个字母,而是相对有意义的接收名字;
其实一些库中的代码有时候也并不是采用单个字母的接收者名字;
为什么Go的strings库里面都是采用函数提供功能,而不是方法呢?
1 | import "math" |
表达式p.Distance
在Go里面被称为选择子(selector),原因是该表达式的语义是为接收者p选择合适的方法;同样p.X
也是selector;
和C++一样,Go的结构体里面不能定义相同名字的方法和变量;否则会编译报错;因为其变量也有可能是一个函数变量;
Go和其他OOP语言很大的一点不同是:它可以把方法绑定到任意类型上(除了指针类型),可以为数值,字符串,slice,map,甚至是函数定义附加的行为;
1 | type MyInt int |
但这里又想到了之前的问题:在方法和函数调用两种方式来提供功能,该如何选择是个问题;这里看到一个解释是:方法可以使用比函数更简短额名字,因为作用域的不同;
- 指针接收者
Go的方法的接收者通常情况下会是一个指针类型;因为接收者也是属于函数的参数,所以它是按值传递的,如果要修改该接收者的成员数据,就必须以指针的形式来传递;在设计规范中,如果一个类型的任何一个方法使用指针接收者,那么其所有的方法都应该使用指针接收者,即使其方法不需要;这么设计的原因应该是为了减少各个方法的调用时行为不一致性,给业务开发的带来复杂度和bug;
Go有一个特性:选择子只能够通过 点号.
来进行方法成员的操作,对于调用时实参变量和行参变量类型在T和T*两者之间不一致时,编译器会自动进行转换,不需要开发者刻意的进行取地址或者解引用的方式进行方法的调用;前提是:对象一定要是个变量;
1 | type Point struct{ X, Y float64 } |
像C++一样,Go的方法的接收者可以是nil;这样可以在对象方法设计的时候提供更安全的操作和判断;
- 匿名内嵌类型的方法
前面学习结构体的相关知识的时候,我们知道Go结构体提供了一种通过匿名结构体嵌套方式来进行结构体组合的机制来构建复杂的对象;嵌套的结构体的数据成员和方法都会被包含在外层结构体类型中;
1 | type Point struct {X, Y float64} |
这里Go的特色是通过组合的方式来构建复杂的对象,这里的内嵌可不是把Point当成ColoredPoint的基类,ColoredPoint并不是Point,他只是包含了Point成员和它的方法;Go实际上是告诉编译器为ColoredPoint生成额外的包装方法来调用Point的方法,相当如下代码:
1 | func (p *ColoredPoint) Distance(q Point) float64 {reurn p.Point.Distance(q))} |
这里需要注意的是:结构体类型当中可以有多个匿名字段;这个时候外层结构体类型拥有所有匿名字段的方法;且Go的实现方式决定其所有匿名结构体的方法不能同名,因为最后其方法都会包装在最外层;
1 | type Point struct {X, Y float64} |
当编译器处理选择子selector(例如cp1.Distance()
)的时候,会有一个搜索顺序:
- 首先,先查找直接声明的方法;
- 其次是查找ColoredPoint的内嵌匿名字段的方法;
- 最后从内嵌字段的内嵌字段的方法中进行查找,这里就是Point和RGBA的内嵌字段的方法中进行查找;
2.8.6 方法变量和表达式
和函数变量类似,方法的表达式和调用可以分开使用;
方法变量就是将一个selector选择子赋值给一个变量,然后此方法的调用就可以只需要提供实参,而不需要提供接收者了,例如:
1 | p := Point{1,2}; |
方法表达式的定义是:T.f或者(T*).f
,T是类型,方法表达式就是一种函数变量;不过方法表达式的调用需要将方法接收者作为函数的第一个参数传递,就像函数一样调用;
1 | p := Point{1,2}; |
方法变量可以用在API的某个功能希望调用特定接收值某个方法,方法表达式可以用在某个功能希望调用不同接收者的某个方法;
3. 接口
Go的接口类型相对于Go其他的具体类型,是一种抽象类型;函数的定义通过接口可以设计成更加的通用和灵活,不用绑定到特定的类型实现上,可以实现更多面向对象的特性;
接口定义了一组方法,但是这些方法不包含代码:它们没有被实现(它们是抽象的),接口里也不能包含变量。接口定义如下:
1 | type Namer interface { |
针对一个具体类型的值,可以精确的知道它是什么以及它能干什么;但针对一个接口类型的值,你不能知道它是什么,你只能知道它能干什么;
和C++的虚基类使用相比还是差别很大的;不过目的都是为了实现程序运行过程中的多态;
3.1 实现接口
如果一个类型实现了一个接口的所有方法,那么这个类型就是实现了该接口;所以这里并不需要特殊的申明实现了某个接口,接口被隐式地实现;
Go中通常称一个具体类型is-a
特定的接口类型;表示其实现了该接口;
这里举个例子,fmt包中定义了一个很重要的接口,让类型决定如何输出自己的机制,就是通过接口实现的,如下:
1 | package fmt |
只要实现了String() string
方法的具体类型,就可以通过fmt输出该具体类型的定制化的输出数据;
1 | package main |
接口作为一种类型,当然接口变量是可赋值的;其赋值规则很简单:当一个类型实现了一个接口时,这个类型的变量就可以赋值该接口变量的;
前面学习方法的selector调用时,我们知道对象的类型和方法接收者类型存在T和T*的差异时,编译器会自动提供转换的语法糖操作;但对接口赋值时,需要知道具体类型对象是否实现了该接口的方法,如下:
1 | test := StringerTest{} |
这里有个问题,我们知道具体类型对应方法的接收者是T类型,可以将T和T*的对象赋值给接口类型的变量,但上面测试在接收者是T*时,确不行,这是为什么???:
1 | type StringerTest struct{} |
3.2 接口值
接口类型的值包含两个部分:一个具体的类型和该类型的一个值;分别称之为接口的动态类型和动态值;
对于接口类型变量的零值就是把接口的动态类型和动态值全部设置为nil;
1 | package io |
接口值的动态类型会设置为指针类型*os.File,而接口值的动态值被设置为os.Stdout的副本,如下图:
当执行w.Write([]byte("hello"))
时,会实际调用(*os.File).Write()
方法,这就是Go的多态;Go中称之为动态分发:编译器会生成一段代码从接口的动态类型的类型描述符中获取对应的方法的地址,然后间接调用该方法,方法的接收者就是接口的动态值;
接口值是可比较的,所以可以作为map的key,但是需要注意,如果两个接口值在比较时,如果动态类型是一致的,但是对应的动态值是不可比较的(例如slice),那么结果是奔溃的
1 | var x interface{} = []int{1,2,3} |
- 含有空指针的非空接口
这里大家容易忽略的是:空接口值和仅仅动态值为nil的接口值是不一样的;
1 | func main() { |
在debug为false的情况下buf为* bytes.Buffer指针类型的零值,但是调用f()的时候,out接口值的动态类型被初始化为* bytes.Buffer,但是动态值为nil,所以out接口值不是零值;
3.3 类型断言
拥有很多方法的接口,给实现了接口的类型提出了很高的要求,很多时候一个接口只定义一个方法,设计上的简单性,才能在增长的过程中保持稳定;
很多时候你会看到一个参数类型是空接口类型interface{}
,看起来没有任何用途,因为其没有包含任何方法;但正是对实现类型没有任何要求,我们可以通过空接口来创建一个指向任意类型的接口变量;然后在方法中通过类型断言来还原出实际值;
类型断言语法为:
1 | v, ok :=interfaceName.(T) |
- 当T是一个具体类型时,类型断言会检查接口的动态类型是否就是T,如果是的话就会返回接口的动态值,类型也就是T;
- 当T是一个接口类型时,会检查调用接口的动态类型是否满足T,如果是,就返回接口类型T,而调用接口的接口值并没有改变,
4. Goroutine
goroutine是Go的最重要的特性,在前面介绍了Go的的诞生背景,很大的原因是在高并发的应用场景中,传统的语言实现起来不是那么容易,Go从语言层面引入的goroutine极大的方便了高并发的开发需求场景;
在实现上,goroutine是Go中最基本的执行单元,每个Go的程序最少含有一个goroutine,启动后main主流程,简称主goroutine;
Go提供了两种的并发设计模型:
共享内存的多线程模型;这和主流语言C/C++,Java的多线程模型基本类型,不过在Go中是多Goroutine(通过多线程调度)来表现的;共享的内存在多线程中可见,通过锁进行同步;
CSP并发模型;CSP全称为Communicating Sequential Process,通信顺序进程,怎么理解呢?下面有一句经典的解释(不知出处):
Don’t communicate by sharing memory; share memory by communicating.
不要用共享内存的方式进行通信,而是用通信的方式进行内存的共享;Go的goroutine之间通过引入channel的方式来进行内存的共享;共享的内存本身仅限于单一goroutine内部;
新的goroutine的创建通过go关键字加上一个函数/方法调用来完成,语法如下:
1 | go fun() |
go语句会启动一个新的goroutine,执行fun()调用,并立刻返回,继续执行主goroutine后面语句;
1 | package main |
4.1 Channel
前面说的 Go提供的的CSP并发模型中,用通信的方式进行内存的共享,Go提供了一个新的变量类型Channel的来进行并发实体即goroutine之间的通信;
类型Channel定义了一个具体类型的通道,goroutineA可以通过Channel发送特定的值到goroutineB;Channel的定义如下:
1 | var identifier chan DataType |
通道只能传输一种类型的数据,比如 chan int
或者 chan string
,所有的类型都可以用于通道,空接口 interface{}
也可以。甚至可以创建通道的通道。同样,未初始化的channel的零值为nil,channel的创建需要使用内置make函数,如下:
1 | var ch1 chan string |
channel可以使用==
进行比较,引用相同时,两个channel比较结果为true;
channel有三个主要的通信操作:
- 发送:向channel中发送数据,使用操作符
<-
,例如表达式ch <- x
;将x的值写入通道ch中; - 接收:从channel中读取数据,同样使用操作符
<-
,只是操作数顺序发生了颠倒,例如表达式x := <-ch
;从通道ch中读取一个值,赋值给变量x; - 关闭:关闭通道,使用函数
close()
,关闭了的channel,表示不会再写入数据,也可以理解为数据已经写完,可以对已关闭的channel进行接收操作,此时会立刻返回通道元素类型的零值,但不能进行发送操作,否则会panic;这里的close
操作和linux的fd的close含义不太相同,fd的close,会让fd不可读写;
Go的channel从容量上可以分为两类:
无缓冲通道;
ch = make(chan int)
,无缓冲通道上的发送操作会发生阻塞,直到另一个goroutine在此通道上进行接收操作;相反亦是如此;所以无缓冲通道也被称为同步通道;缓冲通道;
ch = make(chan int, 10)
,相对于无缓冲通道,提供了一定容量的channel类型,类似于消息队列,向缓存通道发送数据会在队列尾部插入一个元素,接收操作会从队列头部移除一个元素;如果通道满了,发生操作会阻塞,同样如果通道为空,接收操作也会发生阻塞;所以说channel和goroutine的调度深度关联,如果没有另一个goroutine从通道内接收,或者接收速度较慢,那么发送者可能会被阻塞的风险,如果仅仅需要一个简单的队列,可以使用slice来替代;
如果goroutine阻塞通过channel发送消息时,没有接收者进行接收,那么goroutine就会永久阻塞,这种情况叫做goroutine泄露,泄露的goroutine不会发生GC;
并没有一个直接的方式去判断一个channel是否关闭,但是Go提供了一个方式可以判断一个channel是否关闭且已经被读完,如下:
1 | ch := make(chan int, 10) |
上述语法比较繁琐,Go同样提供了for range循环语法对channel进行迭代,可以用于对channel进行接收操作,直接发送者发送完最后一个值且关闭channel,迭代在接受完最后一个值后结束循序;
利用channel的close特性,可以做到goroutine的取消特性,go本身不支持一个goroutine直接取消其他goroutine的操作,因为这会带来共享数据的不确定性;这时候可以通过关闭通道,来达到广播效果,所有goroutine监听到通道的关闭,就可以执行退出操作;
下面是一个简单的channel示例,展示了channel如何在多goroutine之间通过通信的方向进行数据共享的;
1 | func counter(out chan<- int) { |
上面示例还可以看出Go的通道类型支持单向通道,chan<-int
是一个只能发送的channel类型,<-chan int
是只能接收的channel类型;对于申明单向通道类型的channel进行反向的操作,会发生编译错误;默认一个channel是双向的,其可以转换为单向的channel,但是反过来是不行的;
4.2 WaitGroup
有时候划分子任务进行高度并行时,主goroutine需要等待所有子任务goroutine结束后,进行后处理的逻辑,Go提供了sync.WaitGroup来进行Goroutine的完成等待;类似于UNIX IPC中的semaphore。具体用法:
- 主goroutine调用WaitGroup.Add(),增加等待结束goroutine的对应的个数,并拉起对应的子goroutine;
- 子任务goroutine结束后,调用WaitGroup.Done()表明该grotouine结束,即将WaitGroup内运行的goroutine的计数器减一;
- 主gortouine或者单独的goroutine调用WaitGroup.Wait()等待counter变为0,期间会一直阻塞;
如下示例:从channel中接收文件,创建goroutine并发的生成每个文件对应的缩略图,并返回所有缩略图大小之和;
1 | func makeThumbnails6(filenames <-chan string) int64 { |
4.3 计数信号量
Go的并发不是无限制的,因为并发度最终和CPU的核数,IO的限制都有很大关系,且过高的并发会带来调度的开销,所以业务设计上还是需要限制最大并发数,Go中通常用固定容量的缓存通道来实现并发原语,以控制最大并发的goroutine数量;
1 | // 计数信号量,确保并发限制在20个以内 |
4.4 Select多路复用
前面介绍Channel的时候我们知道,channel的默认含有阻塞语义,即使channel是有缓冲的,当其满了后对其写入一样会阻塞;在业务开发过程中,我们有时候需要谨慎使用channel的同步语义,Go提供了select操作,以多路复用的方式同时操作若干channel,而不用在某个channel未准备好的时候阻塞住goroutine;
如下语法,每个case对应一个channel的通信操作,或接收或发送:
- 如果没有default选项,select会一直等待,直到有一个case可操作;如果同时多个case都可操作,select会随机选择一个,这样保证了每个channel都有机会会被选中;
- 如果存在default选项,当没有case可操作时,default代码段会被立刻执行,然后结束select操作;一般会结合for语句,对多个通道进行轮询,如果没有可操作的,default语句会进行sleep,以达到daemon goroutine的工作loop;
1 | select { |
select 的case语句只能是channel的接收,发送操作,否则会编译报错: must be receive, send or assign recv
;
4.5 共享内存的并发模型
前面引入Goroutine时,一开始介绍了传统语言模型中基于共享内存的并发模型和Goroutine采用的CSP并发模型;这里介绍一下Go中当数据发生竞争即同步问题时,提供的传统并发模型;Go中对于并发同步的问题,称之为竞态,对于数据的竞态,传统的并发模型就是通过锁来进行并发互斥;
- 互斥锁
Go提供了sync.Mutex来实现互斥锁,用来同步对共享资源的排他性操作
1 | var mu sync.Mutex |
- 读写锁
1 | var mu sync.RWMutex |
读写锁相对互斥锁只是增加了读写操作的booking过程,用于提供读操作高效的并行,RWMutex是基于Mutex实现的,如下:
1 | // sync/rwmutext.go |
这里可以看一下如下进行实现的;
- 延迟初始化
Go提供一个延迟初始化的接口,保证一个函数调用只会被初始化一次,使用方式如下:
1 | var once sync.Once |
Once的实现其实比较简单,里面包含了一个布尔变量和一个互斥锁,分别用来标记初始化是否已经完成和保护布尔变量和数据结构,如下:
1 | // sync/once.go |
针对基于共享内存的并发模型很容易发生竞争和死锁的问题,Go本身提供了一个动态分析工具:竞态检测器;它能够高效的记录的goroutine对共享变量的所有访问,标识这些goroutine,以及它们的同步操作,例如go语句,channel操作,Lock调用,Wait调用等;竞态检测器会对数据发生竞态,即一个goroutine对一个变量写入后,另一个goroutine没有任何同步操作就直接读写该变量的情况进行记录,其会输出一份报告:包括变量的标识,调用堆栈等用于定位问题;
竞态检测器的使用方式,在go build, go run ,go test的时候加上-race
命令选项就可以了;
竞态检测器是运行时进行检测的,所以无法检测所有未发生竞态的代码,由于有额外的booking操作,该功能会对程序的性能和内存都有影响;
4.6 GO并发模型的示例
这里以一个示例来总结一下Go并发模型,此示例是一个关于函数记忆(memoizing)的问题,即缓存函数执行结果,针对多次调用只需要进行一次计算,如下是通过全局锁的方式进行同步的示例代码:
1 | // Func is the type of the function to memoize. |
基于CSP模型,有一种非阻塞的同步实现,如下:
1 | type entry struct { |
就针对此函数记忆的功能来说CSP无锁高并发的模型肯定要是比加锁同步的模型性能要高的,但是此两种方案在不同的业务场景中要具体看到,不能说哪个方案更新,但通过该例子可以提供很好的并发解决思路;