千呼万唤,函数终于登场啦!登登噔噔瞪!
相信如果你有过其他语言比如C、C++或者PHP的开发经验,相信对函数应该不会陌生。在面向过程的开发中,我们大多数时间,其实就是在写函数。
函数的机制可以让我们将一个大的工作分解为小的任务(分而治之的编程思想),这样的小任务可以让不同程序员在不同时间、不同地方独立完成。每一个函数对调用者隐藏了实现细节,只需要互相约定好“入参”和“返回结果”,那就妥妥的了。
既然函数在编程里面有这么重要的作用,在Go当中,当然也不例外了。但是,除了这些基本的特性之外,还有哪些特别需要学习和注意的,各位看官,咱们一一揭晓答案!
一、身份地位不一般:一等公民(first-class)
听起来就挺牛逼的有没有!在 Go 语言中,函数可是一等的(first-class)公民,函数类型也是一等的数据类型。
关于什么是“一等公民”,也叫“第一类对象”,这里不再赘述,之前有过总结,详细可以参考: 什么是第一类对象?。简单的说,这意味着函数不但可以用于封装代码、分割功能、解耦逻辑,还可以化身为普通的值,在其他函数间传递、赋予变量、做类型判断和转换等等。
总之,“函数是一等的公民”是函数式编程(functional programming)的重要特征。Go 语言在语言层面支持了函数式编程。
二、初识函数:函数声明
函数声明包括函数名、形式参数列表、返回值列表(可省略)以及函数体。
- 函数名:函数的名字,是我们调用函数时的一个标识符,不算函数签名
- 形式参数列表:函数入参列表,不必每个参数都写类型,见下面代码示例。需要特别说明的是,因为实参通过值的方式传递,所以形参是实参的拷贝,对形参进行修改不会影响实参。但如果实参包含引用类型,则实参可能会由于函数的间接引用被修改。
- 返回值列表:可省略,如果没有则没有这部分,且函数体内没有return语句;且结果声明里可以没有名称只有类型
- 函数签名:也叫函数的类型,参数列表和结果列表的统称,不包含参数名称
- 函数体:也就是你的函数功能实现代码块,形式参数作为其局部变量使用。函数体可以没有,这表示该函数不是以Go实现的。只是定义了函数签名。
两个函数的参数列表和结果列表中的元素顺序及其类型是一致的(即函数签名一致),我们就可以说它们是一样的函数,或者说是实现了同一个函数类型的函数。
//函数声明
func name(parameter-list) (result-list) {
body
}
//函数类型声明
type Printer func(contents string) (n int, err error)
//不必为每个参数写参数类型,下面两种等价
func f(i, j, k int, s, t string) { /* ... */ }
func f(i int, j int, k int, s string, t string) { /* ... */ }
//强调第二个参数未使用
func first(x int, _ int) int { return x }
//没有函数体的函数声明
//implemented in assembly language
func Sin(x float64) float
关于Go函数参数传递这里,我们还需要唠叨几句。因为Go参数传递只存在“值传递”,所以传进来的其实都是参数的副本。但是对于基本类型和引用类型之间会有一个差别:
- 基本类型:比如数字、字符串、布尔等,副本是一个“深拷贝”,也就是说复制的是这个值在内存中的底层数据
- 引用类型:比如切片、字典、通道等,副本是一个“浅拷贝”,只会拷贝它们本身(即它指向底层数组中某一个元素的指针,以及它的长度值和容量值)而已,并不会拷贝它们引用的底层数据(底层数组)
三、一等公民的体现:函数值以及高阶函数
在Go中,函数被看作第一类值(first-class values):函数像其他值一样,拥有类型,可以被赋值给其他变量,传递给函数,从函数返回。对函数值(function value)的调用类似函数调用。
func square(n int) int { return n * n }
func negative(n int) int { return -n }
func product(m, n int) int { return m * n }
f := square
fmt.Println(f(3)) // "9"
f = negative
fmt.Println(f(3)) // "-3"
fmt.Printf("%T\n", f) // "func(int) int"
f = product // compile error: can't assign func(int, int) int to func(int) int
函数类型的零值是nil。可以和nil比较,调用值为nil的函数值会引起panic。
var f func(int) int
f(3) // 此处f的值为nil, 会引起panic错误
//正确用法
if f != nil {
f(3)
}
正因为有函数值的存在,我们才能编写“高阶函数”。那什么是高阶函数呢?高阶函数可以满足下面两个条件中的任何之一或者两者同时满足:
- 接受其他的函数作为参数传入
- 把其他的函数作为结果返回
写一个高阶函数的例子:calculate函数实现了函数作为入参传递,genCalculator函数实现了返回一个函数值。
type operate func(x, y int) int
func calculate(x int, y int, op operate) (int, error) {
if op == nil {
return 0, errors.New("invalid operation")
}
return op(x, y), nil
}
func genCalculator(op operate) calculateFunc {
return func(x int, y int) (int, error) {
if op == nil {
return 0, errors.New("invalid operation")
}
return op(x, y), nil
}
}
op := func(x, y int) int {
return x + y
}
x, y = 56, 78
add := genCalculator(op)
result, err = add(x, y)
fmt.Printf("The result: %d (error: %v)\n", result, err)
这里插入一个题外的知识点,这里的 if op == nil 其实有一个官方名字,叫做卫述语句:是指被用来检查关键的先决条件的合法性,并在检查未通过的情况下立即终止当前代码块执行的语句。在 Go 语言中,if 语句常被作为卫述语句。
四、“变化多端”的函数:可变参数和多返回值
参数数量可变的函数称为可变参数函数。相信用的最多的fmt.Printf和类似函数大家都不陌生,它就是可变参数函数。
//参数列表的最后一个参数类型之前加上省略符号“...”
//这是vals被看做是[]int切片
func sum(vals ...int) int {
total := 0
for _, val := range vals {
total += val
}
return total
}
//下面的调用会隐式创建一个数组,然后把原始参数复制进去
//最后把数组的一个切片传递给函数sum
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"
//可变参数函数和已切片为入参的函数是不同的类型
func f(...int) {}
func g([]int) {}
fmt.Printf("%T\n", f) // "func(...int)"
fmt.Printf("%T\n", g) // "func([]int)"
//下面的最后一个参数是interfaces{},说明可以接受任意类型
func errorf(linenum int, format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "Line %d: ", linenum)
fmt.Fprintf(os.Stderr, format, args...)
fmt.Fprintln(os.Stderr)
}
多返回值很好理解,就是一个函数有多于1个的返回值结果。在上面的例子中,我们也都见到过。对于多返回值,我们就主要说明以下几点需要注意和了解的吧:
- 如果一个函数所有的返回值都有显式的变量名,那么该函数的return语句可以省略操作数。这称之为bare return。虽然可以减少代码重复,但是会造成代码难以被理解和阅读,所以不建议过度使用。
- 多值返回的函数需要把返回结果分配给对应数量的变量,如果想要忽略哪些变量,可以把它分配给blank identifier(_表示)
// errors ignored
links, _ := findLinks(url)
//bare return
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
}
五、无名氏:匿名函数
通过函数字面量(function literal)我们可以实现在任何表达式中表示一个函数值。函数值字面量是一种表达式,它的值被称为匿名函数(anonymous function),即func关键字后面没有函数名。重点是,匿名函数可以访问完整的词法环境(lexical environment),在函数中定义的内部函数可以引用该函数的变量。这个重要的特性,可以帮助我们实现“闭包函数”。
strings.Map(func(r rune) rune { return r + 1 }, "HAL-9000")
// 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"
}
六、函数的健壮性:错误处理
对于大部分函数而言,我们永远无法确定它能否成功运行。因为错误永远会超出程序员们的预期。比如任何有I/O操作的地方,都有可能会发生读写操作失败的情况。这个时候,我们需要弄清楚失败的原因。
函数对错误的处理情况分为两种,一种是只会出现一种已知的错误,第二个参数返回的是布尔类型,比如 cache.Lookup,失败的唯一原因是key不存在:
value, ok := cache.Lookup(key)
if !ok {
// ...cache[key] does not exist…
}
}
第二种就是失败原因不止一种,这个时候第二个参数就要返回error类型了:内置的error类型是接口类型,有两种值nil和non-nil,nil意味着执行成功,后者意味失败。失败时,我们可以通过调用error的Error函数或者输出函数获得字符串类型的错误信息。
在Go中,对错误的处理一般有五种策略:
- 传播错误:直接将错误返回给调用者,或者拼接上相应的必要信息,错误信息中应避免大写和换行符。一般而言,被调用函数f(x)会将调用信息和参数信息作为发生错误时的上下文放在错误信息中并返回给调用者
- 失败重试:如果错误的发生是偶然性的,或由不可预知的问题导致的。一个明智的选择是重新尝试失败的操作,但是要控制重试次数
- 输出错误信息并结束程序:这种策略只应在main中执行
- 只输出和记录错误信息,不终止程序继续执行:一般借助log包
- 忽略错误:不处理,程序的逻辑不会因此受到影响
七、恼人的运行时恐慌Panic
或多或少大家也都对Panic不陌生了,因为从以前学习的内容中,我们也经常会遇见和提到panic。那这里,咱们就专门针对它详细了解一下。
Go是编译型语言,对于一些错误是可以在代码编译阶段被检查出来。但是有些错误只能在运行时检查,比如数组访问越界、空指针引用、字典并发读写等,这些运行时引发的错误,就是panic。一般而言,一但出现panic,程序会中断运行,并立即执行在该 goroutine 中被延迟执行的函数(difer机制,下文即将讲到),延迟函数的调用在释放堆栈信息之前。一级一级向上“交接控制权”,直到最外层函数(go函数或者是主goroutine的main函数),然后最后被Go语言运行时系统回收。随后,程序崩溃终止运行并输出日志信息(包括panic value和函数调用的堆栈跟踪信息)。
当然,除了这种真正的panic之外,我们还可以通过内置的panic函数主动引发panic异常。一般情况,我们对于错误处理,应该按照上面的五种策略(优雅处理)来处理,只有实在处理不了的比如逻辑上不可达的路径这种,我们才主动触发panic。
switch s := suit(drawCard()); s {
case "Spades": // ...
case "Hearts": // ...
case "Diamonds": // ...
case "Clubs": // ...
default:
panic(fmt.Sprintf("invalid suit %q", s)) // Joker?
}
由于panic函数的唯一一个参数是空接口类型的,所以它可以接受任何类型的值。但最好传入error类型的错误值,或者可以被有效序列化的值。
八、神奇的Defer函数
顾名思义,defer语句就是被用来延迟执行代码的。延迟到什么时候呢?这要延迟到该语句所在的函数即将执行结束的那一刻,无论结束执行的原因是什么。但要注意,被延迟执行的是defer函数,而不是defer语句。
defer的语法非常简单:只需要在调用普通函数或方法前加上关键字defer。当执行到defer语句时,函数和参数表达式得到计算,但是只有包含这条defer语句的函数执行完毕时,defer后面的函数才会被执行。可以在一个函数中执行多条defer语句,它们的执行顺序与声明顺序相反(先进后出(FILO)的,相当于一个栈)。
defer通常被用在处理成对的操作,如打开、关闭、连接、断开连接、加锁、释放锁。或者调试复杂程序时,defer机制也常被用于记录何时进入和退出函数。
//处理互斥锁
var mu sync.Mutex
var m = make(map[string]int)
func lookup(key string) int {
mu.Lock()
defer mu.Unlock()
return m[key]
}
//调试程序记录进入退出信息
func bigSlowOperation() {
defer trace("bigSlowOperation")() // don't forget the extra parentheses
// ...lots of work…
time.Sleep(10 * time.Second) // simulate slow operation by sleeping
}
func trace(msg string) func() {
start := time.Now()
log.Printf("enter %s", msg)
return func() {
log.Printf("exit %s (%s)", msg,time.Since(start))
}
}
除此之外,还有一个重要用途就是:跟下面要讲的Recover配合,完成对panic的捕获和处理!
九、“圣母”函数:recover函数
难道panic就只能任由它制造恐慌吗?别怕,我们可以用recover函数来“控制”它!(但通常来讲,我们不应该对panic做任何异常处理,我们应该定位问题消灭它),但有时候我们有有必要做一些事情,比如在程序崩溃前,做一些必要的操作(释放资源、记录调试信息等)。
我们需要联用defer语句和recover函数调用,才能够恢复一个已经发生的 panic。
func main() {
fmt.Println("Enter function main.")
defer func(){
fmt.Println("Enter defer function.")
if p := recover(); p != nil {
fmt.Printf("panic: %s\n", p)
}
fmt.Println("Exit defer function.")
}()
// 引发panic。
panic(errors.New("something wrong"))
fmt.Println("Exit function main.")
}
//错误示例
func main() {
fmt.Println("Enter function main.")
// 引发panic。
panic(errors.New("something wrong"))
p := recover()
fmt.Printf("panic: %s\n", p)
fmt.Println("Exit function main.")
}
注意上面的错误示例中,recover调用并不会起到任何作用,甚至都没有机会执行。panic 一旦发生,控制权就会讯速地沿着调用栈的反方向传播。所以,在panic函数调用之后的代码,根本就没有执行的机会。
关于函数的所有知识,全部总结完毕,内容有点儿多,你需要好好理解和消化!