Go语言学习

329 阅读15分钟

零、概述

写这篇文章的时候,我已经看了《Head First Go》、《Go语言精进之路》等,也初步使用了下Kratos。所以可以简单描述下对Go的感觉。

  1. Go语言语法比较简单,像Go的圣经《Effective Go》换算成A4纸也不到20页。
  2. 其中有些语法感觉很幼稚,但是细想又很符合Go对自己简单的定位。
  3. Go语言官方包不如Java多、完善,所以很多东西要自己动手写(嫌弃)。
  4. 整体感觉有必要学一下。

一、学习中有趣的问题记录

  1. 我的学习路线:
  • 先在极客时间上看了两个专栏。 用来入门不推荐,不是很系统。
  • 看了《Effective Go》。推荐,可以快速了解Go的基本语法,增强信息。
  • 《Head First Go》。推荐,很系统的结合案例讲了Go的语法。
  • 《Go语言精进之路》。推荐,有很多实际案例等,进一步加深理解和认知。

二、《Go语言精进之路》的笔记

  1. 很多Go语言初学者经常称这门语言为golang,其实这是不对的:golang仅应用于命名Go语言官方网站,当时之所以使用golang.org作为Go语言官方域名,是因为go.com已经被迪士尼公司占用了。

遇到了很多多年的Gopher,还是喜欢称作Golang。

  1. 简单是一种伟大的美德,但我们需要更艰苦地努力才能实现它,并需要经过一个教育的过程才能去欣赏和领会它。但糟糕的是:复杂的东西似乎更有市场。——Edsger Dijkstra,图灵奖得主
  2. Go的宣言就是“简单”,所以在写代码时也要考虑这点。Go设计者推崇“最小方式”思维,即一件事情仅有一种方式或数量尽可能少的方式去完成,这大大减少了开发人员在选择路径方式及理解他人所选路径方式上的心智负担。
  3. 当我们有必要采用另一种方式处理数据时,我们应该有一些耦合程序的方式,就像花园里将浇水的软管通过预置的螺丝扣拧入另一段那样,这也是Unix IO采用的方式。——Douglas McIlroy,Unix管道的发明者(1964)
  4. Go语言本质上不属于经典OO语言范畴,所以没有继承,而是通过组合的方式进行耦合。
  5. Go语言无类型体系(type hierarchy),类型之间是独立的,没有子类型的概念;每个类型都可以有自己的方法集合,类型定义与方法实现是正交独立的;接口(interface)与其实现之间隐式关联;包(package)之间是相对独立的,没有子包的概念。

Go的文件路劲可以有子路径,但是包是没有的。不像Java那样有子包的概念。所以一般包名都是一个单词,较少想Java那样“java.lang”

  1. interface是Go语言中真正的“魔法”,是Go语言的一个创新设计,它只是方法集合,且与实现者之间的关系是隐式的,它让程序各个部分之间的耦合降至最低,同时是连接程序各个部分的“纽带”。隐式的interface实现会不经意间满足依赖抽象、里氏替换、接口隔离等设计原则,这在其他语言中是需要很刻意的设计谋划才能实现的,但在Go interface看来,一切却是自然而然的。

目前没有发现这种设计对Gopher的帮助,带来的是不爽。像实现一个接口,必须把所有的方法都实现一遍,不像Java那样,可以在编译阶段告知你。 不过Goland提供了快捷键

  1. 并发是有关结构的,而并行是有关执行的。——Rob Pike(2012)
  2. 在人类自然语言学界有一个很著名的假说——“萨丕尔-沃夫假说”,这个假说的内容是这样的:“语言影响或决定人类的思维方式。” 不能影响到你的编程思维方式的编程语言不值得学习和使用。
  3. Go语言找N个素数的代码:
// chapter1/sources/sieve.go

func Generate(ch chan<- int) {
   for i := 2; ; i++ {
      ch <- i
   }
}

func Filter(in <-chan int, out chan<- int, prime int) {
   for {
      i := <-in
      if i%prime != 0 {
         out <- i
      }
   }
}

func main() {
   ch := make(chan int)
   go Generate(ch)
   for i := 0; i < 10; i++ {
      prime := <-ch
      print(prime, "\n")
      ch1 := make(chan int)
      go Filter(ch, ch1, prime)
      ch = ch1
   }
}
  1. 命名。Go语言的贡献者和布道师Dave Cheney给出了一个说法:“一个好笑话,如果你必须解释它,那就不好笑了。好的命名也类似。”至少要遵循两个原则:简单且一致;利用上下文辅助命名。

