Go语言精进之路:从新手到高手的编程思想、方法和技巧1

作者白明分类计算机-编程设计
推荐值85.39999999999999 %来源微信读书
笔记数量91评论数量

第一部分 熟知Go语言的一切

第1条 了解Go语言的诞生与演进

1.1 Go语言的诞生

  • 理想情况是用一个源文件替代.h和.c文件,模块的接口应该被自动提取出来
  • 言初学者经常称这门语言为golang,其实这是不对的:golang仅应用于命名Go语言官方网站,当时之所以使用golang.org作为Go语言官方域名,是因为go.com已经被迪士尼公司占用了。

1.3 Go语言正式发布并开源

  • 这些项目也让Go被誉为“云计算基础设施编程语言”。

第3条 理解Go语言的设计哲学

3.1 追求简单,少即是多

  • Go语言实际上是复杂的,但只是让大家感觉很简单。

3.2 偏好组合,正交解耦

  • 每个类型都可以有自己的方法集合,类型定义与方法实现是正交独立的;接口(interface)与其实现之间隐式关联;
  • Go语言为我们呈现了这样一幅图景:一座座没有关联的“孤岛”,但每个岛内又都很精彩。现在摆在面前的工作就是以最适当的方式在这些孤岛之间建立关联(耦合),形成一个整体。Go采用了组合的方式,也是唯一的方式。
  • 隐式的interface实现会不经意间满足依赖抽象、里氏替换、接口隔离等设计原则,这在其他语言中是需要很刻意的设计谋划才能实现的,但在Go interface看来,一切却是自然而然的。

3.3 原生并发,轻量高效

  • Go运行时默认为每个goroutine分配的栈空间仅2KB。goroutine调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。

3.4 面向工程,“自带电池”

  • ,并在Go语言最初设计阶段就将解决工程问题作为Go的设计原则之一去考虑Go语法、工具链与标准库的设计,这也是Go与那些偏学院派、偏研究性编程语言在设计思路上的一个重大差异
  • 虽然Python的缩进结构在构建小规模程序时的确很方便,但是当代码库变得更大的时候,缩进式的结构非常容易出错。从工程的安全性和可靠性角度考虑,Go团队最终选择了大括号代码块结构。
  • 处理依赖关系时,有时会通过允许一部分重复代码来避免引入较多依赖关系。比如:net包具有其自己的整数到十进制转换实现,以避免依赖于较大且依赖性较强的格式化io包。
  • Go被称为“自带电池”(battery-included)的编程语言

第4条 使用Go语言原生编程思维来写Go代码

4.1 语言与思维——来自大师的观点

  • 人类自然语言学界有一个很著名的假说——“萨丕尔-沃夫假说”,这个假说的内容是这样的:“语言影响或决定人类的思维方式。”

第二部分 项目结构、代码风格与标识符命名

第5条 使用得到公认且广泛使用的项目结构

5.2 Go语言典型项目结构

  • cmd目录:存放项目要构建的可执行文件对应的main包的源文件。如果有多个可执行文件需要构建,则将每个可执行文件的main包单独放在一个子目录中
  • cobra也是这么分配的,还以为是巧合
    cmd目录:存放项目要构建的可执行文件对应的main包的源文件
  • 当然internal也可以放在项目结构中的任一目录层级中,关键是项目结构设计人员明确哪些要暴露到外层代码,哪些仅用于同级目录或子目录中。
  • 参考项目结构与产品设计开发领域的最小可行产品(Minimum Viable Product,MVP)的思路异曲同工,

第6条 提交前使用gofmt格式化源码

6.2 使用gofmt

  • 上面gofmt -r命令执行的意图就是先将源码文件gofmt_demo.go中能与a[3:len(a)]匹配的代码替换为a[3:],然后重新格式化
  • 公共库替换等场景能用上
    上面gofmt -r命令执行的意图就是先将源码文件gofmt_demo.go中能与a[3:len(a)]匹配的代码替换为a[3:],然后重新格式化
  • 上述命令中的a并不是一个具体的字符,而是代表的一个通配符。出现在'pattern -> replacement'中的小写字母都会被视为通配符。我们将pattern中的3改为字母b(通配符):

6.3 使用goimports

  • 可以认为goimports是在gofmt之上又封装了一层,而且goimports提供的命令行选项和参数与gofmt也十分类似

6.4 将gofmt/goimports与IDE或编辑器工具集成

  • Go开发人员多使用各种主流编辑器进行代码的编写、测试和重构工作,他们一般会将gofmt/goimports与编辑器集成,由编辑器在保存源文件时自动调用gofmt/goimports完成代码的格式化,而几乎不会手动敲入gofmt命令进行代码格式化

