Skip to content

Latest commit

 

History

History
535 lines (361 loc) · 21.6 KB

go-effective.md

File metadata and controls

535 lines (361 loc) · 21.6 KB

如何高效写go代码

介绍

新语言,吸收了其他语言中的一些思想,但也不是和其他语言完全一样, 所以需要了解一些细节的设计. 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

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是比较类型.

switch的值会和每个case的值做比较,不一定是int类型,只看比较结果是不是true. 这种格式的switch,和if-else if-else格式是一个概念. 这种格式的switch是没有自动fallthrough的,但一个case是可以带多个值的,逗号隔开. break会结束switch语句,如果要结束外层语句,可以用break带标签.

continue也能带标签,但只能出现在循环中.

type switch

接口.(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非常通用和高效.

map

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) () {} // 表示实参列表是整形

append()

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(),可在出现异常的情况下,取回程序的执行权。