新语言,吸收了其他语言中的一些思想,但也不是和其他语言完全一样, 所以需要了解一些细节的设计. effective go 是对spec/tour of go的扩充.
Go标准库,是核心库,同时也是语言使用的最好demo, 所以多看看Go源码是非常有必要的.
使用gofmt格式化代码.
源码中的大部分格式都会被gofmt按标准进行处理,还是有少数格式会被保留:
- 缩进,gofmt使用tab缩进,使用空格不会被gofmt处理
- 行长度,Go对行长没有限制,所以一行可用多行表示
- 括号,括号会影响计算顺序,不会因为计算优先级而省略掉
- /* */, 块注释一般作为包注释,也会出现在描述或大块代码的disable
- // 行注释是最常见的
- godoc会从源码中收集文档,顶层声明前面的注释(中间无空行)可作为文档
- 所以文档质量的高低,就看注释的功夫
- 每个package都应该有包注释(包说明),包注释应该在包条款前面
- 每一个要暴露的对象,都需要添加doc注释
- doc注释,最好语句完整,长度合适
- 第一个语句的中的第一个单词和声明对象的名字保持一致
- 注释最好是一个完整的句子,主语应该是要描述的对象
- 带因式分解的声明,注释可以抽象一点
- 分组除了用于有关联的变量,也可用于和一个mutex绑定的一组变量
Go中的命名很重要,甚至是作为语义的一部分.包的导出元素,首字母大写.
- 包名应该小写,不带下划线,一般不用缩写,而用单个单词,不用驼峰写法
- 包被导入之后,包名就称为了内容访问器
- 另一个约定是:包名和源码目录对应
- 尽量避免import . 除非测试
- abc包中的类型叫Writer,而不叫abcWriter
- 包暴露了多个类型时,构造方法应该是NewType1() NewType2()
- 包只暴露一个类型时,构造方法应该是New()
- 完备的文档说明比长名字的自解释要好一些,能短名字的就短一点,不行就用文档解释
- 短名字的可读性高很多
- 变量的读取函数命名: var diy; 读Diy() 写SetDiy(),按这样的规则写,这是针对非暴露的,暴露的就无需多此一步
- 如果接口只包含一个方法,接口名应取 '方法er'
- 字符串转换方法命名为String,而不是ToString
- 单词名称命名使用峰驼记法,不要使用下划线,也不要全小写
最后,最好保持和惯例一致,比如说和标准实现类似功能,取名最好一致.
- 源码中一般不需要出现;来表示语句结束,词法分析器会自动处理
- 分号在闭括号之前可以直接省略 eg: go func() { for { a = 1} }
- for循环子句中会出现分号,除此之外,一行中写多行语句也需要用到;
- 控制结构的左大括号{ 不能放在新行开头,只能放行未
遇到以下情况,词法分析器会自动添加;,判断依据是新行前的最后一个标记:
- 类型标识符 eg:int float64
- 基本字面量 eg:数值、字符串
- break continue fallthrough return ++ --
这是分号的插入规则,就是因为这些规则,所以控制结构的大括号不能单独一行. 另外,也就是说如果是一元运算符+-,或是逗号,都不会被认为是语句结束
Go中的控制语句和C中的略有区别:
- 没有do和while语句,for语句做了扩展,包含了do和while的使用场景
- switch更加灵活
- if/switch可以像for语句一样,可以带一个初始化语句
- break/continue语句可以想goto一样,带一个标签
- 新增一个新的语句selecet,这是一个通讯复用语句
- Go语言还省略了(),强调了代码块必须用{}包裹
if/switch可以带初始化语句,可以有效控制变量的作用域.
- swith不会自动下溯(多个case使用一个执行体),但可以将多个条件放在一个case中,逗号分割即可
f, err := os.open("1.txt") // err是声明
d, err := f.Stat() // err是再次赋值,并非重新生成
什么情况下才是再次赋值,要满足以下条件:
- 本次声明和已声明在同一作用域 (不是同一作用域,就是新声明了)
- 赋的值要满足之前声明的类型,且本次声明中至少有一个变量是新声明
for语句细分有3种类型,只有for格式的for语句才有分号.
- for 条件 {}, 条件是true/false,省略表示true
- for 初始;迭代范围;更新迭代因子{} 这是for格式的for语句,3个部分都可省略
- range for, 适用于容器
for可以支持数组、切片、信道、map和字符串, 字符串遍历时是解析utf-8,错误的unicode将占用一个字节, 以u+fffd表示。rune就是一个unicode马点。
普通的switch是比较值,扩展的switch是比较类型.
switch的值会和每个case的值做比较,不一定是int类型,只看比较结果是不是true. 这种格式的switch,和if-else if-else格式是一个概念. 这种格式的switch是没有自动fallthrough的,但一个case是可以带多个值的,逗号隔开. break会结束switch语句,如果要结束外层语句,可以用break带标签.
continue也能带标签,但只能出现在循环中.
接口.(type) 可以获取变量的实际类型,可以结合switch语句来做处理. 类型switch一般用于接口和具体实现.
在case中,重名是ok的,因为每个case的变量都是独立的.
- 函数返回多个值,简化了写法
- 返回带名的返回值,除了增强代码的可读性,还可以简化写法
- 延时函数defer非常适用于成对出现的场景
- 好处1:只写一处,避免遗漏
- 好处2:成对的放在一起,可读性和可理解性大大提高
defer函数中的参数,是在defer执行时确定的,而不是defer语句内的函数执行时确定. 多个defer函数的鄂执行顺序是lifo(后进先出,类似于栈).
- 两种分配原语:new make
- 局部变量对应的数据在函数返回之后依然有效,这点和c不一样
- 数组赋值给数组,会复制其所有元素
- 数组当函数参数,传递的是数组副本,而不是指针
- 一般很少直接使用数组,一般使用切片来操作
- new 会将申请的内存置0,有时置0还不够,就需要用到构造函数。构造函数中可以使用复合字面量来简化。
- 复合字面量,会新申请内存的,特殊情况下复合字面量也可以不包含字段,new(Type)和&Type{} 是等价的
- 复合字面量要结合类型来看
- make 更多用于创建 切片 映射 信道,返回类型不是指针,也会初始化,不是置0操作,而是初始化其内部的数据结构
- make用于引用数据类型,new用于其他
new是申请内存,并赋零值.很多场景都非常有用, 某些场景下,初始化时并不需要零值,此时复合字面量就出场了, 不用复合字面量,可以用显式的构造函数NewXX().
复合字面量的顺序是要严格匹配的,当然也可以指明部分字段名, 未指定的字段按零值处理.这种机制大大提高了灵活性. 如果复合字面量没有指定任何字段,那效果和new()类似. new(xx)和&xx{}的效果相等.
复合字面量也能用于数组/slice/map,此时字段表示的是索引.
数组定义时可以用...来代替长度,表示可变长数组.定义之后就不能改了.
make()就是对new()的另外一种补充,针对map/slice/channel类型, make()是初始化内部结构,初始化之后,值是nil.
切片是对数组的封装,提供的接口更加通用和强大, 除了矩阵变换需要明确维度,go中大部分都是使用切片,而非数组
二维数组有点特殊,特别是做图像处理时,切片分配时有两种方式:
- 独立分配每一次切片,适用于宽高会动态变化
- 只分配一个数组,切片与数组对应即可,适用于宽高固定的情况,构造效率更高
切片独立分配
pic := make([][]uint8,y)
for i: = range pic {
pic[i] = make([]uint8, x)
}
数组一次分配,切片对应, 使用时还是使用切片代替数组
pic := make([][]uint8, y)
a := make([]uint8, x * y)
for i := range pic {
pic[i], a = a[:x], a[x:] // 这个写法很特别,将循环变化的因子通过再次赋值的方式来表现
}
Go中的数组和c中的区别:
- 数组是值,(c中的数组名是指针引用),赋值就是拷贝所有元素
- 数组作为函数参数,传的是值,不是指针(和上面一条对应)
- 长度是数组类型的一部分
切片,slice,切片的赋值会导致两个切片指向同一个数组. 切片是引用,如果切片作为函数参数,是可以改变底层数据的, 数组却是不行的.
os.File类型的Read方法:
func (f *File) Read(buf []byte) (n int, err error)
每次最大读的长度,取决于buf的容量
slice的设计,使得slice非常通用和高效.
key-value
- key可以取很多值,但不能是切片
- key可以是接口,只要支持比较操作
- map和切片类似,都是引用类型。意思是都是传址
- map可以使用复合字面量构建,键值对使用逗号分割,键值之间使用冒号分割
例如:
var a = map[string]int {
"a": 1, // 分号隔开
"b": 2,
}
访问map中不存在的key,会返回类型对应的0值, 也可以用返回的第二个参数来判断key是否存在。
如果某个参数不关心,可以用空白标识符来表示:
_ , ok := a["b"] // a是一个map,_表示忽略返回值,就是一个占位符而已
map中的删除,delete(key),即使key不存在,操作也是安全的, 这点比c++好用多了。
讨论一下如何判断map中是否存在某个key:
- 第一种是将map转换成set,即将用true作为value,存在map中
- 第二种是用两个参数,第二个布尔参数表示key是否存在
- fmt.Printf
- fmt.Fprintf
- fmt.Sprintf 会返回一个新的字符串,而非填充给定的缓冲
下面是部分常用打印格式参数,更加详细的可以翻文档:
- %v,用于打印通用格式,eg:非10进制会打印成10进制,数组、结构体、map都能打印
- %+v,还会把结构体上每个字段名打出来
- %#v,按go语言语法打印出来
- %q,遇到string或
[]byte
时,可打印出带引号的字符串,遇到整数或rune时,打印带单引号的 - %#q,会尽可能使用反引号
- %x,用于字符串 字节数组和整数,打印出很长的16进制字符串
- % x,中间带一个空格,打印出来的字节之间会插入空格
- %T,打印值的类型
自定义类型的默认格式,可通过String()string 方法来实现, 只需要注意String()方法中调用Sprintf时,别触发另一个String()方法 不然就是无限循环,特别是自定义类型就是string时, 解决方法是直接将类型实参显示转换成string。eg:
type MyString string
func (m MyString)String()string{
return fmt.Sprintf("MyString=%s", string(m))
// return fmt.Sprintf("MyString=%s", m) 这种情况会导致无限递归
}
func a(v ...interface{}){} // ...是告诉编译器 v是一个实参列表, 省掉就是一个具体的接口对象了。 func a(v ...int) () {} // 表示实参列表是整形
func append(slice []T, elements ...T) []T 内置的追加函数
// 适用于切片,因为底层数组有可能改变,所以写法一般是
x := []int {1, 2, 3}
y := []int {1, 2, 3}
x = append(x, 4, 5, 6)
// 切片追加切片
x = append(x, y...) // 没有...会在编译时报类型错误
...的用法,在上面的例子中体现了两个:
- 引用类型前面,表示可变参
- 放在引用变量后面,表示遍历的元素
Go提供的初始化比c/c++更加灵活.
- 常量只能是数字 字符 字符串 bool
- 必须是编译器可以计算出的,运行期才能计算出值的不能是常量
- 枚举是通过const实现的,和c++类似都可以从0开始,
- 自增,专门有个iota变量表示0,go叫这个是优雅
- 只有通过iota创建的枚举才是枚举常量
- 变量的初始化和常量的初始化类似
- 不同的是:初始化变量的表达式可以在运行时计算
- init() 每个源文件都可以有一个或多个,被称为初始化器
- 当包变量被计算后,当导入包的变量被计算后,init()会被执行
- init()一般用于校验环境状态或验证一些前提条件
- atoi 字符串转整形
- itoa 整形转字符串
- iota 常量0,也有其他名称:枚举创建器
除了指针和接口类型,很多类型都可以定义方法集. 并不仅仅只有struct才有方法集.
值接收者和指针接收者的区别:
- 值方法:方法中的变更不会影响到原有的接收者
- 指针方法:会修改接收者的数据
- 值方法:可以通过指针和值调用
- 指针方法:只能通过指针调用
- 对此蹩脚的地方,go规定如果接收者可以寻址,go会自动按需添加取址操作
如果m是指针方法,如果a是可寻址的, 那么a.m()和&a.m()的效果是一样的,不写&,go编译器也会自动添加。
测试结果:
- 值方法是不会改变外部变量的,不管是不是指针对象来调用
- 指针方法是可以改变外部变量的,如果接收者可寻址,前面的&可省略
- go中的接口,是将独享的特定行为抽象出来了
- 在go中,一个接口包含一到两个方法是比较常见的
- 接口名和方法名之间一般是有对应关系,io.Writer就是实现了Write功能的某些事
- 一个类型都可以实现多种接口
类型转换有时是为了复用已有功能,目的可能是性能,也可能是为了安全. 有的类型转换是不会创建新值的,有的类型转会创建新值(eg:float转int).
将表达式转成另一种类型,方便调用新类型的方法集,这在Go中是常规手段.
说白一点:现在要对某个类型实现某些功能:
- 要么这个类型实现功能的接口
- 要么这个类型可以转换成某些已实现了这些功能接口的类型
这两种做法都没错,后一种可能见的少,但是效率非常高.
interface.(type)是取具体的类型,interface.(string)是类型断言,判断是不是string
类型断言和switch type到底有啥不一样?
类型断言的格式是:obj,ok := 接口.(具体类型),如果接口里的数据就是这个类型的, 那么obj就是具体类型的对象,ok标识着接口里的数据是否就是指定具体类型的。
switch type,是一种特殊的组合,格式是 接口.(type),返回的是具体类型。 括号里面就是type,不是某一个类型。
这两种只是写法上的类似,而她们的作用(使用场景)是完全不同的.
进一步说: 如果要判断接口变量的多个类型,使用type switch, 如果仅仅关心某一个具体类型,使用类型断言. type switch是可以转换成if-else和类型断言的组合.
类型断言如果失败,第一个返回参数是零值,第二个ok是false.
抽象的一种高级使用方式,但没达到泛型地步.
如果一个类型实现了一个接口,并没有其他方法暴露, 那么这个类型并不需要暴露,只暴露接口即可, 这时关注更多的是接口的行为,而非数据。 同时,也减少了文档的重复.
这种情况下构造函数应该返回一个接口类型,而不是具体类型。
// 下面是打包行为接口
type A interface{ 方法() } // 打包
// 下面是对接口的实现
void (a *实现1) 方法() {} // rar打包
void (a *实现2) 方法() {} // zip打包
// 打包构造应该如下
var 打包对象 A = 实现1
打包对象.方法()
// 如果要切换到zip打包,应该是:
var 打包对象 A = 实现2 // 这样切换不同的打包算法,只需要修改这个构造即可
打包对象.方法()
对于流程来说,如果要替换不同的算法实现,修改构造函数即可, 对流程来说没有任何影响,对实现1和实现2的流程和逻辑也没有任何影响.
在中方法,在流程上,将抽象和实现进行了完美的解耦.
这仅仅是将行为抽象出来,这是接口使用的一种,更加常见的是如下这种: func 传加密数据函数(加密接口)传输接口, 而在使用时就可以自定义加密和自定义传输。 这样的好处是"传加密数据函数"是通用的,不用写多个函数(md5加密tcp传输函数、crc32加密udp传输函数)
如果一个类型,更多关注的是行为,那么可使用接口来增加通用性。
绝大部分事物都可以有方法,都可以满足接口.
Go强大的基因是接口,多事物的接口组合在一起,其灵活性才能体现出Go的强大.
是一个占位符,
-
在for range的多重赋值中出现,是一个常规场景
- 避免了假变量的出现,提高了可读性
-
导入未用到的包,也是空白标识符出现的一个地方
- 仅仅用于调试时可以使用,调试完,应该删掉相关代码
-
除此之外,也有可能是为了包的副作用而导入的
- eg:为了包的init()
- 和上面一条不一样,为了引入包的副作用,这些代码是不需要删除的
-
接口检查
import _ "net/http/pprof" // 只使用pprof的init(),用于记录http处理程序的调试信息
if _,ok = val.(io.Reader) {} // 检查val的类型是否实现了io.Reader接口 如果我们不想在运行过程中,自己写上面的代码去检查某类型是否实现了某接口 也可以让编译器去检查 var _ io.Reader = (*val)(nil) 利用全局变量去检查
接口检查是强烈推荐的,因为会在编译期将错误识别出来,而不是运行期.
接口检查的使用,仅使用到空白标识符和类型断言. 可以判断某个类型是否有实现某个接口.
虽然官方文档不推荐对每个实现都来"接口合理性检查", 但uber编码规范还是推荐,好处是保证运行时,这块的逻辑没问题.
go没有c++的类型继承体系,也没有类型驱动的子类化概念。 在结构体或接口中的内嵌,可以让新类型拥有老类型的部分实现。
- 接口的内嵌,只有接口能被嵌入到接口中,且方法集不相交
- 结构体的内嵌,内嵌类型的方法会称为外部类型的方法,调用时,接收者是内嵌类型
结构体内嵌的命名冲突:外部覆盖内部
这就是编程语言中说的:组合优于继承
对于结构体内嵌,如果内嵌的是指针,那么初始化时需要只想有效的对象, 不然使用时会出错.
不是说内嵌比带字段名在各个场景都优秀. 当内嵌类型的方法集发生变化时,会影响外部类型, 如果此时用字段名的方式,就不会受到影响,这个叫bookkeeping.
内嵌和c++的子类化还是有区别的: 内嵌后,通过接收器调用方法,调用的是内嵌的,而不是外部类型的.
Go中的并发,是Go的高光时刻.
go的规则:线程不主动共享,要共享的值通过信道传递。 任意时刻,只有一个协程能访问该值,这样从设计上杜绝数据竞争。
这个规则换成另一句话是:不通过共享内存来通讯,而是通过通讯来共享内存。
这里的通讯是指读写值,同步是指让值的读写在同一时刻只有一个操作。 go的思想是专门弄出一个新的东西,这个东西负责读写值,其他要访问值的都通过新东西来做, 这样只要保证新东西同一时刻只有一个使用者,这样就不存在值访问竞争了。 这个新东西叫信道。
10个人吃饭,只有1双公筷,那就需要保证同一时间只有一个人得到筷子, go中是这样的:有个服务员来拿筷子,其他人要菜时由服务员处理。 10个人对应多个线程,公筷对应资源,第一种需要程序员来控制竞争。 第二种虽然也是类似的,但程序员只需要告诉服务员就行,省了很多事,让go完成。 这个服务员(信道)是语言提供的,可能不会用到第一种中的锁,性能也应该有提升。
go协程开销很小,只使用栈空间,比重新申请栈空间的代价小很多. go协程会复用多线程,当一个协程阻塞,另一个协程会执行,所以效率杠杠地.
go func() 就可以了 func会在后台运行
在Go中,函数字面量是一个闭包(外部函数的变量的生命周期得到了延长).
channel 也需要通过make来申请内存,信道对象是对底层数据结构的引用。
c1 := make(chan int) // 不带缓冲,同步信道
c2 := make(chan int, 10) // 缓冲信道,异步信道
- 同步信道,在两个协程交换数据时,保证同步
- 缓冲信道,发送者只在缓冲满了才阻塞
缓冲信道,可以作为信号量,限制吞吐量
信道的信道,是go的一大杀器
同步信道channel,最常用法(最简单用法)是等待一个后台协程处理完,返回。 缓冲信道,最常用的就是限流。同一时间最多处理几个请求。
信道channel作为Go的一等公民,可以和其他公民一样,可以申请和传递. 信道的信道,作为一种安全的并行多路复用,还是很常见的.
无竞争/并行/无阻塞的rpc系统,且无mutex,可以利用channel of channel来实现.
多cpu多核处理场景
go中多使用并发,可以有更多的概率触发并行
go是为并发而设计的语言,不是并行,但可以尝试解决一些并行问题,但不是全部。
利用缓冲信道,很容易写出导致缓冲区槽位泄露的情况
在包中实现error接口,自定义错误信息,调试时非常有用
内建的panic()函数会产生一个运行时错误并终止程序。
内建函数recover(),可在出现异常的情况下,取回程序的执行权。