第7条 使用Go命名惯例对标识符进行命名

  • 计算机科学中只有两件难事:缓存失效和命名。——Phil Karlton,Netscape架构师
  • 要想做好Go标识符的命名(包括对包的命名),至少要遵循两个原则:简单且一致;利用上下文辅助命名。

7.1 简单且一致

  • 我们在给包命名时不要有是否与其他包重名的顾虑,因为在Go中,包名可以不唯一。
  • Go语言建议,包名应尽量与包导入路径(import path)的最后一个路径分段保持一致
  • 但在实际情况中,包名与导入路径最后分段不同的也有很多。比如:实时分布式消息队列NSQ的官方客户端包的导入路径为github.com/nsqio/go-nsq,但是该路径下面的包名却是nsq。
  • 显然在导入路径中出现两次“nsq”字样的这种“口吃”现象也是不被Go官方推荐的。
  • 此外,我们在给包命名的时候,不仅要考虑包自身的名字,还要兼顾该包导出的标识符(如变量、常量、类型、函数等)的命名。由于对这些包导出标识符的引用必须以包名为前缀,因此对包导出标识符命名时,在名字中不要再包含包名,
  • Go语言官方要求标识符命名采用驼峰命名法(CamelCase)
  • 不过如果缩略词的首字母是大写的,那么其他字母也要保持全部大写,比如HTTP(Hypertext Transfer Protocol)、CBC(Cipher Block Chaining)等。
  • 我们看到了大量单字母的标识符命名,这是Go在命名上的一个惯例。一般来说,Go标识符仍以单个单词作为命名首选。
  • 循环和条件变量多采用单个字母命名(具体见上面的统计数据);
  • 函数/方法的参数和返回值变量以单个单词或单个字母为主
  • 由于方法在调用时会绑定类型信息,因此方法的命名以单个单词为主;函数多以多单词的复合词进行命名;类型多以多单词的复合词进行命名。
  • 变量名字中不要带有类型信息
  • 不过有些开发者会认为:userSlice中的类型信息可以告诉我们变量所代表的底层存储是一个切片,这样便可以在userSlice上应用切片的各种操作了。提出这样质疑的开发者显然忘记了一条编程语言命名的惯例:保持变量声明与使用之间的距离越近越好,或者在第一次使用变量之前声明该变量。这个惯例与Go核心团队的Andrew Gerrard曾说的“一个名字的声明和使用之间的距离越大,这个名字的长度就越长”异曲同工。
  • 保持简短命名变量含义上的一致性
  • 变量v、k、i的常用含义
  • 变量t的常用含义:
  • 变量b的常用含义:
  • 常量在命名方式上与变量并无较大差别,并不要求全部大写。只是考虑其含义的准确传递,常量多使用多单词组合的方式命名。
  • 可以对名称本身就是全大写的特定常量使用全大写的名字,比如数学计算中的PI,或是为了与系统错误码、系统信号名称保持一致而用全大写方式命名:
  • 。在Go语言中,对于接口类型优先以单个单词命名。对于拥有唯一方法(method)或通过多个拥有唯一方法的接口组合而成的接口,Go语言的惯例是用“方法名+er”命名。

7.2 利用上下文环境,让最短的名字携带足够多的信息

  • Go在给标识符命名时还有着考虑上下文环境的惯例,即在不影响可读性的前提下,兼顾一致性原则,尽可能地用短小的名字命名标识符。这与其他一些主流语言在命名上的建议有所不同,比如Java建议遵循“见名知义”的命名原则

第三部分 声明、类型、语句与控制结构

第8条 使用一致的变量声明形式

  • Gopher在变量声明形式的选择上应尽量保持项目范围内一致。

8.1 包级变量的声明形式

  • 我们看到,对于在声明变量的同时进行显式初始化的这类包级变量,实践中多使用下面的格式:var variableName = InitExpressionGo编译器会自动根据等号右侧的InitExpression表达式求值的类型确定左侧所声明变量的类型。
  • 虽然没有显式初始化,但Go语言会让这些变量拥有初始的“零值”
  • Go语言提供var块用于将多个变量声明语句放在一起,并且在语法上不会限制放置在var块中的声明类型。但是我们一般将同一类的变量声明放在一个var块中,将不同类的声明放在不同的var块中;或者将延迟初始化的变量声明放在一个var块,而将声明并显式初始化的变量放在另一个var块中。

8.2 局部变量的声明形式

  • 对于声明且显式初始化的局部变量,建议使用短变量声明形式短变量声明形式是局部变量最常用的声明形式,它遍布Go标准库代码。
  • 尽量在分支控制时应用短变量声明形式这应该是Go中短变量声明形式应用最广泛的场景
  • 图8-1 变量声明形式使用决策流程图

