golang学习随便记6

函数

开始函数,这差不多开始进入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 而生。

上一篇:从零打造微前端框架:实战“汽车资讯平台”项目


下一篇:go语言异常处理 error panic recover defer