1)变量名字中不要带有类型信息

保持变量声明与使用之间的距离越近越好,或者在第一次使用变量之前声明该变量。这个惯例与Go核心团队的Andrew Gerrard曾说的“一个名字的声明和使用之间的距离越大,这个名字的长度就越长”异曲同工。如果在一屏之内能看到users的声明,那么-Slice这个类型信息显然不必放在变量的名称中了。

目前不是太认可,觉得清晰重要性大于简单。我比较支持Java的“见名知义”的命名规则。不过Go语言说的利用上下文缩短变量名也有道理。

  1. 常量在命名方式上与变量并无较大差别,并不要求全部大写。只是考虑其含义的准确传递,常量多使用多单词组合的方式命名。
  2. 如果让Go语言的设计者重新设计一次变量声明语法,相信他们很大可能不会再给予Gopher这么大的变量声明灵活性,但目前这一切都无法改变。对于以面向工程著称且以解决规模化问题为目标的Go语言,Gopher在变量声明形式的选择上应尽量保持项目范围内一致。

变量命名方式真的太多了

  1. 变量声明决策流程图

image.png

  1. Go常量成为类型安全且对编译器优化友好的语法元素。Go中所有与常量有关的声明都通过const来进行。
// $GOROOT/src/os/file.go
const (
   O_RDONLY int = syscall.O_RDONLY
   O_WRONLY int = syscall.O_WRONLY
   O_RDWR   int = syscall.O_RDWR
   O_APPEND int = syscall.O_APPEND
)

绝大多数情况下,Go常量在声明时并不显式指定类型,也就是说使用的是无类型常量(untyped constant).

// $GOROOT/src/io/io.go
const (
   SeekStart   = 0
   SeekCurrent = 1
   SeekEnd     = 2
)

因为有类型常量容易带来问题。 1)Go对类型的要求十分严格,如果有类型很容易出错。

type myInt int

func main() {
   var a int = 5
   var b myInt = 6
   fmt.Println(a + b) // 编译器会给出错误提示:invalid operation: a + b (mismatched  types int and myInt)
}
const a = 123

func main() {
   b := 5
   c := 6.0
   fmt.Println(a + b) // 可以正确运行
   fmt.Println(a + c) // 可以正确运行
}

让我对类型推断了有了好感。以前以为“const a = 123”是省略了int32标记,实际上这里已经确定了类型。通过上面的案例,明白在真实计算时才确定了类型。

  1. 当通过声明或调用new为变量分配存储空间,或者通过复合文字字面量或调用make创建新值,且不提供显式初始化时,Go会为变量或值提供默认值。
    • 所有整型类型:0
    • 浮点类型:0.0
    • 布尔类型:false
    • 字符串类型:""
    • 指针、interface、切片(slice)、channel、map、function:nil

Go从诞生以来就一直秉承着尽量保持“零值可用”的理念,来看两个例子。

var zeroSlice []int
fmt.Println(zeroSlice) //输出[],因为go语言的切片支持零值可用
zeroSlice = append(zeroSlice, 1)
zeroSlice = append(zeroSlice, 2)
zeroSlice = append(zeroSlice, 3)
fmt.Println(zeroSlice) // 输出:[1 2 3]
func main() {
   var p *net.TCPAddr
   fmt.Println(p) //输出:<nil>。因为里面调用了p.String()。TCPAddr在设计的时候支持了零值可用
}

总结:在写函数的时候,尽量支持零值可用。也可以考虑场景不用支持,例如map就不支持。 18. Go数组是值语义的,这意味着一个数组变量表示的是整个数组,这点与C语言完全不同。在C语言中,数组变量可视为指向数组第一个元素的指针。而在Go语言中传递数组是纯粹的值拷贝,对于元素类型长度较大或元素个数较多的数组,如果直接以数组类型参数传递到函数中会有不小的性能损耗。

重点:Go语言中一个数组变量表示的是整个数组。

  1. 我们可以称切片是数组的“描述符”。切片之所以能在函数参数传递时避免较大性能损耗,是因为它是“描述符”的特性,切片这个描述符是固定大小的,无论底层的数组元素类型有多大,切片打开的窗口有多长。
// $GOROOT/src/runtime/slice.go
type slice struct {
   array unsafe.Pointer //指向下层数据某元素的指针,该元素也是切片的起始元素
   len   int            //切片的长度,即切片中当前元素的个数
   cap   int            //切片的最大容量,cap>=len。
}