第9条 使用无类型常量简化代码

9.1 Go常量溯源

  • const。Go语言中的const整合了C语言中宏定义常量、const只读变量和枚举常量三种形式,并消除了每种形式的不足,使得Go常量成为类型安全且对编译器优化友好的语法元素
  • 绝大多数情况下,Go常量在声明时并不显式指定类型,也就是说使用的是无类型常量(untyped constant)。

9.2 有类型常量带来的烦恼

  • 有类型常量给代码简化带来了麻烦,但这也是Go语言对类型安全严格要求的结果。

9.3 无类型常量消除烦恼,简化代码

  • Go的无类型常量恰恰就拥有像字面值这样的特性,该特性使得无类型常量在参与变量赋值和计算过程时无须显式类型转换,从而达到简化代码的目的:

第10条 使用iota实现枚举常量

  • const ( Apple, Banana = 11, 22 Strawberry, Grape Pear, Watermelon)常量定义的后两行没有显式给予初始赋值,Go编译器将为其隐式使用第一行的表达式
  • iota的值是该行在const块中的偏移量,因此iota的值为0,
  • 枚举常量多数是无类型常量,如果要严格考虑类型安全,也可以定义有类型枚举常量。

第11条 尽量定义零值可用的类型

11.2 零值可用

  • Go从诞生以来就一直秉承着尽量保持“零值可用”的理念,
  • 传统的思维,对于值为nil的变量,我们要先为其赋上合理的值后才能使用。但由于Go中的切片类型具备零值可用的特性,我们可以直接对其进行append操作,而不会出现引用nil的错误。
  • 不过Go并非所有类型都是零值可用的,并且零值可用也有一定的限制,比如:在append场景下,零值可用的切片类型不能通过下标形式操作数据:var s []ints[0] = 12 // 报错!s = append(s, 12) // 正确另外,像map这样的原生类型也没有提供对零值可用的支持

第12条 使用复合字面值作为初值构造器

12.1 结构体复合字面值

  • 使用go vet工具对Go源码进行过静态代码分析的读者可能会知道,go vet工具中内置了一条检查规则:composites。
  • 复合字面值作为结构体值构造器的大量使用,使得即便采用类型零值时我们也会使用字面值构造器形式:s := myStruct{} // 常用而较少使用new这一个Go预定义的函数来创建结构体变量实例

第13条 了解切片实现原理并高效使用

13.1 切片究竟是什么

  • 在C语言中,数组变量可视为指向数组第一个元素的指针。而在Go语言中传递数组是纯粹的值拷贝,对于元素类型长度较大或元素个数较多的数组,如果直接以数组类型参数传递到函数中会有不小的性能损耗。这时很多人会使用数组指针类型来定义函数参数,然后将数组地址传进函数,这样做的确可以避免性能损耗,但这是C语言的惯用法,在Go语言中,更地道的方式是使用切片。
  • 切片作为参数传递带来的性能损耗都是很小且恒定的,甚至小到可以忽略不计,这就是函数在参数中多使用切片而不用数组指针的原因之一。而另一个原因就是切片可以提供比指针更为强大的功能,比如下标访问、边界溢出校验、动态扩容等。而C程序员最喜爱的指针本身在Go语言中的功能受到了限制,比如不支持指针算术运算等。

13.2 切片的高级特性:动态扩容

  • Go切片还支持一个重要的高级特性:动态扩容
  • 我们看到append会根据切片的需要,在当前底层数组容量无法满足的情况下,动态分配新的数组,新数组长度会按一定算法扩展(参见$GOROOT/src/runtime/slice.go中的growslice函数)。
  • 新数组建立后,append会把旧数组中的数据复制到新数组中,之后新数组便成为切片的底层数组,旧数组后续会被垃圾回收掉。这样的append操作有时会给Gopher带来一些困惑,比如通过语法u[low: high]形式进行数组切片化而创建的切片,一旦切片cap触碰到数组的上界,再对切片进行append操作,切片就会和原数组解除绑定

13.3 尽量使用cap参数创建切片

  • 尽量使用cap参数创建切片

第14条 了解map实现原理并高效使用

14.1 什么是map

  • map对value的类型没有限制,但是对key的类型有严格要求:key的类型应该严格定义了作为“==”和“!=”两个操作符的操作数时的行为,因此函数、map、切片不能作为map的key类型。

