Golang基础(二)
函数
函数定义
函数特点
支持:
- 无需声明原型
- 支持不定变参
- 支持多返回值
- 支持命名返回参数
- 支持匿名函数和闭包
- 函数也是一种类型,一个函数可以赋值给变量
不支持:
- 不支持嵌套 (nested): 一个包不能有两个名字一样的函数
- 不支持重载 (overload)
- 不支持默认参数 (default parameter)
函数声明
函数声明包含一个函数名,参数列表, 返回值列表和函数体。如果函数没有返回值,则返回列表可以省略。函数从第一条语句开始执行,直到执行return语句或者执行函数的最后一条语句。
函数可以没有参数或接受多个参数。
注意类型在变量名之后 ,当两个或多个连续的函数命名参数是同一类型,则除了最后一个类型之外,其他都可以省略。(如:func name(x,y,int,s string)
)
函数可以返回任意数量的返回值。使用关键字 func
定义函数,左大括号依旧不能另起一行。
例子:
1 | // 类型相同的相邻参数,参数类型可合并。 多返回值必须用括号。 |
函数是第一类对象,可作为参数传递。建议将复杂签名定义为函数类型,以便于阅读。
看个例子:
1 | package main |
有返回值的函数,必须有明确的终止语句(要有return
),否则会引发编译错误。
可能会偶尔遇到没有函数体的函数声明,这表示该函数不是以Go实现的。这样的声明定义了函数标识符。
1
2
3 package math
func Sin(x float64) float //implemented in assembly language
函数的参数
函数定义时指出,函数定义时有参数,该变量可称为函数的形参。形参就像定义在函数体内的局部变量。
但当调用函数,传递过来的变量就是函数的实参,函数可以通过两种方式来传递参数:
-
值传递:指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数。
1
2
3func swap(x, y int) int {
... ...
} -
引用传递:是指在调用函数时将实际参数的地址传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数。
1
2
3func swap(x, y *int) {
... ...
}
这里提一嘴,go中交换两个数不需要专门写个swap函数,直接这样处理就行:
1
2
3
4
5
6
7
8
9
10
11 package main
import "fmt"
func main() {
x := 10
y := 20
fmt.Println(x, y) // 10 20
x, y = y, x
fmt.Println(x, y) // 20 10
}
在默认情况下,Go 语言使用的是值传递,即在调用过程中不会影响到实际参数。
注意:
- 无论是值传递,还是引用传递,传递给函数的都是变量的副本,不过,值传递是值的拷贝。引用传递是地址的拷贝,一般来说,地址拷贝更为高效。而值拷贝取决于拷贝的对象大小,对象越大,则性能越低
map
、slice
、chan
、指针、interface
默认以引用的方式传递
不定参数
不定参数传值就是函数的参数不是固定的,后面的类型是固定的。(可变参数)
Golang 可变参数本质上就是 slice。只能有一个,且必须是最后一个。
使用不定参数,在参数赋值时可以不用用一个一个的赋值,可以直接传递一个数组或者切片,特别注意的是在参数后加上…
即可。
1 | func myfunc(args ...int) { //0个或多个参数 |
注意:其中args是一个slice,我们可以通过arg[index]
依次访问所有参数,通过len(arg)
来判断传递参数的个数。
任意类型的不定参数: 就是函数的参数和每个参数的类型都不是固定的。
用interface{}
传递任意类型数据是Go语言的惯例用法,而且interface{}
是类型安全的。
1 | func myfunc(args ...interface{}) { |
示例:
不定参数:
1 | package main |
使用 slice 对象做变参时,必须展开!(slice...
)
1 | package main |
任意类型的不定参数:
1 | package main |
函数的返回值
命名返回参数
**Go 的返回值可以被命名,并且就像在函数体开头声明的变量那样使用。**返回值的名称应当具有一定的意义,可以作为文档使用。
没有参数的 return 语句返回各个返回变量的当前值。这种用法被称作“裸”返回。直接返回语句仅应当用在像下面这样的短函数中。在长的函数中它们会影响代码的可读性。
命名返回参数可看做与形参类似的局部变量,最后由 return
隐式返回:
1 | package main |
命名返回参数可被同名局部变量遮蔽,此时需要显式返回。
1 | func add(x, y int) (z int) { |
命名返回参数允许 defer
延迟调用通过闭包读取和修改。
1 | package main |
显式 return
返回前,会先修改命名返回参数。
1 | package main |
运行结果如下:
1 | 103 |
多个返回值
Golang返回值不能用容器对象接收多返回值。只能用多个变量,或 _
忽略。_
标识符,用来忽略函数的某个返回值。
多返回值可直接作为其他函数调用实参。看个例子:
1 | package main |
匿名函数
匿名函数是指不需要定义函数名的一种函数实现方式。1958年LISP首先采用匿名函数。
在Go里面,函数可以像普通变量一样被传递或使用,Go语言支持随时在代码里定义匿名函数。
匿名函数由一个不带函数名的函数声明和函数体组成。匿名函数的优越性在于可以直接使用函数内的变量,不必声明。
例子:
1 | package main |
这里先定义了一个名为sqrt的变量,初始化该变量时和之前的变量初始化有些不同,使用了func,func是定义函数的,可是这个函数和上面说的函数最大不同就是没有函数名,也就是匿名函数。这里将一个函数当做一个变量一样的操作。
Golang匿名函数可赋值给变量,做为结构字段,或者在 channel
里传送。
例子:
1 | package main |
闭包
闭包概念
闭包是由函数及其相关引用环境组合而成的实体(即:闭包=函数+引用环境)。
官方的解释是:所谓闭包,指的是一个拥有许多变量和绑定了这些变量的环境的表达式(通常是一个函数),因而这些变量也是该表达式的一部分。
维基百科讲,闭包(Closure),是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。所以,有另一种说法认为闭包是由函数和与其相关的引用环境组合而成的实体。闭包在运行时可以有多个实例,不同的引用环境和相同的函数组合可以产生不同的实例。
看着上面的描述,会发现闭包和匿名函数似乎有些像。可是可能还是有些云里雾里的。因为跳过闭包的创建过程直接理解闭包的定义是非常困难的。目前在JavaScript、Go、PHP、Scala、Scheme、Common Lisp、Smalltalk、Groovy、Ruby、 Python、Lua、objective c、Swift 以及Java8以上等语言中都能找到对闭包不同程度的支持。通过支持闭包的语法可以发现一个特点,他们都有垃圾回收(GC)机制。 javascript应该是普及度比较高的编程语言了,通过这个来举例应该好理解写。看下面的代码,只要关注script里方法的定义和调用就可以了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<html lang="zh">
<head>
<title></title>
</head>
<body>
</body>
</html>
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.2.6/jquery.min.js" type="text/javascript"></script>
<script>
function a(){
var i=0;
function b(){
console.log(++i);
document.write("<h1>"+i+"</h1>");
}
return b;
}
$(function(){
var c=a();
c();
c();
c();
//a(); //不会有信息输出
document.write("<h1>=============</h1>");
var c2=a();
c2();
c2();
});
</script>这段代码有两个特点:
函数b嵌套在函数a内部 函数a返回函数b 这样在执行完var c=a()后,变量c实际上是指向了函数b(),再执行函数c()后就会显示i的值,第一次为1,第二次为2,第三次为3,以此类推。 其实,这段代码就创建了一个闭包。因为函数a()外的变量c引用了函数a()内的函数b(),就是说:
当函数a()的内部函数b()被函数a()外的一个变量引用的时候,就创建了一个闭包。 在上面的例子中,由于闭包的存在使得函数a()返回后,a中的i始终存在,这样每次执行c(),i都是自加1后的值。 从上面可以看出闭包的作用就是在a()执行完并返回后,闭包使得Javascript的垃圾回收机制GC不会收回a()所占用的资源,因为a()的内部函数b()的执行需要依赖a()中的变量i。
在给定函数被多次调用的过程中,这些私有变量能够保持其持久性。变量的作用域仅限于包含它们的函数,因此无法从其它程序代码部分进行访问。不过,变量的生存期是可以很长,在一次函数调用期间所创建所生成的值在下次函数调用时仍然存在。正因为这一特点,闭包可以用来完成信息隐藏,并进而应用于需要状态表达的某些编程范型中。 下面来想象另一种情况,如果a()返回的不是函数b(),情况就完全不同了。因为a()执行完后,b()没有被返回给a()的外界,只是被a()所引用,而此时a()也只会被b()引 用,因此函数a()和b()互相引用但又不被外界打扰(被外界引用),函数a和b就会被GC回收。所以直接调用a();是页面并没有信息输出。
下面来说闭包的另一要素引用环境。c()跟c2()引用的是不同的环境,在调用i++时修改的不是同一个i,因此两次的输出都是1。函数a()每进入一次,就形成了一个新的环境,对应的闭包中,函数都是同一个函数,环境却是引用不同的环境。这和c()和c()的调用顺序都是无关的。
Go的闭包
Go语言是支持闭包的,这里简单地讲一下在Go语言中闭包是如何实现的。
例子:
1 | package main |
结果如下:
闭包复制的是原对象指针,这就很容易解释延迟引用现象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 package main
import "fmt"
func test() func() {
x := 100
fmt.Printf("x (%p) = %d\n", &x, x)
return func() {
fmt.Printf("x (%p) = %d\n", &x, x)
}
}
func main() {
f := test()
f()
}输出:
1
2 x (0xc42007c008) = 100
x (0xc42007c008) = 100在汇编层 ,test 实际返回的是 FuncVal 对象,其中包含了匿名函数地址、闭包对象指针。当调 匿名函数时,只需以某个寄存器传递该对象即可。
1 FuncVal { func_address, closure_var_pointer ... }外部引用函数参数局部变量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 package main
import "fmt"
// 外部引用函数参数局部变量
func add(base int) func(int) int {
return func(i int) int {
base += i
return base
}
}
func main() {
tmp1 := add(10)
fmt.Println(tmp1(1), tmp1(2))
// 此时tmp1和tmp2不是一个实体了
tmp2 := add(100)
fmt.Println(tmp2(1), tmp2(2))
}返回2个闭包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28 package main
import "fmt"
// 返回2个函数类型的返回值
func test01(base int) (func(int) int, func(int) int) {
// 定义2个函数,并返回
// 相加
add := func(i int) int {
base += i
return base
}
// 相减
sub := func(i int) int {
base -= i
return base
}
// 返回
return add, sub
}
func main() {
f1, f2 := test01(10)
// base一直是没有消
fmt.Println(f1(1), f2(2))
// 此时base是9
fmt.Println(f1(3), f2(4))
}
递归
递归,就是在运行的过程中调用自己。 一个函数调用自己,就叫做递归函数。
构成递归需具备的条件:
- 子问题须与原始问题为同样的事,且更为简单
- 不能无限制地调用本身,须有个出口,化简为非递归状况处理
阶乘
求一个数的阶乘,直接看代码:
1 | package main |
斐波那契数列
代码如下:
1 | package main |
延迟调用(defer)
Golang延迟调用
defer特性:
- 关键字
defer
用于注册延迟调用 - 这些调用直到
return
前才被执。因此可以用来做资源清理 - 多个
defer
语句,按先进后出的方式执行(最前面的defer语句最后执行) - defer语句中的变量,在defer声明时就决定了
defer用途:
- 关闭文件句柄
- 锁资源释放
- 数据库连接释放
go语言的defer
功能强大,对于资源管理非常方便,但是如果没用好,也会有陷阱。
defer是先进后出。这个很自然,后面的语句会依赖前面的资源,因此如果先前面的资源先释放了,后面的语句就没法执行了。
1 | package main |
结果为:
defer 碰上闭包
1 | package main |
结果为:
其实go说的很清楚,我们一起来看看go spec如何说的:
Each time a “defer” statement executes, the function value and parameters to the call are evaluated as usualand saved anew but the actual function is not invoked.
也就是说函数正常执行,由于闭包用到的变量i
在执行的时候已经变成2,所以输出全都是2。
defer f.Close
这个大家用的都很频繁,但是go语言编程举了一个可能一不小心会犯错的例子.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 package main
import "fmt"
type Test struct {
name string
}
func (t *Test) Close() {
fmt.Println(t.name, " closed")
}
func main() {
ts := []Test{{"a"}, {"b"}, {"c"}}
for _, t := range ts {
defer t.Close()
}
}输出结果:
1
2
3 c closed
c closed
c closed这个输出并不会像我们预计的输出c b a,而是输出c c c
可是按照前面的go spec中的说明,应该输出c b a才对啊.
那我们换一种方式来调用一下.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 package main
import "fmt"
type Test struct {
name string
}
func (t *Test) Close() {
fmt.Println(t.name, " closed")
}
func Close(t Test) {
t.Close()
}
func main() {
ts := []Test{{"a"}, {"b"}, {"c"}}
for _, t := range ts {
defer Close(t)
}
}输出结果:
1
2
3 c closed
b closed
a closed这个时候输出的就是c b a
当然,如果你不想多写一个函数,也很简单,可以像下面这样,同样会输出c b a
看似多此一举的声明
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 package main
import "fmt"
type Test struct {
name string
}
func (t *Test) Close() {
fmt.Println(t.name, " closed")
}
func main() {
ts := []Test{{"a"}, {"b"}, {"c"}}
for _, t := range ts {
t2 := t
defer t2.Close()
}
}输出结果:
1
2
3 c closed
b closed
a closed通过以上例子,结合
Each time a "defer" statement executes, the function value and parameters to the call are evaluated as usualand saved anew but the actual function is not invoked.
这句话。可以得出下面的结论:defer后面的语句在执行的时候,函数调用的参数会被保存起来,但是不执行。也就是复制了一份。但是并没有说struct这里的this指针如何处理,通过这个例子可以看出go语言并没有把这个明确写出来的this指针当作参数来看待。
多个defer注册,按 FILO 次序执行 ( 先进后出 )。哪怕函数或某个延迟调用发生错误,这些调用依旧会被执行。
1 | package main |
defer陷阱
异常处理
panic&recover
Golang没有结构化异常,使panic
抛出错误;recover
捕获错误。
异常的使用场景简单描述:Go中可以抛出一个panic
的异常,然后在defer
中通过recover
捕获这个异常,然后正常处理。
panic
:
- 内置函数
- 假如函数
F()
中书写了panic
语句,会终止其后要执行的代码,在panic
所在函数F()
内如果存在要执行的defer
函数列表,按照defer
的逆序执行 - 返回函数
F()
的调用者G()
,在G()
中,调用函数F()
语句之后的代码不会执行,假如函数G()
中存在要执行的defer
函数列表,按照defer
的逆序执行 - 直到goroutine整个退出,并报告错误
recover
:
- 内置函数
- 用来控制一个goroutine的panicking行为,捕获
panic
,从而影响应用的行为
一般的调用建议:
- 在
defer
函数中,通过recever
来终止一个goroutine的panicking过程,从而恢复正常代码的执行 - 可以获取通过
panic
传递的error
注意:
- 利用
recover
处理panic
指令,defer
必须放在panic
之前定义,另外recover
只有在defer
调用的函数中才有效。否则当panic
时,recover
无法捕获到panic
,无法防止panic
扩散。 recover
处理异常后,逻辑并不会恢复到panic
那个点去,函数跑到defer
之后的那个点。- 多个
defer
会形成defer
栈,后定义的defer
语句会被最先调用。
例子:
1 | package main |
输出结果:
由于panic
、recover
参数类型为interface{}
,因此可抛出任何类型对象。
源码:
1 func panic(v interface{})
1 func recover() interface{}
接下来看几个具体的例子来熟悉一下。
-
向已关闭的通道发送数据会引发
panic
。1
2
3
4
5
6
7
8
9
10
11
12
13
14package main
import "fmt"
func main() {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
}
}()
ch := make(chan int)
close(ch)
ch <- 1
}结果为:
-
延迟调用中引发的错误,可被后续延迟调用捕获,但仅最后一个错误可被捕获。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19package main
import "fmt"
func main() {
test()
}
func test() {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
}
}()
defer func() {
panic("defer panic")
}()
panic("test panic")
}结果为:
-
捕获函数
recover
只有在延迟调用内直接调用才会终止错误,否则总是返回nil
。任何未捕获的错误都会沿调用堆栈向外传递。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23package main
import "fmt"
func test() {
defer func() {
fmt.Println(recover()) //有效
}()
defer recover() //无效!
defer fmt.Println(recover()) //无效!
defer func() {
func() {
println("defer inner")
recover() //无效!
}()
}()
panic("test panic")
}
func main() {
test()
}结果为:
-
使用延迟匿名函数,或者像下面这样都是有效的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18package main
import "fmt"
func main() {
test()
}
func test() {
defer except()
panic("test panic")
}
func except() {
if err := recover(); err != nil {
fmt.Println(err)
}
}结果为:
-
如果需要保护代码段,可将代码块重构成匿名函数,如此可确保后续代码被执。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21package main
import "fmt"
func main() {
test(2, 1)
}
func test(x int, y int) {
var z int
func() {
defer func() {
if recover() != nil {
z = 0
}
}()
panic("test panic")
z = x / y
}()
fmt.Printf("%v/%v = %v \n", x, y, z)
}
error
除用panic
引发中断性错误外,还可返回error
类型错误对象来表示函数调用状态。
源码:
1
2
3
4
5 // The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
Error() string
}
标准库errors.New()
和fmt.Errorf()
函数用于创建实现error
接口的错误对象。通过判断错误对象实例来确定具体错误类型。
看个例子:
1 | package main |
结果为:
panic or error?
惯例是:导致关键流程出现不可修复性错误的使用panic
,其他使用 error
。
单元测试
go test工具
Go语言中的测试依赖go test
命令。编写测试代码和编写普通的Go代码过程是类似的,并不需要学习新的语法、规则或工具。
go test
命令是一个按照一定约定和组织的测试代码的驱动程序。在包目录内,所有以_test.go
为后缀名的源代码文件都是go test
测试的一部分,不会被go build
编译到最终的可执行文件中。
在*_test.go
文件中有三种类型的函数,单元测试函数、基准测试函数和示例函数。
类型 | 格式 | 作用 |
---|---|---|
测试函数 | 函数名前缀为Test |
测试程序的一些逻辑行为是否正确 |
基准函数 | 函数名前缀为Benchmark |
测试函数的性能 |
示例函数 | 函数名前缀为Example |
为文档提供示例文档 |
go test
命令会遍历所有的*_test.go
文件中符合上述命名规则的函数,然后生成一个临时的main包用于调用相应的测试函数,然后构建并运行、报告测试结果,最后清理测试中生成的临时文件。
Golang单元测试对文件名和方法名,参数都有很严格的要求:
- 文件名必须以
xx_test.go
命名 - 方法必须是
Test
开头 - 方法参数必须是
t *testing.T
- 使用
go test
执行单元测试
go test参数解读
go test
是go语言自带的测试工具,其中包含的是两类:单元测试和性能测试。
通过go help test
可以看到go test
的使用说明:
格式形如:
1 | go test [-c] [-i] [build flags] [packages] [flags for test binary] |
参数解读:
-c
: 编译go test成为可执行的二进制文件,但是不运行测试
-i
: 安装测试包依赖的package,但是不运行测试
build flags
:调用go help build,这些是编译运行过程中需要使用到的参数,一般设置为空
packages
:调用go help packages,这些是关于包的管理,一般设置为空
flags for test binary
,调用go help testflag,这些是go test
过程中经常使用到的参数:
-test.v
: 是否输出全部的单元测试用例(不管成功或者失败),默认没有加上,所以只输出失败的单元测试用例
-test.run pattern
: 只跑哪些单元测试用例
-test.bench patten
: 只跑那些性能测试用例
-test.benchmem
: 是否在性能测试的时候输出内存情况
-test.benchtime t
: 性能测试运行的时间,默认是1s
-test.cpuprofile cpu.out
: 是否输出cpu性能分析文件
-test.memprofile mem.out
: 是否输出内存性能分析文件
-test.blockprofile block.out
: 是否输出内部goroutine阻塞的性能分析文件
-test.memprofilerate n
: 内存性能分析的时候有一个分配了多少的时候才打点记录的问题。这个参数就是设置打点的内存分配间隔,也就是profile中一个sample代表的内存大小。默认是设置为512 * 1024的。如果你将它设置为1,则每分配一个内存块就会在profile中有个打点,那么生成的profile的sample就会非常多。如果你设置为0,那就是不做打点了你可以通过设置memprofilerate=1和GOGC=off来关闭内存回收,并且对每个内存块的分配进行观察
-test.blockprofilerate n
: 基本同上,控制的是goroutine阻塞时候打点的纳秒数。默认不设置就相当于-test.blockprofilerate=1,每一纳秒都打点记录一下
-test.parallel n
: 性能测试的程序并行cpu数,默认等于GOMAXPROCS
-test.timeout t
: 如果测试用例运行时间超过t,则抛出panic
-test.cpu 1,2,4
: 程序运行在哪些CPU上面,使用二进制的1所在位代表,和nginx的nginx_worker_cpu_affinity是一个道理
-test.short
: 将那些运行时间较长的测试用例运行时间缩短
测试函数
测试函数的格式
每个测试函数必须导入testing
包,测试函数的基本格式(签名)如下:
1 | func TestName(t *testing.T){ |
测试函数的名字必须以Test开头,可选的后缀名必须以大写字母开头,举几个例子:
1 | func TestAdd(t *testing.T){ ... } |
其中参数t用于报告测试失败和附加的日志信息。 testing.T
的拥有的方法如下:
1 | func (c *T) Error(args ...interface{}) |
测试函数示例
就像细胞是构成我们身体的基本单位,一个软件程序也是由很多单元组件构成的。单元组件可以是函数、结构体、方法和最终用户可能依赖的任意东西。总之我们需要确保这些组件是能够正常运行的。单元测试是一些利用各种方法测试单元组件的程序,它会将结果与预期输出进行比较。
测试组
子测试
Go1.7+中新增了子测试,我们可以按照如下方式使用t.Run
执行子测试:
1 | func TestSplit(t *testing.T) { |
此时我们再执行go test
命令就能够看到更清晰的输出内容了:
1 | split $ go test -v |
测试覆盖率
测试覆盖率是你的代码被测试套件覆盖的百分比。通常我们使用的都是语句的覆盖率,也就是在测试中至少被运行一次的代码占总代码的比例。
Go提供内置功能来检查你的代码覆盖率。我们可以使用go test -cover
来查看测试覆盖率。例如:
1 | split $ go test -cover |
从上面的结果可以看到我们的测试用例覆盖了100%的代码。
Go还提供了一个额外的-coverprofile
参数,用来将覆盖率相关的记录信息输出到一个文件。例如:
1 | split $ go test -cover -coverprofile=c.out |
上面的命令会将覆盖率相关的信息输出到当前文件夹下面的c.out文件中,然后我们执行go tool cover -html=c.out
,使用cover工具来处理生成的记录信息,该命令会打开本地的浏览器窗口生成一个HTML报告。
基准测试
基准测试函数格式
基准测试就是在一定的工作负载之下检测程序性能的一种方法。基准测试的基本格式如下:
1 | func BenchmarkName(b *testing.B){ |
基准测试以Benchmark
为前缀,需要一个*testing.B
类型的参数b,基准测试必须要执行b.N次,这样的测试才有对照性,b.N的值是系统根据实际情况去调整的,从而保证测试的稳定性。
基准测试示例
我们为split包中的Split函数编写基准测试如下:
1 | func BenchmarkSplit(b *testing.B) { |
基准测试并不会默认执行,需要增加-bench参数,所以我们通过执行go test -bench=Split命令执行基准测试,输出结果如下:
1 | split $ go test -bench=Split |
其中BenchmarkSplit-8表示对Split函数进行基准测试,数字8表示GOMAXPROCS的值,这个对于并发基准测试很重要。10000000和203ns/op表示每次调用Split函数耗时203ns,这个结果是10000000次调用的平均值。
性能比较函数
上面的基准测试只能得到给定操作的绝对耗时,但是在很多性能问题是发生在两个不同操作之间的相对耗时,比如同一个函数处理1000个元素的耗时与处理1万甚至100万个元素的耗时的差别是多少?再或者对于同一个任务究竟使用哪种算法性能最佳?我们通常需要对两个不同算法的实现使用相同的输入来进行基准比较测试。
性能比较函数通常是一个带有参数的函数,被多个不同的Benchmark函数传入不同的值来调用。举个例子如下:
1 | func benchmark(b *testing.B, size int){/* ... */} |
例如我们编写了一个计算斐波那契数列的函数如下:
1 | // fib.go |
我们编写的性能比较函数如下:
1 | // fib_test.go |
运行基准测试:
1 | split $ go test -bench=. |
这里需要注意的是,默认情况下,每个基准测试至少运行1秒。如果在Benchmark函数返回时没有到1秒,则b.N的值会按1,2,5,10,20,50,…增加,并且函数再次运行。
最终的BenchmarkFib40只运行了两次,每次运行的平均值只有不到一秒。像这种情况下我们应该可以使用-benchtime标志增加最小基准时间,以产生更准确的结果。例如:
1 | split $ go test -bench=Fib40 -benchtime=20s |
这一次BenchmarkFib40函数运行了50次,结果就会更准确一些了。
使用性能比较函数做测试的时候一个容易犯的错误就是把b.N作为输入的大小,例如以下两个例子都是错误的示范:
1 | // 错误示范1 |
重置时间
b.ResetTimer之前的处理不会放到执行时间里,也不会输出到报告中,所以可以在之前做一些不计划作为测试报告的操作。例如:
1 | func BenchmarkSplit(b *testing.B) { |
并行测试
func (b *B) RunParallel(body func(*PB))
会以并行的方式执行给定的基准测试。
RunParallel会创建出多个goroutine,并将b.N分配给这些goroutine执行, 其中goroutine数量的默认值为GOMAXPROCS。用户如果想要增加非CPU受限(non-CPU-bound)基准测试的并行性, 那么可以在RunParallel之前调用SetParallelism 。RunParallel通常会与-cpu标志一同使用。
Setup与TearDown
测试程序有时需要在测试之前进行额外的设置(setup
)或在测试之后进行拆卸(teardown
)。
这里就有点像java的
@before
和@after
注解。(Junit中的@Before和@After注解)
TestMain
通过在*_test.go
文件中定义TestMain
函数来可以在测试之前进行额外的设置(setup
)或在测试之后进行拆卸(teardown
)操作。
如果测试文件包含函数:func TestMain(m *testing.M)
那么生成的测试会先调用 TestMain(m),然后再运行具体测试。TestMain运行在主goroutine中, 可以在调用 m.Run前后做任何设置(setup)和拆卸(teardown)。退出测试的时候应该使用m.Run的返回值作为参数调用os.Exit。
一个使用TestMain来设置Setup和TearDown的示例如下:
1 | func TestMain(m *testing.M) { |
需要注意的是:在调用TestMain时, flag.Parse并没有被调用。所以如果TestMain 依赖于command-line标志 (包括 testing 包的标记), 则应该显示的调用flag.Parse。
子测试的Setup与Teardown
有时候我们可能需要为每个测试集设置Setup与Teardown,也有可能需要为每个子测试设置Setup与Teardown。下面我们定义两个函数工具函数如下:
1 | // 测试集的Setup与Teardown |
使用方式如下:
1 | func TestSplit(t *testing.T) { |
测试结果如下:
1 | split $ go test -v |
示例函数
示例函数的格式
被go test
特殊对待的第三种函数就是示例函数,它们的函数名以Example为前缀。它们既没有参数也没有返回值。标准格式如下:
1 | func ExampleName() { |
示例函数示例
下面的代码是我们为Split函数编写的一个示例函数:
1 | func ExampleSplit() { |
为代码编写示例代码有如下三个用处:
- 示例函数能够作为文档直接使用,例如基于web的godoc中能把示例函数与对应的函数或包相关联
- 示例函数只要包含了
// Output:
也是可以通过go test
运行的可执行测试 - 示例函数提供了可以直接运行的示例代码,可以直接在golang.org的godoc文档服务器上使用Go Playground运行示例代码。下图为strings.ToUpper函数在Playground的示例函数效果
压力测试
Go怎么写测试用例
开发程序其中很重要的一点是测试,我们如何保证代码的质量,如何保证每个函数是可运行,运行结果是正确的,又如何保证写出来的代码性能是好的,我们知道单元测试的重点在于发现程序设计或实现的逻辑错误,使问题及早暴露,便于问题的定位解决,而性能测试的重点在于发现程序设计上的一些问题,让线上的程序能够在高并发的情况下还能保持稳定。
Go语言中来实现单元测试和性能测试:Go语言中自带有一个轻量级的测试框架testing和自带的go test命令来实现单元测试和性能测试,testing框架和其他语言中的测试框架类似,你可以基于这个框架写针对相应函数的测试用例,也可以基于该框架写相应的压力测试用例,那么接下来让我们一一来看一下怎么写。
另外建议安装gotests插件自动生成测试代码:
1 | go get -u -v github.com/cweill/gotests/... |
如何编写测试用例
如何编写压力测试
压力测试用来检测函数(方法)的性能,和编写单元功能测试的方法类似,此处不再赘述,但需要注意:
压力测试用例必须遵循如下格式,其中XXX可以是任意字母数字的组合,但是首字母不能是小写字母
1 | func BenchmarkXXX(b *testing.B) { ... } |
go test不会默认执行压力测试的函数,如果要执行压力测试需要带上参数-test.bench,语法:-test.bench=“test_name_regex”,例如go test -test.bench=".*"
表示测试全部的压力测试函数
在压力测试用例中,请记得在循环体内使用testing.B.N,以使测试可以正常的运行 文件名也必须以_test.go结尾
下面我们新建一个压力测试文件webbench_test.go,代码如下所示:
1 | package gotest |
我们执行命令go test webbench_test.go -test.bench=".*"
,可以看到如下结果:
1 | Benchmark_Division-4 500000000 7.76 ns/op 456 B/op 14 allocs/op |
上面的结果显示我们没有执行任何TestXXX的单元测试函数,显示的结果只执行了压力测试函数,第一条显示了Benchmark_Division执行了500000000次,每次的执行平均时间是7.76纳秒,第二条显示了Benchmark_TimeConsumingFunction执行了500000000,每次的平均执行时间是7.80纳秒。最后一条显示总共的执行时间。
通过上面对单元测试和压力测试的学习,我们可以看到testing包很轻量,编写单元测试和压力测试用例非常简单,配合内置的
go test
命令就可以非常方便的进行测试,这样在我们每次修改完代码,执行一下go test
就可以简单的完成回归测试了。
方法
在之前的笔记中已经学习过方法了,这里再详细看一遍。
方法定义
Golang 方法总是绑定对象实例,并隐式将实例作为第一实参 (receiver)。
- 只能为当前包内命名类型定义方法
- 参数 receiver可任意命名(官方建议只用一个小写字母,如person用p)。如方法中未曾使用 ,可省略参数名
- 参数 receiver 类型可以是值类型或指针类型
- 不支持方法重载,receiver 只是参数签名的组成部分
- 可用实例 value 或 pointer 调用全部方法,编译器自动转换
一个方法就是一个包含了接受者的函数,接受者可以是命名类型或者结构体类型的一个值或者是一个指针。
所有给定类型的方法属于该类型的方法集。
方法定义和示例
普通函数与方法的区别
- 对于普通函数,接收者为值类型时,不能将指针类型的数据直接传递,反之亦然
- 对于方法(如struct的方法),接收者为值类型时,可以直接用指针类型的变量调用方法,反过来同样也可以
说人话,就是如果方法的receiver类型是指针,也可以直接用本身来调用。反之亦然。(Go的语法糖)
看个例子就行:
1 | package main |
匿名字段
Golang匿名字段 :可以像字段成员那样访问匿名字段方法,编译器负责查找。
1 | package main |
输出结果:
1 | Manager: 0xc42000a060 |
通过匿名字段,可获得和继承类似的复用能力。依据编译器查找次序,只需在外层定义同名方法,就可以实现 “override”。
1 | package main |
输出结果:
1 | Manager: 0xc420074180, &{{1 Tom} Administrator} |
方法集
Golang方法集 :每个类型都有与之关联的方法集,这会影响到接口实现规则。
- 类型 T 方法集包含全部 receiver T 方法
- 类型 *T 方法集包含全部 receiver T + *T 方法
- 如类型 S 包含匿名字段 T,则 S 和 *S 方法集包含 T 方法
- 如类型 S 包含匿名字段 *T,则 S 和 *S 方法集包含 T + *T 方法
- 不管嵌入 T 或
*T
,*S
方法集总是包含 T + *T 方法
用实例 value 和 pointer 调用方法 (含匿名字段) 不受方法集约束,编译器总是查找全部方法,并自动转换 receiver 实参。
Go 语言中内部类型方法集提升的规则:
-
类型 T 方法集包含全部 receiver T 方法(这个当然是很显然的)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16package main
import "fmt"
type T struct {
a int
}
func (t T) study() {
fmt.Println("类型 T 方法集包含全部 receiver T 方法")
}
func main() {
t := T{a: 1}
t.study()
}输出结果:
-
类型
*T
方法集包含全部receiver T + *T
方法1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21package main
import "fmt"
type T struct {
a int
}
func (t T) study1() {
fmt.Println("类型 *T 方法集包含全部 receiver T 方法")
}
func (t *T) study2() {
fmt.Println("类型 *T 方法集包含全部 receiver *T 方法")
}
func main() {
t := &T{a: 1}
t.study1()
t.study2()
}输出结果:
给定一个结构体类型 S 和一个命名为 T 的类型,方法提升像下面规定的这样被包含在结构体方法集中:
-
如果类型 S 包含匿名字段 T,则 S 和
*S
方法集包含 T 方法这条规则说的是当我们嵌入一个类型,嵌入类型的接受者为值类型的方法将被提升,可以被外部类型的值和指针调用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22package main
import "fmt"
type T struct {
a int
}
type S struct {
T
}
func (t T) study1() {
fmt.Println("如类型 S 包含匿名字段 T,则 S 和 *S 方法集包含 T 方法")
}
func main() {
s1 := S{T{a: 1}}
s2 := &S{T{a: 1}}
s1.study1()
s2.study1()
} -
如果类型 S 包含匿名字段
*T
,则 S 和*S
方法集包含T + *T
方法。这条规则说的是当我们嵌入一个类型的指针,嵌入类型的接受者为值类型或指针类型的方法将被提升,可以被外部类型的值或者指针调用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28package main
import "fmt"
type T struct {
a int
}
type S struct {
*T
}
func (t T) study1() {
fmt.Println("如类型 S 包含匿名字段 *T,则 S 和 *S 方法集包含 T 方法")
}
func (t *T) study2() {
fmt.Println("如类型 S 包含匿名字段 *T,则 S 和 *S 方法集包含 *T 方法")
}
func main() {
s1 := S{&T{a: 1}}
s2 := &S{&T{a: 1}}
s1.study1()
s1.study2()
s2.study1()
s2.study2()
}
表达式
Golang 表达式 :根据调用者不同,方法分为两种表现形式:
1 | instance.method(args...) |
前者称为 method value,后者 method expression。
两者都可像普通函数那样赋值和传参,区别在于 method value 绑定实例,而 method expression 则须显式传参。
1 | package main |
输出结果:
需要注意,method value 会复制 receiver。
1 | package main |
输出结果
1 | {2 Jack} |
剩余部分参考表达式
自定义error
panic&recover
1 | package main |
return error
1 | package main |
自定义error
1 | package main |
输出结果:
1 | get path error, path=/Users/pprof/Desktop/go/src/test.txt |
接口
接口(interface)定义了一个对象的行为规范,只定义规范不实现,由具体的对象来实现规范的细节。
接口类型
在Go语言中接口(interface)是一种类型,一种抽象的类型。
interface是一组method的集合,是duck-type programming的一种体现(If it looks like a duck, and it quacks like a duck, then it is a duck.)。接口做的事情就像是定义一个协议(规则),只要一台机器有洗衣服和甩干的功能,我就称它为洗衣机。不关心属性(数据),只关心行为(方法)。请牢记接口(interface)是一种类型。
为什么要使用接口
1 | type Cat struct{} |
上面的代码中定义了猫和狗,然后它们都会叫,你会发现main函数中明显有重复的代码,如果我们后续再加上猪、青蛙等动物的话,我们的代码还会一直重复下去。那我们能不能把它们当成“能叫的动物”来处理呢?
像类似的例子在我们编程过程中会经常遇到:
比如一个网上商城可能使用支付宝、微信、银联等方式去在线支付,我们能不能把它们当成“支付方式”来处理呢?
比如三角形,四边形,圆形都能计算周长和面积,我们能不能把它们当成“图形”来处理呢?
比如销售、行政、程序员都能计算月薪,我们能不能把他们当成“员工”来处理呢?
Go语言中为了解决类似上面的问题,就设计了接口这个概念。接口区别于我们之前所有的具体类型,接口是一种抽象的类型。当你看到一个接口类型的值时,你不知道它是什么,唯一知道的是通过它的方法能做什么。
接口的定义
Go语言提倡面向接口编程。
- 接口是一个或多个方法签名的集合。任何类型的方法集中只要拥有该接口对应的全部方法签名,就表示它 “实现” 了该接口,无须在该类型上显式声明实现了哪个接口。这称为Structural Typing。所谓对应方法,是指有相同名称、参数列表 (不包括参数名) 以及返回值。当然,该类型还可以有其他方法。
- 接口只有方法声明,没有实现,没有数据字段。
- 接口可以匿名嵌入其他接口,或嵌入到结构中。 对象赋值给接口时,会发生拷贝,而接口内部存储的是指向这个复制品的指针,既无法修改复制品的状态,也无法获取指针。
- 只有当接口存储的类型和对象都为nil时,接口才等于nil。
- 接口调用不会做receiver的自动转换。
- 接口同样支持匿名字段方法。
- 接口也可实现类似OOP中的多态。
- 空接口可以作为任何类型数据的容器。
- 一个类型可实现多个接口。
- 接口命名习惯以
er
结尾。
每个接口由数个方法组成,接口的定义格式如下:
1 | type 接口类型名 interface{ |
其中:
- 接口名:使用
type
将接口定义为自定义的类型名。Go语言的接口在命名时,一般会在单词后面添加er
,如有写操作的接口叫Writer,有字符串功能的接口叫Stringer等。接口名最好要能突出该接口的类型含义。 - 方法名:当方法名首字母是大写且这个接口类型名首字母也是大写时,这个方法可以被接口所在的包(package)之外的代码访问。
- 参数列表、返回值列表:参数列表和返回值列表中的参数变量名可以省略。
举个例子:
1 | type writer interface { |
当你看到这个接口类型的值时,你不知道它是什么,唯一知道的就是可以通过它的Write方法来做一些事情。
实现接口的条件
一个对象只要全部实现了接口中的方法,那么就实现了这个接口。换句话说,接口就是一个需要实现的方法列表。
我们来定义一个Sayer接口:
1 | type Sayer interface { |
定义dog和cat两个结构体:
1 | type dog struct { |
因为Sayer接口里只有一个say方法,所以我们只需要给dog和cat 分别实现say方法就可以实现Sayer接口了:
1 | func (c cat) say() { |
接口的实现就是这么简单,只要实现了接口中的所有方法,就实现了这个接口。
接口类型变量
那实现了接口有什么用呢?
接口类型变量能够存储所有实现了该接口的实例。 例如上面的示例中,Sayer类型的变量能够存储dog和cat类型的变量。
示例如下:
1 | package main |
值接收者和指针接收者实现接口
使用值接收者实现接口和使用指针接收者实现接口有什么区别呢?接下来我们通过一个例子看一下其中的区别。
我们有一个Mover接口和一个dog结构体:
1 | type Mover interface { |
值接收者实现接口
实现接口的是dog类型,例子:
1 | package main |
从上面的代码中我们可以发现,使用值接收者实现接口之后,不管是dog结构体还是结构体指针*dog
类型的变量都可以赋值给该接口变量。
因为Go语言中有对指针类型变量求值的语法糖,dog指针fugui内部会自动求值*fugui
。
指针接收者实现接口
同样的代码我们再来测试一下使用指针接收者有什么区别:
1 | package main |
此时实现Mover接口的是*dog
类型,所以不能给x传入dog类型的wangcai,此时x只能存储*dog
类型的值。
类型与接口的关系
一个类型实现多个接口
一个类型可以同时实现多个接口,而接口间彼此独立,不知道对方的实现。 例如,狗可以叫,也可以动。我们就分别定义Sayer接口和Mover接口,如下:
1 | type Sayer interface { |
dog既可以实现Sayer接口,也可以实现Mover接口。
1 | package main |
多个类型实现同一接口
Go语言中不同的类型还可以实现同一接口。
首先我们定义一个Mover接口,它要求必须由一个move方法。
1 | type Mover interface { |
狗可以动,汽车也可以动,可以使用如下代码实现这个关系:
1 | package main |
这个时候我们在代码中就可以把狗和汽车当成一个会动的物体来处理了,不再需要关注它们具体是什么,只需要调用它们的move方法就可以了。
并且一个接口的方法,不一定需要由一个类型完全实现,接口的方法可以通过在类型中嵌入其他类型或者结构体来实现。例子:
1 | package main |
接口嵌套
接口与接口间可以通过嵌套创造出新的接口。
1 | type sayer interface { |
嵌套得到的接口的使用与普通接口一样,这里我们让cat实现animal接口:
1 | package main |
空接口
在go1.18之后,有关键词
any
来替代interface{}
。源码:
1
2 // any is an alias for interface{} and is equivalent to interface{} in all ways.
type any = interface{}
空接口的定义
空接口是指没有定义任何方法的接口。因此任何类型都实现了空接口。
空接口类型的变量可以存储任意类型的变量。
1 | package main |
空接口的应用
- 空接口作为函数的参数
- 空接口作为map的值
空接口作为函数的参数
使用空接口实现可以接收任意类型的函数参数。
1 | package main |
空接口作为map的值
使用空接口实现可以保存任意值的字典。
1 | package main |
类型断言
空接口可以存储任意类型的值,那我们如何获取其存储的具体数据呢?
这里就要引入一下接口值的概念了:一个接口的值(简称接口值)是由一个具体类型和具体类型的值两部分组成的。这两部分分别称为接口的动态类型和动态值。
我们来看一个具体的例子:
1 | var w io.Writer |
请看下图分解:
想要判断空接口中的值这个时候就可以使用类型断言,其语法格式:
1 | x.(T) |
其中:
- x:表示类型为
interface{}
的变量 - T:表示断言x可能是的类型
该语法返回两个参数,第一个参数是x转化为T类型后的变量,第二个值是一个布尔值,若为true则表示断言成功,为false则表示断言失败。
举个例子:
1 | package main |
上面的示例中如果要断言多次就需要写多个if判断,这个时候我们可以使用switch语句来实现:
1
2
3
4
5
6
7
8
9
10
11
12 func justifyType(x interface{}) {
switch v := x.(type) {
case string:
fmt.Printf("x is a string,value is %v\n", v)
case int:
fmt.Printf("x is a int is %v\n", v)
case bool:
fmt.Printf("x is a bool is %v\n", v)
default:
fmt.Println("unsupport type!")
}
}
因为空接口可以存储任意类型值的特点,所以空接口在Go语言中的使用十分广泛。
关于接口需要注意的是,只有当有两个或两个以上的具体类型必须以相同的方式进行处理时才需要定义接口。不要为了接口而写接口,那样只会增加不必要的抽象,导致不必要的运行时损耗。