运行时,每个切片变量都是一个runtime.slice结构体类型的实例。 创建切片的方式有下面几种:

s := make([]byte,5)//make创建。这种会创建个底层数组
u := [2]byte{11,23}
s1 := u[0,1]// 这种底层数据和数组共用空间,在s1发生扩容之后,才会自建空间。所以没有扩容前,对s1、u的改变都会在另外一个上面也体现。扩容动作会触发新空间的建立,数据复制,老空间的回收
  1. map不支持零值可用,未明显赋值的map类型变量的零值为nil。和切片一样,创建map类型变量有两种方式:一种是使用复合字面值,另一种是使用make这个预声明的内置函数。map也是一种引用类型。
// $GOROOT/src/net/status.go
var statusText = map[int]string{
   StatusOK:                   "OK",
   StatusCreated:              "Created",
   StatusAccepted:             "Accepted",
   ...
}

// $GOROOT/src/net/client.go
icookies = make(map[string][]*Cookie)

// $GOROOT/src/net/h2_bundle.go
http2commonLowerHeader = make(map[string]string, len(common))
  1. Go的普通map是不支持并发读写的,会直接报panic。Go 1.9版本中引入了支持并发写安全的sync.Map类型,可以用来在并发读写的场景下替换掉map。另外考虑到map可以自动扩容,map中数据元素的value位置可能在这一过程中发生变化,因此Go不允许获取map中value的地址,这个约束是在编译期间就生效的。

  2. string类型的数据是不可变的;获取长度的时间复杂度是o(1)级别;原生支持多行字符串。其他的特性在其他语言也有。

  3. string类型也是一个描述符,它本身并不真正存储数据,而仅是由一个指向底层存储的指针和字符串的长度字段组成。直接将string类型通过函数/方法参数传入也不会有太多的损耗,因为传入的仅仅是一个“描述符”,而不是真正的字符串数据。

  4. 做了预初始化的strings.Builder连接构建字符串效率最高;带有预初始化的bytes.Buffer和strings.Join这两种方法效率十分接近,分列二三位;未做预初始化的strings.Builder、bytes.Buffer和操作符连接在第三档次;fmt.Sprintf性能最差,排在末尾。由此可以得出一些结论:在能预估出最终字符串长度的情况下,使用预初始化的strings.Builder连接构建字符串效率最高;strings.Join连接构建字符串的平均性能最稳定,如果输入的多个字符串是以[]string承载的,那么strings.Join也是不错的选择;使用操作符连接的方式最直观、最自然,在编译器知晓欲连接的字符串个数的情况下,使用此种方式可以得到编译器的优化处理;fmt.Sprintf虽然效率不高,但也不是一无是处,如果是由多种不同类型变量来构建特定格式的字符串,那么这种方式还是最适合的。

  5. 无论是string转slice还是slice转string,转换都是要付出代价的,这些代价的根源在于string是不可变的,运行时要为转换后的类型分配新内存。想要更高效地进行转换,唯一的方法就是减少甚至避免额外的内存分配操作。slice类型是不可比较的,而string类型是可比较的,因此在日常Go编码中,我们会经常遇到将slice临时转换为string的情况。Go编译器为这样的场景提供了优化。

  6. 构建Go程序时,编译器会重新编译依赖包的源文件还是直接链接包的.a文件呢?

    • 在使用第三方包的时候,在第三方包源码存在且对应的.a已安装的情况下,编译器链接的仍是根据第三方包最新源码编译出的.a文件,而不是之前已经安装到$GOPATH/pkg/darwin_amd64下的目标文件。
    • 编译器在编译过程中必然要使用的是编译单元(一个包)所依赖的包的源码。

