Go语言与数据库开发:01-05
接下来,我们讨论一下Go语言中的函数。
函数可以让我们将一个语句序列打包为一个单元,然后可以从程序中其它地方多次调用。函
数的机制可以让我们将一个大的工作分解为小的任务,这样的小任务可以让不同程序员在不
同时间、不同地方独立完成。一个函数同时对用户隐藏了其实现细节。由于这些因素,对于
任何编程语言来说,函数都是一个至关重要的部分。
函数声明包括函数名、形式参数列表、返回值列表(可省略)以及函数体。
func name(parameter-list) (result-list) {
body
}
形式参数列表描述了函数的参数名以及参数类型。这些参数作为局部变量,其值由参数调用
者提供。返回值列表描述了函数返回值的变量名以及类型。如果函数返回一个无名变量或者
没有返回值,返回值列表的括号是可以省略的。如果一个函数声明不包括返回值列表,那么
函数体执行完毕后,不会返回任何值。
如果一组形参或返回值有相同的类型,我们不必为每个形参都写出参数类
型。下面2个声明是等价的:
func f(i, j, k int, s, t string) { / ... / }
func f(i int, j int, k int, s string, t string) { / ... / }
。在函数调用时,Go语
言没有默认参数值,也没有任何方法可以通过参数名指定形参,因此形参和返回值的变量名
对于函数调用者而言没有意义。
在函数体中,函数的形参作为局部变量,被初始化为调用者提供的值。函数的形参和有名返
回值作为函数最外层的局部变量,被存储在相同的词法块中。
实参通过值的方式传递,因此函数的形参是实参的拷贝。对形参进行修改不会影响实参。但
是,如果实参包括引用类型,如指针,slice(切片)、map、function、channel等类型,实参可
能会由于函数的简介引用被修改。
多返回值
在Go中,一个函数可以返回多个值。我们已经在之前例子中看到,许多标准库中的函数返回2
个值,一个是期望得到的返回值,另一个是函数出错时的错误信息。
虽然良好的命名很重要,但你也不必为每一个返回值都取一个适当的名字。比如,按照惯
例,函数的最后一个bool类型的返回值表示函数是否运行成功,error类型的返回值代表函数
的错误信息,对于这些类似的惯例,我们不必思考合适的命名,它们都无需解释。
如果一个函数将所有的返回值都显示的变量名,那么该函数的return语句可以省略操作数。这
称之为bare return。
// CountWordsAndImages does an HTTP GET request for the HTML
// document url and returns the number of words and images in it.
func CountWordsAndImages(url string) (words, images int, err error) {
resp, err := http.Get(url)
if err != nil {
return
}
doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
err = fmt.Errorf("parsing HTML: %s", err)
return
}
words, images = countWordsAndImages(doc)
return
}
func countWordsAndImages(n html.Node) (words, images int) { / ... */ }
按照返回值列表的次序,返回所有的返回值,在上面的例子中,每一个return语句等价于:
return words, images, err
当一个函数有多处return语句以及许多返回值时,bare return 可以减少代码的重复,但是使得
代码难以被理解。不宜过度使用bare return。
错误
panic是来自被调函数的信号,表示发生了某个已知的bug。一个良好的程序
永远不应该发生panic异常。
对于大部分函数而言,永远无法确保能否成功运行。这是因为错误的原因超出了程序员的控
制。举个例子,任何进行I/O操作的函数都会面临出现错误的可能,只有没有经验的程序员才
会相信读写操作不会失败,即时是简单的读写。因此,当本该可信的操作出乎意料的失败
后,我们必须弄清楚导致失败的原因。
在Go的错误处理中,错误是软件包API和应用程序用户界面的一个重要组成部分,程序运行
失败仅被认为是几个预期的结果之一。
对于那些将运行失败看作是预期结果的函数,它们会返回一个额外的返回值,通常是最后一
个,来传递错误信息。如果导致失败的原因只有一个,额外的返回值可以是一个布尔值,通
常被命名为ok。比如,cache.Lookup失败的唯一原因是key不存在,那么代码可以按照下面的
方式组织:
value, ok := cache.Lookup(key)
if !ok {
// ...cache[key] does not exist…
}
通常,导致失败的原因不止一种,尤其是对I/O操作而言,用户需要了解更多的错误信息。因
此,额外的返回值不再是简单的布尔类型,而是error类型。
内置的error是接口类型。
现在我们只需要明白error类型可能是nil或者non-nil。nil意味着函数运行成功,non-nil表示失
败。对于non-nil的error类型,我们可以通过调用error的Error函数或者输出函数获得字符串类型
的错误信息。
fmt.Println(err)
fmt.Printf("%v", err)
通常,当函数返回non-nil的error时,其他的返回值是未定义的(undefined),这些未定义的返回
值应该被忽略。然而,有少部分函数在发生错误时,仍然会返回一些有用的返回值。比如,
当读取文件发生错误时,Read函数会返回可以读取的字节数以及错误信息。对于这种情况,
正确的处理方式应该是先处理这些不完整的数据,再处理错误。因此对函数的返回值要有清
晰的说明,以便于其他人使用。
在Go中,函数运行失败时会返回错误信息,这些错误信息被认为是一种预期的值而非异常
(exception),这使得Go有别于那些将函数运行失败看作是异常的语言。
虽然Go有各种异常机制,但这些机制仅被使用在处理那些未被预料到的错误,即bug,而不是那些在健壮程序
中应该被避免的程序错误。
错误处理策略
当一次函数调用返回错误时,调用者有应该选择何时的方式处理错误。根据情况的不同,有
很多处理方式,让我们来看看常用的五种方式。
首先,也是最常用的方式是传播错误。
这意味着函数中某个子程序的失败,会变成该函数的失败。
fmt.Errorf函数使用fmt.Sprintf格式化错误信息并返回。
当错误最终由main函数处理时,错误信息应提供清晰的从原因到后果
的因果链,就像美国宇航局事故调查时做的那样:
genesis: crashed: no parachute: G-switch failed: bad relay orientation
由于错误信息经常是以链式组合在一起的,所以错误信息中应避免大写和换行符。最终的错
误信息可能很长,我们可以通过类似grep的工具处理错误信息;
编写错误信息时,我们要确保错误信息对问题细节的描述是详尽的。
一般而言,被调函数f(x)会将调用信息和参数信息作为发生错误时的上下文放在错误信息中并
返回给调用者,调用者需要添加一些错误信息中不包含的信息,比如添加url到html.Parse返回
的错误中
处理错误的第二种策略。如果错误的发生是偶然性的,或由不可预知的问题导
致的。一个明智的选择是重新尝试失败的操作。在重试时,我们需要限制重试的时间间隔或
重试的次数,防止无限制的重试。
如果错误发生后,程序无法继续运行,我们就可以采用第三种策略:输出错误信息并结束程
序。需要注意的是,这种策略只应在main中执行。对库函数而言,应仅向上传播错误,除非
该错误意味着程序内部包含不一致性,即遇到了bug,才能在库函数中结束程序。
// (In function main.)
if err := WaitForServer(url); err != nil {
fmt.Fprintf(os.Stderr, "Site is down: %vn", err)
os.Exit(1)
}
调用log.Fatalf可以更简洁的代码达到与上文相同的效果。log中的所有函数,都默认会在错误
信息之前输出时间信息。
if err := WaitForServer(url); err != nil {
log.Fatalf("Site is down: %vn", err)
}
长时间运行的服务器常采用默认的时间格式,而交互式工具很少采用包含如此多信息的格
式。
我们可以设置log的前缀信息屏蔽时间信息,一般而言,前缀信息会被设置成命令名。
log.SetPrefix("wait: ")
log.SetFlags(0)
第四种策略:有时,我们只需要输出错误信息就足够了,不需要中断程序的运行。我们可以
通过log包提供函数
if err := Ping(); err != nil {
log.Printf("ping failed: %v; networking disabled",err)
}
或者标准错误流输出错误信息。
if err := Ping(); err != nil {
fmt.Fprintf(os.Stderr, "ping failed: %v; networking disabledn", err)
}
log包中的所有函数会为没有换行符的字符串增加换行符。
第五种,也是最后一种策略:我们可以直接忽略掉错误。
dir, err := ioutil.TempDir("", "scratch")
if err != nil {
return fmt.Errorf("failed to create temp dir: %v",err)
}
// ...use temp dir…
os.RemoveAll(dir) // ignore errors; $TMPDIR is cleaned periodically
尽管os.RemoveAll会失败,但上面的例子并没有做错误处理。这是因为操作系统会定期的清
理临时目录。正因如此,虽然程序没有处理错误,但程序的逻辑不会因此受到影响。我们应
该在每次函数调用后,都养成考虑错误处理的习惯,当你决定忽略某个错误时,你应该在清
晰的记录下你的意图。
在Go中,错误处理有一套独特的编码风格。检查某个子函数是否失败后,我们通常将处理失
败的逻辑代码放在处理成功的代码之前。如果某个错误会导致函数返回,那么成功时的逻辑
代码不应放在else语句块中,而应直接放在函数体中。Go中大部分函数的代码结构几乎相
同,首先是一系列的初始检查,防止错误发生,之后是函数的实际逻辑。
函数值
在Go中,函数被看作第一类值(first-class values):函数像其他值一样,拥有类型,可以被
赋值给其他变量,传递给函数,从函数返回。对函数值(function value)的调用类似函数调用。
函数类型的零值是nil。调用值为nil的函数值会引起panic错误:
var f func(int) int
f(3) // 此处f的值为nil, 会引起panic错误
函数值可以与nil比较:
var f func(int) int
if f != nil {
f(3)
}
但是函数值之间是不可比较的,也不能用函数值作为map的key。
函数值使得我们不仅仅可以通过数据来参数化函数,亦可通过行为。
strings.Map对字符串中的每个字符调用add1
函数,并将每个add1函数的返回值组成一个新的字符串返回给调用者。
func add1(r rune) rune { return r + 1 }
fmt.Println(strings.Map(add1, "HAL-9000")) // "IBM.:111"
fmt.Println(strings.Map(add1, "VMS")) // "WNT"
fmt.Println(strings.Map(add1, "Admix")) // "Benjy"
匿名函数
拥有函数名的函数只能在包级语法块中被声明,通过函数字面量(function literal),我们可
绕过这一限制,在任何表达式中表示一个函数值。函数字面量的语法和函数声明相似,区别
在于func关键字后没有函数名。函数值字面量是一种表达式,它的值被成为匿名函数(anonymous function)。
函数字面量允许我们在使用时函数时,再定义它。通过这种技巧,我们可以改写之前对
strings.Map的调用:
strings.Map(func(r rune) rune { return r + 1 }, "HAL-9000")
更为重要的是,通过这种方式定义的函数可以访问完整的词法环境(lexical environment),
这意味着在函数中定义的内部函数可以引用该函数的变量
// squares返回一个匿名函数。
// 该匿名函数每次被调用时都会返回下一个数的平方。
func squares() func() int {
var x int
return func() int {
x++
return x * x
}
}
func main() {
f := squares()
fmt.Println(f()) // "1"
fmt.Println(f()) // "4"
fmt.Println(f()) // "9"
fmt.Println(f()) // "16"
}
函数squares返回另一个类型为 func() int 的函数。对squares的一次调用会生成一个局部变量
x并返回一个匿名函数。每次调用时匿名函数时,该函数都会先使x的值加1,再返回x的平
方。第二次调用squares时,会生成第二个x变量,并返回一个新的匿名函数。新匿名函数操
作的是第二个x变量。
squares的例子证明,函数值不仅仅是一串代码,还记录了状态。在squares中定义的匿名内
部函数可以访问和更新squares中的局部变量,这意味着匿名函数和squares中,存在变量引
用。这就是函数值属于引用类型和函数值不可比较的原因。Go使用闭包(closures)技术实
现函数值,Go程序员也把函数值叫做闭包。
通过这个例子,我们看到变量的生命周期不由它的作用域决定:squares返回后,变量x仍然
隐式的存在于f中。
可变参数
参数数量可变的函数称为为可变参数函数。
在声明可变参数函数时,需要在参数列表的最后一个参数类型之前加上省略符号“...”,这表示
该函数会接收任意数量的该类型参数。
func sum(vals...int) int {
total := 0
for _, val := range vals {
total += val
}
return total
}
sum函数返回任意个int型参数的和。在函数体中,vals被看作是类型为[] int的切片。sum可以接
收任意数量的int型参数:
fmt.Println(sum()) // "0"
fmt.Println(sum(3)) // "3"
fmt.Println(sum(1, 2, 3, 4)) // "10"
在上面的代码中,调用者隐式的创建一个数组,并将原始参数复制到数组中,再把数组的一
个切片作为参数传给被调函数。
下面的代码功能与上个例子中最后一条语句相同。
values := []int{1, 2, 3, 4}
fmt.Println(sum(values...)) // "10"
虽然在可变参数函数内部,...int 型参数的行为看起来很像切片类型,但实际上,可变参数函
数和以切片作为参数的函数是不同的。
func f(...int) {}
func g([]int) {}
fmt.Printf("%Tn", f) // "func(...int)"
fmt.Printf("%Tn", g) // "func([]int)"
可变参数函数经常被用于格式化字符串。
Deferred函数
随着函数变得复杂,需要处理的错误也变多,维护清理逻辑变得越来越困
难。而Go语言独有的defer机制可以让事情变得简单。
你只需要在调用普通函数或方法前加上关键字defer,就完成了defer所需要的语法。当defer语
句被执行时,跟在defer后面的函数会被延迟执行。直到包含该defer语句的函数执行完毕时,
defer后的函数才会被执行,不论包含defer语句的函数是通过return正常结束,还是由于panic
导致的异常结束。你可以在一个函数中执行多条defer语句,它们的执行顺序与声明顺序相反。
defer语句经常被用于处理成对的操作,如打开、关闭、连接、断开连接、加锁、释放锁。通
过defer机制,不论函数逻辑多复杂,都能保证在任何执行路径下,资源被释放。释放资源的
defer应该直接跟在请求资源的语句后。
调试复杂程序时,defer机制也常被用于记录何时进入和退出函数。
。通过这种方式, 我们可以只通过一条语句控制函数的入口和所有的出口,甚至可以记录函数的
运行时间,如例子中的start。需要注意一点:不要忘记defer语句后的圆括号,否则本该在进入时
执行的操作会在退出时执行,而本该在退出时执行的,永远不会被执行。
defer语句中的函数会在return语句更新返回值变量后再执行,又因为在函数中定义
的匿名函数可以访问该函数包括返回值变量在内的所有变量,所以,对匿名函数采用defer机
制,可以使其观察函数的返回值。
在循环体中的defer语句需要特别注意,因为只有在函数执行完毕后,这些被延迟的函数才会执行。
Panic异常
Go的类型系统会在编译时捕获很多错误,但有些错误只能在运行时检查,如数组访问越界、
空指针引用等。这些运行时错误会引起painc异常。
一般而言,当panic异常发生时,程序会中断运行,并立即执行在该goroutine中被延迟的函数(defer 机制)。
随后,程序崩溃并输出日志信息。日志信息包括panic value和函数调用的堆栈跟踪信息。panic value通常是某
种错误信息。对于每个goroutine,日志信息中都会有与之相对的,发生panic时的函数调用堆栈跟踪信息。通常,
我们不需要再次运行程序去定位问题,日志信息已经提供了足够的诊断依据。因此,在我们填写问题报告时,一
般会将panic异常和日志信息一并记录。
不是所有的panic异常都来自运行时,直接调用内置的panic函数也会引发panic异常;panic函
数接受任何值作为参数。当某些不应该发生的场景发生时,我们就应该调用panic。比如,当
程序到达了某条逻辑上不可能到达的路径。
断言函数必须满足的前置条件是明智的做法,但这很容易被滥用。除非你能提供更多的错误
信息,或者能更快速的发现错误,否则不需要使用断言,编译器在运行时会帮你检查代码。
func Reset(x *Buffer) {
if x == nil {
panic("x is nil") // unnecessary!
}
x.elements = nil
}
虽然Go的panic机制类似于其他语言的异常,但panic的适用场景有一些不同。由于panic会引
起程序的崩溃,因此panic一般用于严重错误,如程序内部的逻辑不一致。勤奋的程序员认为
任何崩溃都表明代码中存在漏洞,所以对于大部分漏洞,我们应该使用Go提供的错误机制,
而不是panic,尽量避免程序的崩溃。在健壮的程序中,任何可以预料到的错误,如不正确的
输入、错误的配置或是失败的I/O操作都应该被优雅的处理,最好的处理方式,就是使用Go的
错误机制。
将panic机制类比其他语言异常机制的读者可能会惊讶,runtime.Stack为何能输出已经被释放
函数的信息?在Go的panic机制中,延迟函数的调用在释放堆栈信息之前。
Recover捕获异常
通常来说,不应该对panic异常做任何处理,但有时,也许我们可以从异常中恢复,至少我们
可以在程序崩溃前,做一些操作。举个例子,当web服务器遇到不可预料的严重问题时,在崩
溃前应该将所有的连接关闭;如果不做任何处理,会使得客户端一直处于等待状态。如果web
服务器还在开发阶段,服务器甚至可以将异常信息反馈到客户端,帮助调试。
如果在deferred函数中调用了内置函数recover,并且定义该defer语句的函数发生了panic异
常,recover会使程序从panic中恢复,并返回panic value。导致panic异常的函数不会继续运
行,但能正常返回。在未发生panic时调用recover,recover会返回nil。
当某个异常出现时,我们不会选择让解析器崩溃,而是会将panic异常当作普通的解析错误,并
附加额外信息提醒用户报告此错误。
func Parse(input string) (s *Syntax, err error) {
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("internal error: %v", p)
}
}()
// ...parser...
}
deferred函数帮助Parse从panic中恢复。在deferred函数内部,panic value被附加到错误信息
中;并用err变量接收错误信息,返回给调用者。我们也可以通过调用runtime.Stack往错误信
息中添加完整的堆栈调用信息。
安全的做法是有选择性的recover。换句话说,只恢复应该被恢复的panic异
常,此外,这些异常所占的比例应该尽可能的低。为了标识某个panic是否应该被恢复,我们
可以将panic value设置成特殊类型。在recover时对panic value进行检查,如果发现panic
value是特殊类型,就将这个panic作为errror处理,如果不是,则按照正常的panic进行处理。
有些情况下,我们无法恢复。某些致命错误会导致Go在运行时终止程序,如内存不足。