本文分享函数的定义、特性、defer陷阱、异常处理、单元测试、基准测试等
以及方法和接口相关内容
1 函数
函数的定义
func 函数名(参数列表) (返回值列表) { // 函数体(实现函数功能的代码)
}
匿名函数的定义就是没有函数名,可以当做一个函数变量操作
func main() {sum := func(x,y int) (z int) {z = x + yreturn }fmt.Println(sum(4,5))//9
}
GO的函数特点:
声明方式:使用
func
关键字进行声明,包括函数名、参数列表、返回值列表和函数体,无需声明原型参数和返回值:
可以有多个参数,每个参数带有类型。
支持返回多个值。
如果只有一个返回值且不声明变量,可以省略返回值的括号。
不定长度变参:支持不定长参数列表,使得函数更加灵活。
命名返回值参数:允许为返回值命名,增强代码可读性。
函数作为类型:函数是一种类型,可以赋值给变量,也可以作为参数传递。
不支持的特性:
不支持嵌套函数(即一个函数内部不能定义另一个函数)。
不支持函数重载(即不能根据参数类型和数量的不同来定义多个同名的函数)。
不支持默认参数(即不能为参数设置默认值)。
匿名函数和闭包:
匿名函数:没有函数名的函数,可以直接定义并赋值给变量。
闭包:能访问和操作其外部词法环境的函数。
声明函数的注意事项:
函数名命名规范:函数名应遵循Go语言的标识符命名规范。函数名的首字母大小写决定了其可见性,即是否可以在其他包中访问。如果首字母大写,则该函数可以被本包和其他包使用,类似public;如果首字母小写,则只能被本包使用,其他包不能使用,类似private。
参数和返回值:
函数的参数列表和返回值列表需要用圆括号包围。每个参数后面应带有其类型。
如果有多个返回值,它们的类型也需要被明确指定。
如果返回值名称被省略,则必须在函数体中使用
return
语句返回具体的值。
函数体:函数体用大括号{}
包围,且左大括号{
必须位于函数声明行的末尾,与func
关键字在同一行。
局部变量:在函数内部定义的变量是局部的,只能在函数内部使用。它们不会在函数外部生效。
参数传递:Go语言中的基本数据类型和数组默认是通过值传递的,即函数接收的是这些值的副本。在函数内部对这些值的修改不会影响到原始数据。如果需要修改函数外部的变量,可以通过传递变量的地址(使用&
操作符)来实现,函数内部可以通过指针来操作这些变量。
不支持函数重载:Go语言不支持传统的函数重载,即不能根据参数的类型或数量来定义多个同名的函数。但是,由于Go支持可变参数和空接口,开发者可以通过这些特性来模拟实现类似函数重载的效果。
函数作为类型:在Go语言中,函数本身也是一种类型,可以被赋值给变量,也可以作为参数传递给其他函数。这使得函数可以作为一等公民在Go程序中使用,增加了代码的灵活性和可复用性。
函数参数的传递
在Go语言中,函数参数的传递方式主要有两种:值传递(pass by value)和引用传递(pass by reference)。但值得注意的是,Go中没有直接的引用传递方式,而是通过指针实现类似引用传递的效果。
值传递:
当函数参数是基本数据类型(如整数、浮点数、布尔值、字符串等)或结构体时,它们默认是通过值传递的。
函数接收的是这些值的副本,函数内部对参数的修改不会影响到函数外部的原始数据。
示例代码(值传递):
package main import "fmt" func modifyValue(x int) { x = 100 // 修改副本的值
} func main() { y := 50 fmt.Println("Before function call:", y) // 输出: Before function call: 50 modifyValue(y) fmt.Println("After function call:", y) // 输出: After function call: 50 // y的值没有改变,因为modifyValue接收的是y的副本
}
通过指针实现引用传递:
当需要修改函数外部的变量时,可以传递变量的地址(指针)给函数。
函数内部通过指针来操作变量,这样就能够修改函数外部的原始数据。
示例代码(通过指针实现引用传递):
package main import "fmt" func modifyValueWithPointer(x *int) { *x = 100 // 通过指针修改原始数据
} func main() { y := 50 fmt.Println("Before function call:", y) // 输出: Before function call: 50 modifyValueWithPointer(&y) // 传递y的地址 fmt.Println("After function call:", y) // 输出: After function call: 100 // y的值改变了,因为modifyValueWithPointer通过指针修改了y的原始值
}
注意事项:
当使用指针时,需要确保指针不为
nil
,否则在解引用时会导致运行时错误。无论是值传递,还是引用传递,传递给函数的都是变量的副本,不过,值传递是值的拷贝。引用传递是地址的拷贝,一般来说,地址拷贝避免了大量数据的复制更为高效,使用引用传递需要小心处理指针和内存访问
map、slice、chan、指针、interface默认以引用的方式传递
函数返回的注意事项:
1、命名返回参数可看做与形参类似的局部变量,最后由 return 隐式返回。
package mainfunc add(x, y int) (z int) {z = x + yreturn
}func main() {println(add(1, 2))
}
2、命名返回参数可被同名局部变量遮蔽,此时需要显式返回。
func add(x, y int) (z int) {{ // 不能在一个级别,引发 "z redeclared in this block" 错误。var z = x + y// return // Error: z is shadowed during returnreturn z // 必须显式返回。}
}
3、隐式返回建议在短函数中使用,长函数会影响代码可读性
4、命名返回参数允许 defer 延迟调用通过闭包读取和修改。
package mainfunc add(x, y int) (z int) {defer func() {z += 100}()z = x + y//先算3,再加100return
}func main() {println(add(1, 2)) //103
}
5、显式 return 返回前,会先修改命名返回参数。
package mainfunc add(x, y int) (z int) {defer func() {println(z) // 输出: 203}()z = x + yreturn z + 200 // 执行顺序: (z = z + 200) -> (call defer) -> (return)
}func main() {println(add(1, 2)) // 输出: 203
}
闭包与递归
闭包在go中的解释:
当内部函数B引用了外部函数A的变量c时,这个引用会形成一个闭包。闭包保证了即使外部函数A执行完毕,只要内部函数B还存在引用,变量c就不会被垃圾回收器回收。这样,后续再次调用内部函数B时,它仍然可以访问和操作变量c的值,这些值是基于首次创建闭包时的状态
示例代码:
func a() func() int {i := 0b := func() int {i++fmt.Println(i)return i}return b
}func main() {c := a()c()//1c()//2c()//3
}
go的递归,函数自己调用自己
1.子问题须与原始问题为同样的事,且更为简单。2.不能无限制地调用本身,须有个出口,化简为非递归状况处理。
通过递归实现斐波那契数列(Fibonacci)
package main import "fmt" // 递归实现斐波那契数列
func fibonacci(n int) int { if n <= 1 { return n } return fibonacci(n-1) + fibonacci(n-2)
} func main() { var n int fmt.Print("Enter a number: ") fmt.Scan(&n) for i := 0; i < n; i++ { fmt.Printf("%d ", fibonacci(i)) }
}
需要注意的是,这种递归实现方法虽然简单直观,但是效率非常低,因为它会重复计算很多已经计算过的值。对于较大的n
,这种方法会导致大量的重复计算和非常长的运行时间。在实际应用中,通常会使用动态规划或迭代的方法来提高效率。
package main import "fmt" // 迭代实现斐波那契数列
func fibonacci(n int) int { if n <= 1 { return n } a, b := 0, 1 for i := 2; i <= n; i++ { a, b = b, a+b } return b
} func main() { var n int fmt.Print("Enter a number: ") fmt.Scan(&n) for i := 0; i < n; i++ { fmt.Printf("%d ", fibonacci(i)) }
}
对于简单问题,迭代通常是更好的选择,性能更好,避免了栈溢出问题,对于复杂的问题,使用递归就是更自然容易理解,具体需要根据问题特性和需求来权衡
使用defer的陷阱,注意事项
defer的特点
关键字 defer 用于注册延迟调用。
这些调用直到 return 前才被执。因此,可以用来做资源清理。
多个defer语句,按先进后出的方式执行。
defer语句中的变量,在defer声明时就决定了。
defer的用途
资源清理:如关闭文件句柄、锁资源释放、数据库连接释放等。这些资源如果不及时清理,可能会导致内存泄漏或其他问题。通过使用defer,可以确保这些资源在函数退出时得到清理,无论函数是正常返回还是发生异常。
错误处理:在Go语言中,defer可以与recover结合使用,用于在发生panic时执行特定的错误处理逻辑。这在资源泄漏、死锁等场景下特别有用,因为发生panic时程序进程不一定会终止,可能被外层recover捕获,此时可以利用defer来确保必要的执行。
defer功能强大,对于资源管理非常方便,但是如果没用好,也会有陷阱。
陷阱:defer与闭包的相遇
action1:
package mainimport "fmt"func main() {var whatever [5]struct{}for i := range whatever {defer fmt.Println(i)}
}
结果:
43210
action2
package mainimport "fmt"func main() {var whatever [5]struct{}for i := range whatever {defer func() { fmt.Println(i) }()}
}
结果:
44444
是不是很神奇?
很多人以为 defer
会捕获循环变量在每次迭代时的“当前”值,但实际上 defer
捕获的是循环变量最终的引用(对于引用类型如切片、映射、通道或指针)或副本(对于值类型如整数、结构体等),这两段代码的变量i
都是循环变量的当前迭代的索引值
第一段代码,defer记住了按照先进后出,最后从大到小打印出索引值,fmt.Println(i)在每次循环迭代中立即执行,这里将i作为参数调用pritln函数,会记住每次迭代的值,并打印出当前的i
值。
但是第二段函数是闭包函数在每次循环迭代中都被创建,但由于i
是一个整数类型的值,闭包捕获的是i
的值的副本。然而,由于defer
语句将闭包的执行推迟到了main
函数返回之前(关键就是因为defer导致执行时间延后),所有闭包函数实际上都捕获了i
的最后一个值(在这个例子中是4
),因为闭包是在循环结束后才执行的。
注意:闭包函数捕获的是循环变量的引用(对于引用类型)或最终值(对于值类型),而不是每次循环变量迭代的值
为了修复闭包的问题,你需要将循环变量i
作为参数传递给闭包,就像这样:
package main import "fmt" func main() { var whatever [5]struct{} for i := range whatever { defer func(idx int) { fmt.Println(idx) }(i) }
}
在这个修复后的版本中,每次循环迭代时,我们都将当前的i
值作为参数传递给闭包函数。这样,闭包就会捕获这个特定时刻的i
值的副本,而不是变量i
本身。因此,每个闭包实例都会保存它自己的idx
值,并在defer
语句执行时打印出来。
总结:闭包在创建时捕获其外部作用域中变量的值或引用,但具体捕获的是哪个值取决于闭包实际执行的时间。如果你想确保闭包捕获的是循环中每次迭代的值,你应该将循环变量作为参数传递给闭包
注意事项:
1、defer放在return语句后面不会被执行
2、滥用defer会导致性能问题,尤其是在一个 "大循环" 里。
3、采用相同变量释放不同资源,使用defer可能会出现问题(解决方法:要么使用不同变量,要么在函数里面传入当时的参数)
package main import ( "fmt" "os"
) func do() error { var err error var f *os.File // 打开第一个文件 f, err = os.Open("book.txt") if err != nil { return err } defer func(file *os.File) { if err := file.Close(); err != nil { fmt.Printf("defer close book.txt err %v\n", err) } }(f) // 立即调用匿名函数,并将当前的 f 作为参数传递 // 使用 f 进行操作... // 打开第二个文件,并重用变量 f f, err = os.Open("another-book.txt") if err != nil { return err } defer func(file *os.File) { if err := file.Close(); err != nil { fmt.Printf("defer close another-book.txt err %v\n", err) } }(f) // 再次立即调用匿名函数,并将新的 f 作为参数传递 // 使用新的 f 进行操作... return nil
} func main() { if err := do(); err != nil { fmt.Println("Error in do:", err) }
}
异常处理
Go语言的异常处理很简单,一共两种方式
1、通过返回错误值:
package main import ( "errors" "fmt"
) // 一个可能返回错误的函数
func divide(a, b int) (int, error) { if b == 0 { // 返回一个预定义的错误或者自定义错误 return 0, errors.New("division by zero") } return a / b, nil
} func main() { result, err := divide(10, 0) // 尝试除以零 if err != nil { // 处理错误 fmt.Println("Error:", err) return } // 如果没有错误,则继续处理结果 fmt.Println("Result:", result)
}
通过panic抛出异常,recover捕获异常
package main import ( "fmt"
) // 一个可能触发panic的函数
func mightPanic() { // 假设这里有一些无法恢复的错误情况 panic("something went wrong, panic!")
} func main() { defer func() { if r := recover(); r != nil { // 恢复panic,处理异常情况 fmt.Println("Recovered from panic:", r) } }() mightPanic() // 调用可能会触发panic的函数 fmt.Println("This will be printed if panic is recovered")
}
注意事项:
导致核心流程出现不可修复性问题用panic,其他是error
利用recover处理panic指令,defer 必须放在 panic 之前定义,另外 recover 只有在 defer 调用的函数中才有效。否则当panic时,recover无法捕获到panic,无法防止panic扩散。
recover 处理异常后,逻辑并不会恢复到 panic 那个点去,函数跑到 defer 之后的那个点。
多个 defer 会形成 defer 栈,后定义的 defer 语句会被最先调用。
单元测试
开发要写测试,测试更要写测试!这里继续跟着文档理单元测试(功能)和基准测试(性能)
go test
go test命令是一个按照一定约定和组织的测试代码的驱动程序。在包目录内,所有以_test.go为后缀名的源代码文件都是go test测试的一部分,不会被go build编译到最终的可执行文件中。
在*_test.go
文件中有三种类型的函数,单元测试函数、基准测试函数和示例函数。
类型 | 格式 | 作用 |
---|---|---|
测试函数 | 函数名前缀为Test | 测试程序的一些逻辑行为是否正确 |
基准函数 | 函数名前缀为Benchmark | 测试函数的性能 |
示例函数 | 函数名前缀为Example | 为文档提供示例文档 |
go test命令会遍历所有的*_test.go
文件中符合上述命名规则的函数,然后生成一个临时的main包用于调用相应的测试函数,然后构建并运行、报告测试结果,最后清理测试中生成的临时文件。
Golang单元测试对文件名和方法名,参数都有很严格的要求。
1、文件名必须以xx_test.go命名 2、方法必须是Test[^a-z]开头 3、方法参数必须 t *testing.T 4、使用go test执行单元测试
终端运行ge test help 可以查看命令详解
格式形如:
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 : 将那些运行时间较长的测试用例运行时间缩短
示例:
目录结构:
calculator.go
package testimport (//"errors""sync"
)// DivideAndTruncateConcurrent 接受两个整数切片,返回一个新的整数切片,
// 包含第一个切片中的每个数字分别除以第二个切片中的每个数字后取整的结果。
func DivideAndTruncateConcurrent(dividends, divisors []int) []int {var wg sync.WaitGroupresults := make(chan int, len(dividends)) // 创建一个带缓冲的channel来存储结果// 为每个被除数和除数对启动一个goroutinefor i := range dividends {wg.Add(1)go func(dividend, divisor int) {defer wg.Done()results <- dividend / divisor}(dividends[i], divisors[i])}// 等待所有goroutine完成go func() {wg.Wait()close(results) // 关闭results channel}()// 收集结果到切片中var finalResults []intfor result := range results {finalResults = append(finalResults, result)}return finalResults
}
这里我们写了一个函数,可以将两个切片两面的数字相处最后得出一个结果
接下来我们编写成功的测试用例:
calculator_test.go
package testimport "testing"func TestDivideAndTruncateConcurrent(t *testing.T) {dividends := []int{10, 20, 6}divisors := []int{5, 2, 3}expect := []int{2, 10, 2}resulsts := DivideAndTruncateConcurrent(dividends, divisors) // 注意两个slice不能直接比较,可以单个拎出来比较,也可以通过映射for i, result := range resulsts {if result != expect[i] {t.Errorf("result[%d] = %d, want %d", i, result, expect[i])}}//通过映射来比较//if !reflect.DeepEqual(expect, resulsts) {// t.Errorf("expected is %v,but results is %v", expect, resulsts)//}
}
然后我们需要切换到test目录下,运行go test
PASS
ok GO20240301/src/test 0.467s
一个测试用例比较少,我们来点异常的例子,比如两个slice不同宽度的slice,或者除数为0(目前肯定有bug)
从代码复用的角度来看我们,可以直接将所有测试数据存储在一个打的slice里面,通过循环来执行,且看代码:
func TestDivideAndTruncateConcurrent(t *testing.T) {type testcase struct {dividend []intdivisor []intexpected []int}testcase01 := []testcase{{dividend: []int{20, 6, 12}, divisor: []int{5, 3, 6}, expected: []int{4, 2, 2}},{dividend: []int{20, 6, 12}, divisor: []int{5, 3, 6}, expected: []int{1, 2, 2}},{dividend: []int{20, 6, 12}, divisor: []int{5, 3, 6,7}, expected: []int{4, 2, 2}},{dividend: []int{20, 6, 12}, divisor: []int{5, 0, 6}, expected: []int{4, 0, 2}},}for _,tc := range testcase01{singleTest := DivideAndTruncateConcurrent(tc.dividend,tc.divisor)if !reflect.DeepEqual(tc.expect, singleTest) {t.Errorf("expected is %v,but results is %v", tc.expected, singleTest)}}}
最后执行结果go test -v 可以查看结果并看到失败信息
这里可以明显看到发生了panic,所以下面我们把程序优化一下修复一下
calculator.go
package testimport ("errors""sync"
)// DivideAndTruncateConcurrent 接受两个整数切片,返回一个新的整数切片,
// 包含第一个切片中的每个数字分别除以第二个切片中的每个数字后取整的结果。
// 如果两个切片的长度不同,或者遇到除数为0的情况,它将通过error返回。
func DivideAndTruncateConcurrent(dividends, divisors []int) ([]int, error) {if len(dividends) != len(divisors) {// 如果两个切片的长度不同,返回错误 return nil, errors.New("slice lengths must be equal")}var wg sync.WaitGroupresults := make(chan int, len(dividends)) // 创建一个带缓冲的channel来存储结果 errorsChannels := make(chan error, 1) // 创建一个带缓冲的channel来存储可能出现的错误 // 为每个被除数和除数对启动一个goroutine for i := range dividends {wg.Add(1)go func(dividend, divisor int) {defer wg.Done()if divisor == 0 {// 发送错误到errors channel errorsChannels <- errors.New("division by zero")return}// 发送结果到results channel results <- dividend / divisor}(dividends[i], divisors[i])}// 等待所有goroutine完成 go func() {wg.Wait()close(results) // 关闭results channel }()// 收集结果到切片中 var finalResults []intfor result := range results {finalResults = append(finalResults, result)}// 检查是否有错误发生 select {case err := <-errorsChannels:// 如果有错误,返回nil切片和错误 return nil, errdefault:// 如果没有错误,返回结果切片和nil错误 return finalResults, nil}
}
同时更新测试代码
package testimport ("errors""reflect""testing"
)func TestDivideAndTruncateConcurrent(t *testing.T) {type testcase struct {dividend []intdivisor []intexpected []interr error}testcasePlan := []testcase{{dividend: []int{20, 6, 12}, divisor: []int{5, 3, 6}, expected: []int{4, 2, 2}},{dividend: []int{20, 6, 12}, divisor: []int{5, 3, 6}, expected: []int{1, 2, 2}},{dividend: []int{20, 6, 12}, divisor: []int{5, 3, 6, 7}, expected: nil, err: errors.New("slice lengths must be equal")},{dividend: []int{20, 6, 12}, divisor: []int{5, 0, 6}, expected: nil, err: errors.New("division by zero")},}for _, tc := range testcasePlan {singleTest, err := DivideAndTruncateConcurrent(tc.dividend, tc.divisor)if !reflect.DeepEqual(tc.expected, singleTest) {t.Errorf("expected is %v,but results is %v", tc.expected, singleTest)}if err != nil { //如果err不为空,则判断错误信息是否相同if err.Error() != tc.err.Error() {t.Errorf("expected error for %v,but got %v", tc.err, err)}}}}
测试结果:
但是这样我们不知道具体哪些是失败例子,所以我们考虑使用子测试,go1.7+新增子测试,可以使用t.Run执行,以下为优化测试代码
package testimport ("errors""reflect""testing"
)func TestDivideAndTruncateConcurrent(t *testing.T) {type testcase struct {dividend []intdivisor []intexpected []interr error} //将测试用例修改为map类型,可以命名名字testcasePlan := map[string]testcase{"success": {dividend: []int{20, 6, 12}, divisor: []int{5, 3, 6}, expected: []int{4, 2, 2}},"wrong": {dividend: []int{20, 6, 12}, divisor: []int{5, 3, 6}, expected: []int{1, 2, 2}},"noEqualLength": {dividend: []int{20, 6, 12}, divisor: []int{5, 3, 6, 7}, expected: nil, err: errors.New("slice lengths must be equal")},"zeroDivisor": {dividend: []int{20, 6, 12}, divisor: []int{5, 0, 6}, expected: nil, err: errors.New("division by zero")},}for caseName, tc := range testcasePlan {t.Run(caseName, func(t *testing.T) {//建立子测试singleTest, err := DivideAndTruncateConcurrent(tc.dividend, tc.divisor)if !reflect.DeepEqual(tc.expected, singleTest) {t.Errorf("expected is %v,but results is %v", tc.expected, singleTest)}if err != nil {if err.Error() != tc.err.Error() {t.Errorf("expected error for %v,but got %v", tc.err, err)}}})}}
执行结果:
PS D:\GolandProjects\GO20240301\src\test> go test -v
=== RUN TestDivideAndTruncateConcurrent
=== RUN TestDivideAndTruncateConcurrent/noEqualLength
=== RUN TestDivideAndTruncateConcurrent/zeroDivisor
=== RUN TestDivideAndTruncateConcurrent/success
=== RUN TestDivideAndTruncateConcurrent/wrongcalculator_test.go:26: expected is [1 2 2],but results is [2 2 4]
--- FAIL: TestDivideAndTruncateConcurrent (0.00s)--- PASS: TestDivideAndTruncateConcurrent/noEqualLength (0.00s)--- PASS: TestDivideAndTruncateConcurrent/zeroDivisor (0.00s)--- PASS: TestDivideAndTruncateConcurrent/success (0.00s)--- FAIL: TestDivideAndTruncateConcurrent/wrong (0.00s)
FAIL
exit status 1
FAIL GO20240301/src/test 0.422s
这样我们就知道具体结果,一目了然
当然我们也可以执行指定测试用例:意识直接点击对应测试用例
还可以运行命令go test -v -run=DivideAndTruncateConcurrent/success
此外,为了避免遗漏我们代码没有测试到,可以运行加上-cover 可以查看代码覆盖率,可以加上-coverprofile=fileaddress将覆盖率相关信息输出到文件
基准测试
go语言的基准测试主要用来测试代码的性能
func BenchmarkFunction(b *testing.B) { for i := 0; i < b.N; i++ { // 这里放你想要测试的代码 Function() }
}
Function()
是你想要测试的函数。b.N
是一个由测试框架控制的循环次数,以确保每次测试运行足够长的时间以得到稳定的结果。
此外,testing.B提供了许多方法,这里只举出一些常用的,更多的可以参考官方文档
B.N
是一个由测试框架控制的循环次数。你不需要直接设置它,但可以在你的基准测试函数中使用它来控制你的代码要执行多少次。测试框架会自动调整 B.N
的值,以便在合理的时间内运行基准测试。
func BenchmarkFunction(b *testing.B) { for i := 0; i < b.N; i++ { // 这里放你想要测试的代码 Function() }
}
B.ReportAllocs()
B.ReportAllocs()
告诉测试框架在基准测试结束时报告内存分配统计信息。默认情况下,基准测试不报告内存分配,因为它们通常关注运行时间。
func BenchmarkFunction(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { // 这里放你想要测试的代码 Function() }
}
B.ResetTimer()
B.ResetTimer()
重置基准测试的计时器。在调用 ResetTimer
之后执行的代码才会被计时,这通常用于排除初始化代码的影响。
func BenchmarkFunction(b *testing.B) { // 不计时的初始化代码 someInitialization() b.ResetTimer() for i := 0; i < b.N; i++ { // 这里放你想要测试的代码 Function() }
}
B.StartTimer()
B.StartTimer()
启动基准测试的计时器。如果之前调用了 B.StopTimer()
,那么 StartTimer
会恢复计时。
func BenchmarkFunction(b *testing.B) { b.StopTimer() // 不计时的初始化代码 someInitialization() b.StartTimer() for i := 0; i < b.N; i++ { // 这里放你想要测试的代码 Function() }
}
B.StopTimer()
B.StopTimer()
停止基准测试的计时器。这通常用于排除初始化代码或准备工作的运行时间。
func BenchmarkFunction(b *testing.B) { b.StopTimer() // 不计时的初始化代码 someInitialization() b.StartTimer() for i := 0; i < b.N; i++ { // 这里放你想要测试的代码 Function() }
}
B.SetBytes(n int64)
B.SetBytes(n int64)
设置每次基准测试操作处理的字节数。这有助于在报告时解释基准测试的结果,特别是当基准测试处理不同大小的数据集时。
func BenchmarkFunction(b *testing.B) { data := make([]byte, 1024) // 假设每次操作处理1KB的数据 for i := 0; i < b.N; i++ { // 这里放你想要测试的代码 Function(data) } b.SetBytes(int64(len(data)))
}
B.RunParallel(body func(*testing.PB))
B.RunParallel
用于在多个 Goroutine 中并行执行基准测试,以更好地利用多核处理器。testing.PB
是一个进度报告器,用于同步 Goroutines 并报告进度。
func BenchmarkParallelFunction(b *testing.B) { b.RunParallel(func(pb *testing.PB) { for pb.Next() { // 这里放你想要测试的代码 Function() } })
}
在使用这些方法时,请确保理解它们如何影响基准测试的计时和报告,以确保你的基准测试结果是准确和有意义的。
下面我们来测试一下自己程序的性能(注意性能我们只关心时间不关心结果,所以不要使用上面的测试例子)
func BenchmarkDivideAndTruncateConcurrent(b *testing.B) {dividend := []int{12, 24, 56, 34}divisor := []int{6, 8, 7, 2}// 重置计时器排除初始化代码影响b.ResetTimer()// 测试结束报告内存分配信息b.ReportAllocs()// 使用b.N作为循环次数for i := 0; i < b.N; i++ {_, err := DivideAndTruncateConcurrent(dividend, divisor)if err != nil {b.Errorf("Unexpected error occurred: %v", err)}}
}
结果解释:
BenchmarkDivideAndTruncateConcurrent-20:这是基准测试的名称,
-20
表示这个基准测试使用了GOMAXPROCS的值为20来运行。GOMAXPROCS是Go运行时设置的最大并行执行的CPU核心数。这里,基准测试可能是在一个拥有至少20个CPU核心的机器上运行的,或者人为设置了GOMAXPROCS为20来模拟多核环境下的性能。352120:这是基准测试运行的迭代次数。基准测试工具会尝试找到一个合适的迭代次数,使得每次测试的总运行时间足够长(通常是1秒),以获取更稳定的性能测量。在这个例子中,基准测试函数被调用了352120次。
3580 ns/op:这是每次操作(operation)的平均耗时,单位是纳秒(nanoseconds)。在这个例子中,每次调用
BenchmarkDivideAndTruncateConcurrent
函数平均需要3580纳秒,也就是大约3.58微秒。这个数字是评估函数性能的关键指标之一。592 B/op:这是每次操作分配的平均字节数。在基准测试期间,Go运行时跟踪了内存分配情况,以评估函数对内存使用的效率。在这个例子中,每次操作平均分配了592字节的内存。
16 allocs/op:这是每次操作发生的平均内存分配次数。这个数字反映了函数在每次调用时产生垃圾收集压力的频率。在这个例子中,每次操作平均发生了16次内存分配。
性能优化:
我之前的程序主要有这些问题:
1、额外的errorsChannels
通道来传递错误。这增加了同步的复杂性。一个更简单的做法是在goroutine中直接处理错误,例如,使用atomic
包来设置一个共享的错误变量。如果发生错误,可以原子地设置这个变量,并在主goroutine中检查它。
2、使用channel来收集结果和错误确实提供了一种同步机制,但也可能成为性能瓶颈。如果可能,考虑使用切片和互斥锁(sync.Mutex
)来收集结果,并在所有goroutine完成后一次性返回结果
3、在循环中频繁地调用append
可能会导致大量的内存分配和垃圾收集压力。可以考虑预先分配一个足够大的切片来存储结果,或者使用sync.Map
来避免锁争用。
package test import ( "errors" "sync" "sync/atomic"
) func DivideAndTruncateConcurrent(dividends, divisors []int) ([]int, error) { if len(dividends) != len(divisors) { return nil, errors.New("slice lengths must be equal") } var wg sync.WaitGroup var errValue atomic.Value // 用于原子地存储错误 results := make([]int, len(dividends)) // 预先分配结果切片 for i := range dividends { wg.Add(1) go func(dividend, divisor int, index int) { defer wg.Done() if divisor == 0 { // 原子地设置错误 errValue.Store(errors.New("division by zero")) return } // 直接设置结果切片中的值 results[index] = dividend / divisor }(dividends[i], divisors[i], i) } wg.Wait() // 等待所有goroutine完成 // 检查是否有错误发生 if err := errValue.Load(); err != nil { // 如果有错误,返回nil切片和错误 return nil, err.(error) } // 如果没有错误,返回结果切片和nil错误 return results, nil
}
运行结果 :
性能优化前后对比结果:
操作数/时间 (ns/op): 从原来的
3580 ns/op
降低到了1954 ns/op
,这意味着每次操作的平均耗时减少了大约 45%。这是非常显著的性能提升。内存分配 (B/op): 从原来的
592 B/op
降低到了448 B/op
,这意味着每次操作的平均内存分配减少了约 24%。虽然减少的百分比没有操作时间那么显著,但仍然是积极的改进。分配次数 (allocs/op): 从原来的
16 allocs/op
降低到了11 allocs/op
,这意味着每次操作的平均内存分配次数减少了约 31%。这有助于减少垃圾收集的压力,提升程序的整体性能。
2 方法
Golang 方法总是绑定对象实例,并隐式将实例作为第一实参 (receiver)。
方法的定义
func (recevier type) methodName(参数列表)(返回值列表){}
方法的种类
package maintype Test struct{}// 无参数、无返回值
func (t Test) method0() {}// 单参数、无返回值
func (t Test) method1(i int) {}// 多参数、无返回值
func (t Test) method2(x, y int) {}// 无参数、单返回值
func (t Test) method3() (i int) {return
}// 多参数、多返回值
func (t Test) method4(x, y int) (z int, err error) {return
}// 无参数、无返回值
func (t *Test) method5() {}// 单参数、无返回值
func (t *Test) method6(i int) {}// 多参数、无返回值
func (t *Test) method7(x, y int) {}// 无参数、单返回值
func (t *Test) method8() (i int) {return
}// 多参数、多返回值
func (t *Test) method9(x, y int) (z int, err error) {return
}func main() {}
方法的接受者有两种一种是值类型,一种是指针类型,需要注意的是,如果方法接受类型是值类型,不管传入什么类型都会按照值类型操作,同样如果接受类型是指针,不管传入值还是指针都会按照指针来操作,如下示例代码:
package mainimport "fmt"type Data struct {x int
}func (self Data) ValueTest() { // func ValueTest(self Data);fmt.Printf("Value: %p\n", &self)
}func (self *Data) PointerTest() { // func PointerTest(self *Data);fmt.Printf("Pointer: %p\n", self)
}func main() {d := Data{}p := &dfmt.Printf("Data: %p\n", p)d.ValueTest() // ValueTest(d)d.PointerTest() // PointerTest(&d)p.ValueTest() // ValueTest(*p)p.PointerTest() // PointerTest(p)
}
结果:
Data: 0xc42007c008Value: 0xc42007c018Pointer: 0xc42007c008Value: 0xc42007c020Pointer: 0xc42007c008
普通函数与方法的区别
1.对于普通函数,接收者为值类型时,不能将指针类型的数据直接传递,反之亦然。
2.对于方法(如struct的方法),接收者为值类型时,可以直接用指针类型的变量调用方法,反过来同样也可以。
匿名字段
在Go语言中,结构体(struct)可以包含匿名字段(anonymous field),这是一种特殊的字段,它直接使用另一个类型作为字段,而不给该字段指定名称。当结构体包含匿名字段时,它可以访问该匿名字段类型的所有导出方法(即首字母大写的方法),就好像这些方法直接定义在包含它的结构体中一样。
下面是使用匿名字段和方法的一些关键点:
方法继承:匿名字段的类型的方法可以被包含它的结构体的实例直接调用,就像这些方法属于包含它的结构体一样。
字段冲突:如果结构体中两个或更多匿名字段有相同名称的导出字段或方法,那么在访问这些字段或方法时会产生冲突,需要显式地通过类型名来访问。
方法调用:当调用包含匿名字段的结构体的方法时,Go语言会首先在当前结构体中查找该方法,如果找不到,则会查找匿名字段类型中定义的方法。
方法重写:如果包含匿名字段的结构体定义了与匿名字段类型相同名称的方法,那么该方法会“覆盖”或“重写”匿名字段类型中的方法。
字段提升:匿名字段类型的字段(导出字段)也可以被包含它的结构体直接访问,就像这些字段是包含它的结构体的一部分一样。
下面是一个使用匿名字段和方法的简单示例:
package main import "fmt" // 定义一个接口
type Speaker interface { Speak() string
} // 定义一个结构体类型Dog,它实现了Speaker接口
type Dog struct{} func (d Dog) Speak() string { return "Woof!"
} // 定义一个包含匿名字段的结构体Person
type Person struct { Name string Dog // 匿名字段,类型为Dog
} func main() { p := Person{Name: "Alice", Dog: Dog{}} // 直接调用匿名字段Dog的Speak方法 fmt.Println(p.Speak()) // 输出: Woof! // 也可以通过类型名显式调用匿名字段的方法 fmt.Println(p.Dog.Speak()) // 输出: Woof!
}
在这个例子中,Person
结构体包含一个匿名字段 Dog
,Dog
类型实现了 Speaker
接口的 Speak
方法。因此,我们可以直接通过 Person
的实例 p
调用 Speak
方法,就像 Speak
是 Person
的一部分一样。
需要注意的是,如果 Person
结构体也定义了 Speak
方法,那么它将覆盖 Dog
类型的 Speak
方法,此时通过 p.Speak()
将调用 Person
的 Speak
方法。如果还需要调用 Dog
的 Speak
方法,则必须显式地使用 p.Dog.Speak()
。
方法集
在Go语言中,方法集(Method Set)是一个核心概念,它定义了某个类型的值或指针可以调用的方法集合
• 类型 T 方法集包含全部 receiver T 方法。• 类型 *T 方法集包含全部 receiver T + *T 方法。• 如类型 S 包含匿名字段 T,则 S 和 *S 方法集包含 T 方法。• 如类型 S 包含匿名字段 *T,则 S 和 *S 方法集包含 T + *T 方法。• 不管嵌入 T 或 *T,*S 方法集总是包含 T + *T 方法。
表达式
在Go语言中,方法表达式是一种特殊的语法结构,它允许我们将方法作为一个值传递,并在不同的上下文中调用该方法。方法表达式通过将方法名与接收者类型或接收者实例关联起来,形成一个可调用的函数值。
方法表达式的基本语法如下:
funcValue := receiverType.MethodName
或者,如果是通过接收者实例来获取方法表达式,则:
funcValue := receiverInstance.MethodName
这里的 receiverType
是方法的接收者类型,MethodName
是方法的名称,receiverInstance
是接收者类型的实例。funcValue
是一个函数值,它可以在后续被调用。
需要注意的是,当方法通过接收者类型获取时(即第一个语法),该方法表达式实际上是创建了一个没有绑定具体接收者实例的函数。当你调用这个函数时,需要手动传递接收者实例作为第一个参数。
当方法通过接收者实例获取时(即第二个语法),该方法表达式实际上是将该实例的方法绑定到了该实例上,因此后续调用时无需再传递接收者实例。
以下是一个简单的例子来说明方法表达式的用法:
package main import "fmt" type Person struct { Name string
} func (p Person) SayHello() { fmt.Printf("Hello, my name is %s\n", p.Name)
} func main() { alice := Person{Name: "Alice"} // 通过接收者实例获取方法表达式 sayHelloToAlice := alice.SayHello sayHelloToAlice() // 输出:Hello, my name is Alice // 通过接收者类型获取方法表达式 var sayHello func(Person) // 声明一个与SayHello方法签名相同的函数类型变量 sayHello = Person.SayHello sayHello(alice) // 输出:Hello, my name is Alice
}
在上面的例子中,sayHelloToAlice
是通过接收者实例 alice
获取的 SayHello
方法表达式。由于它已经绑定了 alice
实例,因此在调用时不需要再传递 alice
。
而 sayHello
是通过接收者类型 Person
获取的 SayHello
方法表达式。它本身没有绑定任何实例,因此在调用时需要手动传递一个 Person
类型的实例作为参数。
方法表达式在Go语言中是一种非常强大的特性,它允许我们更灵活地处理方法和函数,例如将它们作为参数传递给其他函数,或者将它们赋值给变量以便后续调用。
3 面向对象(接口)
在之前讲到函数的数据类型的时候已经提到过接口,GO语言中接口是一种抽象的类型
interface是一组method的集合,是duck-type programming的一种体现。接口做的事情就像是定义一个协议(规则),只要一台机器有洗衣服和甩干的功能,我就称它为洗衣机。不关心属性(数据),只关心行为(方法)。
接口是一个或多个方法签名的集合。任何类型的方法集中只要拥有该接口'对应的全部方法'签名。就表示它 "实现" 了该接口,无须在该类型上显式声明实现了哪个接口。这称为Structural Typing。所谓对应方法,是指有相同名称、参数列表 (不包括参数名) 以及返回值。当然,该类型还可以有其他方法。
接口的特点和注意事项: 接口只有方法声明,没有实现,没有数据字段。 接口可以匿名嵌入其他接口,或嵌入到结构中。 对象赋值给接口时,会发生拷贝,而接口内部存储的是指向这个复制品的指针,既无法 修改复制品的状态,也无法获取指针。 只有当接口存储的类型>和对象都为nil时,接口才等于nil。 接口调用不会做receiver的自动转换。 接口同样支持匿名字段方法。 接口也可实现类似OOP中的多态。 空接口可以作为任何类型数据的容器。 一个类型可实现多个接口。 接口命名习惯以 er 结尾。
接口定义
接口使用type
关键字定义,后面跟着接口的名字和一组方法签名。
type MyInterface interface { Method1() error Method2(param int) string
}
在上面的代码中,我们定义了一个名为MyInterface
的接口,它有两个方法:Method1
和Method2
。
实现接口
在Go语言中,一个类型隐式地实现了接口,只要它拥有接口中定义的所有方法。不需要显式声明类型实现了某个接口。
type MyStruct struct { // 结构体字段
} func (m *MyStruct) Method1() error { // 实现细节 return nil
} func (m *MyStruct) Method2(param int) string { // 实现细节 return "Result"
} // MyStruct类型现在实现了MyInterface接口,因为它拥有所有必需的方法
接口的常用操作
类型断言:用于检查接口值中是否包含特定类型的值,并获取该值。
var myInterface MyInterface = &MyStruct{} if myStruct, ok := myInterface.(*MyStruct); ok { // myStruct现在是*MyStruct类型,可以使用它的方法
} else { // 类型断言失败
}
空接口:
interface{}
是一个空接口,它没有定义任何方法,因此所有类型都实现了空接口。空接口经常用于存储任意类型的值。
var anyValue interface{} = "Hello, World!"
接口组合:一个接口可以嵌入其他接口,从而组合多个接口的方法。
type Reader interface { Read(p []byte) (n int, err error)
} type Writer interface { Write(p []byte) (n int, err error)
} type ReadWriter interface { Reader Writer
}
接口使用注意事项
接口的最小化原则:在设计接口时,应该尽量保持接口方法的数量最小化,只包含必要的操作。这有助于保持接口的简洁性和灵活性。
避免接口污染:不应该在接口中定义不相关的方法,这会导致接口变得庞大和难以维护。
接口作为参数和返回值:接口可以作为函数的参数和返回值类型,这增加了代码的灵活性和可重用性。
接口与实现解耦:通过接口,我们可以将代码的实现与使用解耦,使得代码更加模块化和易于测试。
避免空接口过度使用:空接口可以存储任何类型的值,但如果过度使用,会导致类型信息丢失,增加出错的可能性。应该尽量避免在不需要的情况下使用空接口。
示例代码
下面是一个简单的示例,展示了接口的定义、实现和使用:
package main import ( "fmt"
) // 定义接口
type Shape interface { Area() float64 Perimeter() float64
} // 矩形结构体和它的方法
type Rectangle struct { width, height float64
} func (r Rectangle) Area() float64 { return r.width * r.height
} func (r Rectangle) Perimeter() float64 { return 2 * (r.width + r.height)
} // 圆形结构体和它的方法
type Circle struct { radius float64
} func (c Circle) Area() float64 { return 3.14 * c.radius * c.radius
} func (c Circle) Perimeter() float64 { return 2 * 3.14 * c.radius
} // 计算并打印形状的面积和周长
func printShapeDetails(s Shape) { fmt.Printf("Area: %.2f\n", s.Area()) fmt.Printf("Perimeter: %.2f\n", s.Perimeter())
} func main() { rect := Rectangle{width: 10, height: 5} circle := Circle{radius: 7} printShapeDetails(rect) printShapeDetails(circle)
}
扫描二维码关注阿尘blog,一起交流学习