这里没有仔细看,后面有时间再了解。主要有几个疑问:1)go语言会有Java的类加载过程吗?2)go如何对源码加密(提供接口,方便导入,然后提供编译后的文件用来编译?)。

  1. 编译器要找到依赖包的源码文件,就需要知道依赖包的源码路径。这个路径由两部分组成:基础搜索路径和包导入路径。

    • 基础搜索路径是一个全局的设置,下面是规则描述:
      • 所有包(无论是标准库还是第三方包)的源码基础搜索路径都包括$GOROOT/src。
      • 在上述基础搜索路径的基础上,不同版本的Go包含的其他基础搜索路径有不同。
        • Go 1.11 版本之前,包的源码基础搜索路径还包括$GOPATH/src
        • Go 1.11 ~1.12版本,包的源码基础搜索路径有三种模式:1)经典gopath模式下(GO111MODULE=off):GOPATH/src2moduleaware模式下(GO111MODULE=on)GOPATH/src;2)module-aware模式下(GO111MODULE=on):GOPATH/pkg/mod;3)auto模式下(GO111MODULE=auto):在GOPATH/src路径下,与gopath模式相同,在GOPATH/src路径下,与gopath模式相同,在GOPATH/src路径外且包含go.mod,与module-aware模式相同。
      • 搜索路径的第二部分就是每个包源码文件头部的包导入路径
    • 源文件头部的包导入语句import后面的部分就是一个路径,路径的最后一个分段也不是包名。包导入语句中的只是一个路径。不过Go语言有一个惯用法,那就是包导入路径的最后一段目录名最好与包名一致。
    • Go语言还有一个惯用法:当包名与包导入路径中的最后一个目录名不同时,最好用下面的语法将包名显式放入包导入语句。
  2. Go编译器在编译过程中必然要使用的是编译单元(一个包)所依赖的包的源码;Go源码文件头部的包导入语句中import后面的部分是一个路径,路径的最后一个分段是目录名,而不是包名;Go编译器的包源码搜索路径由基本搜索路径和包导入路径组成,两者结合在一起后,编译器便可确定一个包的所有依赖包的源码路径的集合,这个集合构成了Go编译器的源码搜索路径空间;同一源码文件的依赖包在同一源码搜索路径空间下的包名冲突问题可以由显式指定包名的方式解决。

  3. Go语言的表达式求值顺序

n0,n1 := 1,2
n0,n1 = n0+n1,n0 //n0和n1分别是什么?
  • 包级别变量声明语句的表达式求值顺序是由初始化依赖(initialization dependencies)规则决定的。
    • 包级别变量的初始化按照变量声明的先后顺序进行。如果某个变量(如变量a)的初始化表达式中直接或间接依赖其他变量(如变量b),那么a在b的后面。
    • 同一包内但不同文件中的变量的声明顺序依赖编译器处理文件的顺序
  • Go规定表达式操作数中的所有函数、方法以及channel操作按照从左到右的次序进行求值。
  • 赋值语句分为两个阶段:
    • 第一阶段:对于等号左边的下标表达式、指针解引用表达式和等号右边表达式中的操作数,按照普通求值规则从左到右进行求值;
    • 第二阶段,按照从左到右的顺序对变量进行赋值。
  1. 使用if控制语句时应遵循“快乐路径”原则——尽早返回等。
  2. for range的避坑指南
  • 小心迭代变量的重用。空间地址不变,值会迭代。
func main() {
   m := map[string]string{
      "test1": "test1",
      "test3": "test2",
      "test2": "test3",
   }

   for k, v := range m {
      fmt.Printf("kAddress:%v,kValue:%v,vAddress:%v,vValue:%v \n", &k, k, &v, v)
   }
}
  • 注意参与迭代的是range表达式的副本。
func arrayRangeExpression() {
   var a = [5]int{1, 2, 3, 4, 5}
   var r [5]int

   fmt.Println("arrayRangeExpression result:")
   fmt.Println("a = ", a)

   for i, v := range a {//这里的a是数组的a的副本(不是v是数组某个元素的副本)。所以一般循环使用&a或者切片来替代。推荐取看《Go语言精进之路》19.2,论述很严谨。
      if i == 0 {
         a[1] = 12
         a[2] = 13
      }

      r[i] = v
   }

   fmt.Println("r = ", r)
   fmt.Println("a = ", a)
}

期待的输出结果,r和改变后的a是相同的。实际上r和改变前的a是相同的。

  • Go语言里面break=label的场景要比Java多。
  1. 一个Go包可以拥有多个init函数,每个组成Go包的Go源文件中可以定义多个init函数。在初始化Go包时,Go运行时会按照一定的次序逐一调用该包的init函数。Go运行时不会并发调用init函数,它会等待一个init函数执行完毕并返回后再执行下一个init函数,且每个init函数在整个Go程序生命周期内仅会被执行一次。因此,init函数极其适合做一些包级数据的初始化及初始状态的检查工作。不要依赖init函数的执行次序。
  2. 如果一门编程语言对某种语言元素的创建和使用没有限制,我们可以像对待值(value)一样对待这种语法元素,那么我们就称这种语法元素是这门编程语言的“一等公民”.
  3. 函数可以当做类型。
type HandlerFunc func(ResponseWriter, * Request)
  1. 显式类型转换
// chapter4/sources/function_as_first_class_citizen_3.go

