函数
开始函数,这差不多开始进入golang的深水区了
函数声明
实际上开始使用golang写第一个程序就开始使用函数,即main函数,只是简单的main函数是没有参数列表和返回值列表的。和C语言不同,golang的返回值可以像形参一样命名(而不仅仅是返回值的类型),此时,每个命名的返回值会被声明为一个局部变量(初始0值),另外,golang是可以返回多个值的。
func f(i, j, k int, s, t string) {
// ...
}
func sub(x, y int) (z int) {
z = x - y
return
}
递归
书上的例子有点复杂,解析HTML作为理解这个概念有点“厚重”,这里放一个斐波那契例子吧
func fib(n int64) int64 {
if n == 0 || n == 1 {
return 1
} else {
return fib(n-1) + fib(n-2)
}
}
函数递归调用的相关问题是调用栈问题,好在golang的栈是可变长的,可达1GB左右的上限,在正常的应用中不会溢出。
多返回值
函数返回多个值在golang中是常态,例如一个正常的返回结果和一个err。多返回值(返回多个值)和命名返回值(裸返回)可以相互替代,一方面裸返回可以减少重复代码,另一方面,裸返回也可能造成程序返回值的情况不清晰,所以应保守使用裸返回(实际就是很少看到裸返回)
错误
golang这里说的错误,其实是其他语言里面的异常,也就是程序员再厉害,也不能掌控的引发程序错误的情况,对此类情况,程序员在编程阶段应该有“预案”,其他语言是异常处理,golang就是处理错误。golang的错误处理,也是被王垠大神吐槽的一个点,或许类似Windows API那样的用一个代码来指示错误显得不那么现代。(golang有异常,不过它大致相当于Unhandled Exception)
golang中,用来指示错误的这个结果通常是最后一个返回值,并且错误只有一种情况时,该结果一般是bool类型(通常取名ok),对于复杂的错误,该结果是error这个内置的接口类型。
golang使用非常“传统”的错误处理方式,它的优点和缺点还是比较明显的,优点是出了错定位比较直接,错误信息简单明确,不需要去跟踪栈找错误,缺点是程序员有责任仔细地处理所有可预见的异常。
resp, err := http.Get(url)
if err != nil {
return nil, err
}
上面的代码,在处理调用 http.Get()的错误时,直接将该错误作为返回值的一部分返回
doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("parsing %s as HTML: %v", url, err)
}
上面的代码中,在处理调用 html.Parse()的错误时,用 fmt.Errorf 重新格式化一个错误消息再返回
fmt.Errorf 其实是使用了 fmt.Sprintf 格式化错误消息再返回错误值。重新格式化错误消息时,只添加本函数自己(函数和它的参数)行为有关的描述。按书里的说法,错误消息首字母不大写,也不换行,从而最终消息成为一个长串来理解错误发生路径。
package main
import (
"fmt"
"log"
"net/http"
"time"
)
func main() {
url := "http://www.baidu.com"
if err := WaitForServer(url); err != nil {
log.Fatalf("Site is down: %v\n", err)
}
}
func WaitForServer(url string) error {
const timeout = 1 * time.Minute
deadline := time.Now().Add(timeout)
for tries := 0; time.Now().Before(deadline); tries++ {
_, err := http.Head(url)
if err == nil {
return nil
}
log.Printf("server not responding (%s); retrying ...", err)
time.Sleep(time.Second << uint(tries)) // 按指数退避
}
return fmt.Errorf("server %s failed to respond after %s", url, timeout)
}
上面的代码,对于错误处理采用了不同的策略,即在一定时间内重复尝试,对网络程序通常如此。同时,里面采用了按指数延时的退避策略,对于错误信息,使用了 log 包的一些函数。对于服务器程序,可能不一定有标准输出,但应该有写日志以便事后检查错误原因。
golang的日志功能显然还是有点复杂的,暂时略过。
在各种错误中,有一种错误其实不是错误,即 EOF,对调用者来说,读取到文件尾的确是“错误”(不能读满一个缓冲区)。io包包含用来指示 EOF 的错误 io.EOF
函数变量
函数变量表明 golang 是某种程度上的函数式语言。可以把已经定义好的函数的名字看作“函数常量”,可以把函数常量赋值给函数变量。函数变量有点类似函数指针,用法上又和JavaScript的function相似(也可以认为和php函数相似,都是函数闭包),函数类型的零值是nil (差不多就是C++ nullptr 的意思)。
不能调用一个空的函数变量。
var f func(int) int
f(3)
上述代码将引起宕机。
函数变量可以判空(和nil比较),但函数变量之间不能用 == 比较,类型一致也不行。
匿名函数
golang函数是函数闭包,所以可以是匿名的闭包,并且它也是词法作用域的,里层函数可以使用外层函数的变量。(参考文章很多,这里随便贴一个词法静态作用域和动态作用域的区别 词法作用域和动态作用域 - 简书 (jianshu.com))
package main
import (
"fmt"
)
func main() {
f := squares()
fmt.Println(f())
fmt.Println(f())
fmt.Println(f())
fmt.Println(f())
}
func squares() func() int { // 函数将返回 func() int 类型的函数
var x int // 可以理解为 x 是 squares() 闭包环境内共享的变量
return func() int {
x++
return x * x
}
}
上述代码中,squares() 函数内部的匿名函数,可以更新squares()的局部变量x,并且main()函数中,在函数 squares() 返回后,变量 x 还是活着的(隐藏在函数变量 f 中),变量 x 的生命周期并不是由它的作用域决定的。
书上的拓扑排序例子略显复杂,因为这里先是为了理解语言概念,暂时略过。
和javascript一样,golang使用静态作用域,有优点,但在循环中使用迭代变量时,容易产生违反直觉的现象
package main
import (
"fmt"
"os"
)
func main() {
var rmdirs []func()
for _, dir := range tempDirs() {
os.MkdirAll(dir, 0755)
rmdirs = append(rmdirs, func() {
MyRemoveAll(dir) // 这里会有问题
})
}
// ....
for _, rmdir := range rmdirs {
rmdir()
}
}
func tempDirs() []string {
return []string{"/tmp/dir1", "/tmp/dir2"}
}
func MyRemoveAll(dir string) {
fmt.Println(dir)
os.RemoveAll(dir)
}
程序本意是依次创建tempDirs()返回的两个路径的目录,最后又用循环依次删除这两个目录,删除时输出删掉的目录,但实际输出为
/tmp/dir2
/tmp/dir2
而且仔细去找一下,可以发现 /tmp/dir1 仍然存在,只有 /tmp/dir2 被删除了。事实上,如果有100个目录,那么删除的是最后一个目录。
原因和Javascript中是一样的(不过JS在有let关键字之前,没有块作用域,而golang是有块作用域的)。闭包 - JavaScript | MDN (mozilla.org)
for _, dir := range tempDirs() {
os.MkdirAll(dir, 0755) // 这里会有问题
rmdirs = append(rmdirs, func() {
MyRemoveAll(dir)
})
}
这个for循环构成一个块作用域,func()参数(实际为空)和MyRemoveAll()构成内层,for构成外层,因为在内层找不到dir,所以dir来自外层,又外层会共享dir这个标识符(词法作用域认名字不认执行),dir值在后面的rmdir()执行引发MyRemoveAll()执行之前已经确定值,所以它是循环的最后一个有效值(道理和JS闭包一样,看MDN那个链接即可)。
golang中没有JS中let那样的关键字,但因为它本身有块作用域,所以,只需要引入一个内部变量来捕获迭代变量即可。
// ..................
dir := dir
rmdirs = append(rmdirs, func() {
MyRemoveAll(dir)
})
// ..................
同样的,写成下面形式的for循环也是不对的(不是结果不对,而是不能通过编译,因为那个共享的i在真的被执行时已经是i了,下标越界)
// ...................
dirs := tempDirs()
for i := 0; i < len(dirs); i++ {
os.MkdirAll(dirs[i], 0755)
rmdirs = append(rmdirs, func() {
MyRemoveAll(dirs[i])
})
}
// ...................
在 go 语句 和 defer 语句中,也存在这类迭代变量捕获的问题。简单来说,真正的执行逻辑延后于定义时,对于词法作用域的循环变量,就会存在这个问题。
参数变长的函数
和C语言实现变长参数比起来,golang实现参数变长参数的函数要容易得多,因为它有slice和打散。
func main() {
fmt.Println(sum(1, 2, 3, 4, 5))
}
func sum(vals ...int) int {
total := 0
for _, val := range vals {
total += val
}
return total
}
在参数中,vals 的类型是 ...int,表示它是一堆打散的整数(1,2,3,4,5),而在函数内部,它自动包装成了slice([]int{1, 2, 3, 4, 5}),...int 类型 和 []int 并不相同,所以,这里应该认为是golang语言自动转换的过程。
延迟函数调用
这又是一个被王垠大神吐槽的点,理解起来也比较费劲,也不容易使用。
func title(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
ct := resp.Header.Get("Content-Type")
if ct != "text/html" && !strings.HasPrefix(ct, "text/html;") {
resp.Body.Close() // 在返回错误前关闭
return fmt.Errorf("%s has type %s, not text/html", url, ct)
}
doc, err := html.Parse(resp.Body)
resp.Body.Close() // 在返回错误前关闭
if err != nil {
return fmt.Errorf("parsing %s as HTML: %v", url, err)
}
doc, err := html.Parse(resp.Body)
resp.Body.Close() // 在返回错误前关闭
if err != nil {
return fmt.Errorf("parsing %s as HTML: %v", url, err)
}
visitNode := func (n *html.Node) {
if n.Type == html.ElementNode && n.Data == "title" && n.FirstChild != nil {
fmt.Println(n.FirstChild.Data)
}
}
forEachNode(doc, visitNode, nil)
return nil
}
上面的代码中,为了确保在发生错误发生而返回之前,网络总是关闭的,正常执行网络也是关闭的,就需要多次写重复的关闭代码。golang用defer机制了处理这种情况。
golang不限定defer语句的数量。被defer的语句,会在函数将要返回时才被执行,所以它是延迟执行(不过表达式和值的计算在调用时就进行了)。多个defer语句的执行顺序和调用顺序正好相反,所以,defer语句常见的场景是资源的关闭或释放。使用defer语句,资源的打开关闭就会是
打开资源 A,失败处理
(成功时)defer 关闭资源A
使用资源 A
打开资源 B,失败处理
(成功时)defer 关闭资源B
使用资源 B
……
(函数返回前,无论哪一条路径,隐含执行 ……关闭资源B,关闭资源A)
defer语句还可以用于调试复杂的函数,即在函数的“入口”和“出口”设置调试行为(例如打印语句),下面的例子是计算函数执行时间
func main() {
bigSlowOperation()
}
func bigSlowOperation() {
defer trace("bigSlowOperation")()
// .... some jobs
time.Sleep(10 * time.Second) // 模拟慢操作
}
func trace(msg string) func() {
start := time.Now()
log.Printf("enter %s", msg)
return func() {
log.Printf("exit %s (%s)", msg, time.Since(start))
}
}
输出
2021/09/20 18:43:33 enter bigSlowOperation
2021/09/20 18:43:43 exit bigSlowOperation (10.0522629s)
上面的例子中, trace("bigSlowOperation")() 是被defer执行的,但trace函数内部的表达式是在调用时就计算的,所以,在入口处就执行了
start := time.Now()
log.Printf("enter %s", msg)
trace函数返回的是一个匿名函数,所以 trace("bigSlowOperation")() 后面是带圆括号的,这个匿名函数的真正执行,就要到 bigSlowOperation() 返回前了,所以就能实现函数执行时间的计算。
下面的代码可以输出函数每次调用时参数和结果,而且在triple中,defer的使用改变了double(x)返回给triple的值
func double(x int) (result int) {
defer func() { fmt.Printf("double(%d) = %d\n", x, result) }()
return x + x
}
func triple(x int) (result int) {
defer func() { result += x }()
return double(x)
}
在循环中使用 defer 需要小心,例如
for _, filename := range filenames {
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close()
// use f
}
因为操作系统能打开的文件是有限的(文件描述符会用尽),defer执行文件关闭使得上述代码可能会有问题。解决办法是把对单个文件的打开、关闭和使用打包到一个函数中,即
for _, filename := range filenames {
if err := doFile(filename); err != nil {
return err
}
}
func doFile(filefilename string) error {
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close()
// use f
}
对于文件写入,使用 defer 可能会有问题,即打开了文件,然后defer 关闭,再写文件,之后很多代码,等这些结束defer的操作才执行,即文件关闭,而很多文件系统,操作系统并不会立刻将数据写入,往往是推迟写入(可能推迟到文件关闭的时候),这样,如果写入是失败的,就出现写错误过迟返回,可能造成数据丢失。
panic (宕机,崩溃)
panic() 接受任意参数,对于继续执行后续语句会更糟糕的情况,应该选择 panic。但 panic 发生时,所有的延迟函数会执行(倒序,从栈顶一直到main()函数),所以,不能把panic理解成 exit (golang有os.Exit)
恢复
有一些情况,我们希望 panic 之后做得更多,例如更好的清理,甚至合适的方式恢复。如果内置的 recover 函数在defer函数内部调用,而且这个包含defer语句的函数发生panic,则recover函数会终止当前的 panic 状态并且返回 panic 的值,发生 panic 的函数不会继续从 panic 的地方继续运行,它会正常返回。如果 recover函数在其它地方(例如没有 panic)使用,那么它没有任何效果,直接返回nil,所以 recover 函数转为 panic 而生。