14.2 map的基本操作

  • 综上,Go语言的一个最佳实践是总是使用“comma ok”惯用法读取map中的值。
  • 我们看到对同一map做多次遍历,遍历的元素次序并不相同。这是因为Go运行时在初始化map迭代器时对起始位置做了随机处理。因此千万不要依赖遍历map所得到的元素次序。

14.3 map的内部实现

  • nevacuate:在map扩容阶段充当扩容进度计数器。所有下标号小于nevacuate的bucket都已经完成了数据排空和迁移操作
  • 当某个bucket(比如buckets[0])的8个空槽(slot)都已填满且map尚未达到扩容条件时,运行时会建立overflow bucket,并将该overflow bucket挂在上面bucket(如buckets[0])末尾的overflow指针上,这样两个bucket形成了一个链表结构,该结构的存在将持续到下一次map扩容。

14.4 尽量使用cap参数创建map

  • ,如果可能的话,我们最好对map使用规模做出粗略的估算,并使用cap参数对map实例进行初始化。

第15条 了解string实现原理并高效使用

15.1 Go语言的字符串类型

  • 对string进行切片化后,Go编译器会为切片变量重新分配底层存储而不是共用string的底层存储,因此对切片的修改并未对原string的数据产生任何影响。
  • 我们看到,对string的底层的数据存储区仅能进行只读操作,一旦试图修改那块区域的数据,便会得到SIGBUS的运行时错误,对string数据的“篡改攻击”再次以失败告终。

第16条 理解Go语言的包导入

16.1 Go程序构建过程

  • 我们在编译app1时给go build命令传入-x -v命令行选项来输出详细的构建日志信息
  • 所谓的使用第三方包源码,实际上是链接了以该最新包源码编译的、存放在临时目录下的包的.a文件而已。
  • 显然和依赖第三方包一样,依赖标准库包在编译时也是需要所依赖的标准库包的源码的。
  • 这说明默认情况下对于标准库中的包,编译器直接链接的是$GOROOT/pkg/darwin_amd64下的.a文件。
  • go build -a可以让编译器将Go源文件(比如例子中的main.go)的所有直接和间接的依赖包(包括标准库)都重新编译一遍,并将最新的.a作为链接器的输入

16.2 究竟是路径名还是包名

  • 包导入语句中的只是一个路径。不过Go语言有一个惯用法,那就是包导入路径的最后一段目录名最好与包名一致,

第19条 了解Go语言控制语句惯用法及使用注意事项

19.2 for range的避“坑”指南

  • 参与循环的是range表达式的副本。也就是说在上面这个例子中,真正参与循环的是a的副本,而不是真正的a
  • 切片与数组还有一个不同点,就是其len在运行时可以被改变,而数组的长度可认为是一个常量,不可改变
  • ange表达式的复制行为还会带来一些性能上的消耗,尤其是当range表达式的类型为数组时,range需要复制整个数组;而当range表达式类型为数组指针或切片时,这个消耗将小得多,因为仅仅需要复制一个指针或一个切片的内部表示(一个结构体)即可。
  • 不过for range对于string来说,每次循环的单位是一个rune,而不是一个byte,返回的第一个值为迭代字符码点的第一字节的位置

第四部分 函数与方法

第20条 在init函数中检查包级变量的初始状态

20.2 程序初始化顺序

  • init函数适合做包级数据的初始化及初始状态检查工作的前提条件是,init函数的执行顺位排在其所在包的包级变量之后。

20.3 使用init函数检查包级变量的初始状态

  • 一旦init函数在检查包数据初始状态时遇到失败或错误的情况(尽管极少出现),则说明对包的“质检”亮了红灯,如果让包“出厂”,那么只会导致更为严重的影响。因此,在这种情况下,快速失败是最佳选择。我们一般建议直接调用panic或者通过log.Fatal等函数记录异常日志,然后让程序快速退出。

第21条 让自己习惯于函数是“一等公民”

21.2 函数作为“一等公民”的特殊运用

  • 我们想将MyAdd函数赋值给BinaryAdder接口。直接赋值是不行的,我们需要一个底层函数类型与MyAdd一致的自定义类型的显式转换,这个自定义类型就是MyAdderFunc,该类型实现了BinaryAdder接口,这样在经过MyAdderFunc的显式类型转换后,MyAdd被赋值给了BinaryAdder的变量i。这样,通过i调用的Add方法实质上就是MyAdd函数。

第22条 使用defer让函数更简洁、更健壮

22.1 defer的运作机制

  • deferred函数是一个在任何情况下都可以为函数进行收尾工作的好场合。

22.2 defer的常见用法

  • deferred函数虽然可以拦截绝大部分的panic,但无法拦截并恢复一些运行时之外的致命问题。
See all book notesSee all book notes