type BinaryAdder interface {
   Add(int, int) int
}

type MyAdderFunc func(int, int) int

func (f MyAdderFunc) Add(x, y int) int {
   return f(x, y)
}

func MyAdd(x, y int) int {
   return x + y
}

func main() {
   var i BinaryAdder = MyAdderFunc(MyAdd)
   fmt.Println(i.Add(5, 6))
}

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

函数没办法赋值给接口,然后给函数写一个接口的实现,然后利用方法的特性就可以实现类型转换。

  1. 虽然Go不推崇函数式编程,但有些时候局部应用函数式编程风格可以写出更优雅、更简洁、更易维护的代码。
  • 函数柯里化(currying)。在计算机科学中,柯里化是把接受多个参数的函数变换成接受一个单一参数(原函数的第一个参数)的函数,并返回接受余下的参数和返回结果的新函数的技术
  • 闭包是在函数内部定义的匿名函数,并且允许该匿名函数访问定义它的外部函数的作用域。本质上,闭包是将函数内部和函数外部连接起来的桥梁。
  • 函子需要满足两个条件
    • 函子本身是一个容器类型,以Go语言为例,这个容器可以是切片、map甚至是channel。
    • 该容器需要实现一个方法,该方法接受一个函数类型参数,并在容器的每个元素上应用的那个函数,得到一个新函子,原函子容器内部的元素值不受影响。

    没怎么看懂函子,后续重新学习。《Go语言精进之路1》21.2

  1. defer
  • 只有在函数和方法内部才能使用defer;
  • deter关键字后面只能接受函数或方法,这些函数被称为deferred函数。

defer将它们注册到其所在goroutine用于存放deferred函数的栈数据结构中,这些deferred函数将在执行defer的函数退出前被按后进先出(LIFO)的顺序调度执行。

image.png 38. defer的机制是入栈和出栈,所以在使用过程中要考虑这个点——例如出栈入栈的顺序会影响数据执行的顺序。 39. Go方法具有如下特点。1)方法名的首字母是否大写决定了该方法是不是导出方法。2)方法定义要与类型定义放在同一个包内。由此我们可以推出:不能为原生类型(如int、float64、map等)添加方法,只能为自定义类型定义方法(示例代码如下)。3)receiver参数的基类型本身不能是指针类型或接口类型——不能是对基本类型的指针进行二次声明。

这种方式有合理性,也有不爽的地方。例如我定义了dao类型,提供对数据查询能力。但是当下面的文件很多,我想分类管理时就无法实现。

-dao

----article_pack

--------article.go(这里面没办法实现方法)

  1. 方法的本质:出了绑定接口外,方法的本质是为了类型提供了“this”能力。Go方法的本质:一个以方法所绑定类型实例为第一个参数的普通函数。
  2. 结构体里面可以嵌入接口。

没想清楚使用场景。看起来很灵活,但是很难理解,等后面有经验了再看。

  1. 虽然string类型变量可以直接赋值给interface{}类型变量,但是[]string类型变量并不能直接赋值给[]interface{}类型变量。
  2. 学习到了一种选项模式。Rob Pike的文章
  3. 接口是Go这门静态类型语言中唯一的“动静兼备”的语言特性。
  • 接口的静态特性

接口类型变量具有静态类型,比如:var e error中变量e的静态类型为error。支持在编译阶段的类型检查:当一个接口类型变量被赋值时,编译器会检查右值的类型是否实现了该接口方法集合中的所有方法。

_ 接口的动态特性

接口类型变量兼具动态类型,即在运行时存储在接口类型变量中的值的真实类型。比如:var i interface{} = 13中接口变量i的动态类型为int。接口类型变量在程序运行时可以被赋值为不同的动态类型变量,从而支持运行时多态。

  1. interface内部有两种表示:eface和iface。eface(empty interface)表示没有方法的空接口;iface用于表示拥有方法的接口类型变量。
// $GOROOT/src/runtime/runtime2.go
type iface struct {
   tab  *itab
   data unsafe.Pointer
}

type eface struct {
   _type *_type
   data  unsafe.Pointer //没有方法,但是可以存储数据
}
type T struct {
   n int
   s string
}

func main() {
   var t = T {
      n: 17,
      s: "hello, interface",
   }
   var ei interface{} = t // Go运行时使用eface结构表示ei
}

空接口可以用来表示任何类型:任何结构体都实现了空接口。

  1. 在Go语言中,将任意类型赋值给一个接口类型变量都是装箱操作。有了前面对接口类型变量内部表示的了解,我们知道接口类型的装箱实则就是创建一个eface或iface的过程。
  2. 尽量定义小接口。
  • 接口越小,抽象程度越高,被接纳度越高——可以用接口组装新接口。
  • 易于实现和测试
  • 契约职责单一(结构体实现了接口的全部方法,那么就是实现了接口),易于复用组合
  1. 定义小接口可以遵循的方法
    1. 抽象出接口。在定义小接口之前,我们需要首先深入理解问题域,聚焦抽象并发现接口。初期不要在意接口的大小,对问题域的理解是循序渐进的。
    1. 将大接口拆分为小接口。
    1. 接口的单一契约职责。
  1. 尽量避免使用空接口作为函数参数类型——空接口不提供任何信息(不为编译器提供任何信息,所以失去编译器事前检查的功能等。尽量不要使用可以逃过编译器类型安全检查的空接口类型interface{})
  2. 并发不是并行,并发关乎结构,并行关乎执行。
  3. goroutine是由Go运行时管理的用户层轻量级线程。
  4. goroutine调度模型与演进过程
  • G-M模型 在这个模型中,每个goroutine对应运行时中的一个抽象结构-G(goroutine),而被视作“物理CPU”的操作系统线程则被抽象为另一个结构-M(machine)
  • G-P-M模型

image.png 53. 不要通过共享内存来通信,而应该通过通信来共享内存。 54. CSP(communicating sequential Process,通信顺序进程)模型。

image.png 在Tony Hoare眼中,一个符合CSP模型的并发程序应该是一组通过输入/输出原语连接起来的P的集合。从这个角度来看,CSP理论不仅是一个并发参考模型,也是一种并发程序的程序组织方法。其组合思想与Go的设计哲学不谋而合。CSP理论中的P(Process,进程)是个抽象概念,它代表任何顺序处理逻辑的封装,它获取输入数据(或从其他P的输出获取),并生产可以被其他P消费的输出数据。

  1. Go针对CSP模型提供了三种并发原语。
  • goroutine:对应CSP模型中的P,封装了数据的处理逻辑,是Go运行时调度的基本执行单元。
  • channel:对应CSP模型中的输入/输出原语,用于goroutine之间的通信和同步。
  • select:用于应对多路输入/输出,可以让goroutine同时协调处理多个channel操作。

目前理解起来有难度,后面再看。

  1. 错误处理的策略与构造错误值的方法是密切关联的——go语言的错误采用了C语言的方式,是个值,可以进行比较。
  2. 在标准库中,Go提供了构造错误值的两种基本方法——errors.New和fmt.Errorf
  3. 测试代码与包代码放在同一个包目录下,并且Go要求所有测试代码都存放在以*_test.go结尾的文件中。
  4. 包内测试与包外测试的差别:将测试代码放在与被测包同名的包中的测试方法称为“包内测试”。把将测试代码放在名为被测包包名+"_test"的包中的测试方法称为“包外测试”。

包内测试容易循环依赖,导致变异不通过。优先使用包外测试,可以通过“安插后门”的方式解决对未导出变量的访问。

// $GOROOT/src/fmt/export_test.go
package fmt

var IsSpace = isSpace
var Parsenum = parsenum
  1. 测试固件(test fixture)是指一个人造的、确定性的环境,一个测试用例或一个测试套件(下的一组测试用例)在这个环境中进行测试,其测试结果是可重复的(多次测试运行的结果是相同的)。我们一般使用setUp和tearDown来代表测试固件的创建/设置与拆除/销毁的动作。
  2. stub也是一种替身概念,和fake替身相比,stub替身增强了对替身返回结果的间接控制能力,这种控制可以通过测试前对调用结果预设置来实现。不过,stub替身通常仅针对计划之内的结果进行设置,对计划之外的请求也无能为力。
  3. mock的能力强大,应用范围要窄很多,只用于实现某接口的实现类型的替身。
  4. go-fuzz:根据维基百科的定义,模糊测试就是指半自动或自动地为程序提供非法的、非预期、随机的数据,并监控程序在这些输入数据下是否会出现崩溃、内置断言失败、内存泄露、安全漏洞等情况。
  5. 性能基准测试在Go语言中是“一等公民”。我们可以像对普通单元测试那样在*_test.go文件中创建被测对象的性能基准测试,每个以Benchmark前缀开头的函数都会被当作一个独立的性能基准测试:
func BenchmarkXxx(b *testing.B) {
    //...
}