七叶笔记 » golang编程 » 大白话 golang 教程-09-深入学习函数调用

大白话 golang 教程-09-深入学习函数调用

目前已经多次使用到函数了,包括系统的内置函数 len、cap、append、copy、delete、make、new 等,后面会接触到更多的内置函数,比如 panic、recover、close 等等。

函数调用的时候编译器会计算函数需要使用的栈空间,记录调用下一个函数的函数、函数返回后的下一个指令位置、当前函数的栈基址、被调用函数的本地变量,记录的意思就是把它们压入执行栈,被调用的函数指令执行完了后再跳回到原来指令的位置(先进后出),继续执行原来函数的下一条指令,层层调用直到 main 函数执行完毕退出,而且栈空间大小是不断调整的。

首先回到 main 函数,go 语言除了 main 这个特殊的函数外,还有一个 init 函数也比较特殊,如果包内出现了 init 函数,它将首先被调用。

 package initial

import "fmt"

func init() {
  fmt.Println("init called in initial package")
}  

包 initial 下的 init 函数,只要导入这个包,init 函数就会被执行(不用调用包内的其他函数)。

 import (
  _ "09-inside-func/initial"
)  

_ 表示忽略占位符,这个写法就是导入包,但是又不打算调用包内的函数,目的就是让它触发 init 的调用,该函数不能有参数和返回值,也不能被其他函数显示调用,每个包内可以有多个 init 函数,甚至每个源文件都可以有多个 init 函数,但请注意同一个包内的 init 函数执行顺序并不能保证,不同的包的 init 顺序按照导入包的依赖关系(不是导入包的编码顺序) 来执行,所有的 init 函数比 main 函数还先执行。那 init 有什么应用场景呢?

1. 初始化变量

2. 程序运行前的独立的准备工作

3. 实现只调用一次 once 的功能 (init 的机制决定了只会被执行一次,很可靠)

4. 配合日志用于调试包的导入顺序等问题

前面的章节示例知道,函数可以赋值给一个变量,也可以是匿名函数直接运行,函数也可以作为一个函数的返回值,也就是返回函数的函数。

 func getNameFunc(name string) func() string {
  return func() string {
    return name
  }
}  

这个函数返回一个 “没有参数而且返回字符串” 的函数,下面的调用它的代码:

 func Test_getNameFunc(t *testing.T) {
  f1 := getNameFunc("zhangsan")
  f2 := getNameFunc("lisi")
  fmt.Println(f1()) // zhangsan
  fmt.Println(f2()) // lisi
}  

很显然,函数可以作为函数的返回值,也能作为函数的参数传递。

 // NameExchangeFunc 变换名称的函数
type NameExchangeFunc func(string) string

func doubleNameFunc(name string) string {
  return name + name
}

func upperNameFunc(name string) string {
  return strings.ToUpper(name)
}

// func changeName(name string, f func(string) string) string {
func changeName(name string, f NameExchangeFunc) string {
  return f(name)
}  

为了简化参数的写法,重新定义了 NameExchangeFunc 类型,函数 changeName 第一个参数表示待处理的名称,第二个参数表示处理的策略方案,由调用者来指定,对函数的引用直接使用它的名称。

 func Test_changeName(t *testing.T) {
  doubleName := changeName("zhangsan", doubleNameFunc)
  upperName := changeName("lisi", upperNameFunc)
  fmt.Println(doubleName) // zhangsanzhangsan
  fmt.Println(upperName)  // LISI
}  

函数的最后一个参数支持可变参数,它的本质是切片,很多库函数都使用可变参数。

 func joinAll(prefix string, strs ...string) string {
  str := prefix

  // 可变参数的本质是切片
  fmt.Printf("%T\n", strs) // []string

  for _, s := range strs {
    str = fmt.Sprintf("%s-%s", str, s)
  }

  return str
}

func Test_joinAll(t *testing.T) {
  str := joinAll("prefix", "one", "two", "three")
  fmt.Println(str) // prefix-one-two-three
}  

递归是函数很重要的特性,运行过程中自己调用自己就是递归,递归一定要有结束函数运行的条件,否则函数调用上下文不断的压入栈,会导致栈溢出。

 func willDie() {
  willDie()
}

// fatal error: stack overflow  

依据 N 的阶乘是 N * N-1 可以使用递归写出以下的代码:

 func factor(num uint64) (res uint64) {
  if num > 1 {
    res = num * factor(num-1)
    return 
  }

  return 1
}  

注意递归退出的条件是被计算的数 > 1,如果 num 等于 1,直接返回 1,比如 6 的计算过程如下:

 res = 6 * factor(5)                 // 压栈
res = 6 * 5 * factor(4)             // 压栈
res = 6 * 5 * 4 * factor(3)         // 压栈
res = 6 * 5 * 4 * 3 * factor(2)     // 压栈
res = 6 * 5 * 4 * 3 * 2 * factor(1) // 压栈
res = 6 * 5 * 4 * 3 * 2 * 1         // 出栈
res = 6 * 5 * 4 * 3 * 2             // 出栈
res = 6 * 5 * 4 * 6                 // 出栈
res = 6 * 5 * 24                    // 出栈
res = 6 * 120                       // 出栈
res = 720                           // 返回  

可见递归函数的缺点就是函数调用压栈,会一直增加栈的使用空间,如果栈空间被耗尽,就会发生溢出,把函数的条件反过来重构一下代码,更好理解:

 func factor2(num uint64) uint64 {
  if num == 1 {
    return 1
  }
  
  return num * factor2(num-1)
}  

对于递归的优化,很多语言有尾递归(TCO-Tail Call Optimization),它的原理是函数最后一步操作(注意不一定是代码中的最后一行语句)是调用它本身,这样函数结束以后没有后续操作,不需要保存当前函数的执行环境,永远只有一个调用记录,编译器可以优化,因为没有必要记录调用时的上下文,抹掉栈的当前函数信息直接替换为新的函数,这样栈的空间始终是一定的。

它的编写套路通常是: 比普通的线性递归函数多一个参数,用这个参数来保存上一次调用函数得到的结果,修改上面的阶乘:

 func tailFactor(num, current uint64) uint64 {
  if num == 1 {
    return current
  }

  return tailFactor(num-1, num*current)
}  

它计算 6 的过程如下:

 tailFactor(5,6)
tailFactor(4,30)
tailFactor(3,120)
tailFactor(2,360)
tailFactor(1,720) // 返回  

不过对 factor2(10) 和 tailFactor(9,10) 进行基准测试,发现它们的性能差别好像并不大:

 // factor2(10) 
52402336	        23.0 ns/op	       0 B/op	       0 allocs/op

// tailFactor(9,10) 
54346518	        22.0 ns/op	       0 B/op	       0 allocs/op  

看起来 go 的编译器并没有对尾递归进行优化,要确定的话需要把这两个函数单独写成文件,反编译出汇编代码看看他们是否有区别:

 // factor2
"".factor STEXT size=108 args=0x10 locals=0x18
  0x0000 00000 (factor.go:3)  TEXT  "".factor2(SB), ABIInternal, $24-16
  0x0000 00000 (factor.go:3)  MOVQ  (TLS), CX
  0x0009 00009 (factor.go:3)  CMPQ  SP, 16(CX)
  0x000d 00013 (factor.go:3)  PCDATA  $0, $-2
  0x000d 00013 (factor.go:3)  JLS 101
  0x000f 00015 (factor.go:3)  PCDATA  $0, $-1
  0x000f 00015 (factor.go:3)  SUBQ  $24, SP
  0x0013 00019 (factor.go:3)  MOVQ  BP, 16(SP)
  0x0018 00024 (factor.go:3)  LEAQ  16(SP), BP
  0x001d 00029 (factor.go:3)  FUNCDATA  $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
  0x001d 00029 (factor.go:3)  FUNCDATA  $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
  0x001d 00029 (factor.go:4)  MOVQ  "".num+32(SP), AX
  0x0022 00034 (factor.go:4)  CMPQ  AX, $1
  0x0026 00038 (factor.go:4)  JNE 59
  0x0028 00040 (factor.go:5)  MOVQ  $1, "".~r1+40(SP)
  0x0031 00049 (factor.go:5)  MOVQ  16(SP), BP
  0x0036 00054 (factor.go:5)  ADDQ  $24, SP
  0x003a 00058 (factor.go:5)  RET
  0x003b 00059 (factor.go:8)  LEAQ  -1(AX), CX
  0x003f 00063 (factor.go:8)  MOVQ  CX, (SP)
  0x0043 00067 (factor.go:8)  PCDATA  $1, $0
  0x0043 00067 (factor.go:8)  CALL  "".factor(SB)
  0x0048 00072 (factor.go:8)  MOVQ  8(SP), AX
  0x004d 00077 (factor.go:8)  MOVQ  "".num+32(SP), CX
  0x0052 00082 (factor.go:8)  IMULQ AX, CX
  0x0056 00086 (factor.go:8)  MOVQ  CX, "".~r1+40(SP)
  0x005b 00091 (factor.go:8)  MOVQ  16(SP), BP
  0x0060 00096 (factor.go:8)  ADDQ  $24, SP
  0x0064 00100 (factor.go:8)  RET
  0x0065 00101 (factor.go:8)  NOP
  0x0065 00101 (factor.go:3)  PCDATA  $1, $-1
  0x0065 00101 (factor.go:3)  PCDATA  $0, $-2
  0x0065 00101 (factor.go:3)  CALL  runtime.morestack_noctxt(SB)
  0x006a 00106 (factor.go:3)  PCDATA  $0, $-1
  0x006a 00106 (factor.go:3)  JMP 0

// tailFactor
"".tailFactor STEXT size=114 args=0x18 locals=0x20
  0x0000 00000 (tailfactor.go:3)  TEXT  "".tailFactor(SB), ABIInternal, $32-24
  0x0000 00000 (tailfactor.go:3)  MOVQ  (TLS), CX
  0x0009 00009 (tailfactor.go:3)  CMPQ  SP, 16(CX)
  0x000d 00013 (tailfactor.go:3)  PCDATA  $0, $-2
  0x000d 00013 (tailfactor.go:3)  JLS 107
  0x000f 00015 (tailfactor.go:3)  PCDATA  $0, $-1
  0x000f 00015 (tailfactor.go:3)  SUBQ  $32, SP
  0x0013 00019 (tailfactor.go:3)  MOVQ  BP, 24(SP)
  0x0018 00024 (tailfactor.go:3)  LEAQ  24(SP), BP
  0x001d 00029 (tailfactor.go:3)  FUNCDATA  $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
  0x001d 00029 (tailfactor.go:3)  FUNCDATA  $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
  0x001d 00029 (tailfactor.go:4)  MOVQ  "".num+40(SP), AX
  0x0022 00034 (tailfactor.go:4)  CMPQ  AX, $1
  0x0026 00038 (tailfactor.go:4)  JNE 60
  0x0028 00040 (tailfactor.go:5)  MOVQ  "".current+48(SP), AX
  0x002d 00045 (tailfactor.go:5)  MOVQ  AX, "".~r2+56(SP)
  0x0032 00050 (tailfactor.go:5)  MOVQ  24(SP), BP
  0x0037 00055 (tailfactor.go:5)  ADDQ  $32, SP
  0x003b 00059 (tailfactor.go:5)  RET
  0x003c 00060 (tailfactor.go:8)  LEAQ  -1(AX), CX
  0x0040 00064 (tailfactor.go:8)  MOVQ  CX, (SP)
  0x0044 00068 (tailfactor.go:8)  MOVQ  "".current+48(SP), CX
  0x0049 00073 (tailfactor.go:8)  IMULQ CX, AX
  0x004d 00077 (tailfactor.go:8)  MOVQ  AX, 8(SP)
  0x0052 00082 (tailfactor.go:8)  PCDATA  $1, $0
  0x0052 00082 (tailfactor.go:8)  CALL  "".tailFactor(SB)
  0x0057 00087 (tailfactor.go:8)  MOVQ  16(SP), AX
  0x005c 00092 (tailfactor.go:8)  MOVQ  AX, "".~r2+56(SP)
  0x0061 00097 (tailfactor.go:8)  MOVQ  24(SP), BP
  0x0066 00102 (tailfactor.go:8)  ADDQ  $32, SP
  0x006a 00106 (tailfactor.go:8)  RET
  0x006b 00107 (tailfactor.go:8)  NOP
  0x006b 00107 (tailfactor.go:3)  PCDATA  $1, $-1
  0x006b 00107 (tailfactor.go:3)  PCDATA  $0, $-2
  0x006b 00107 (tailfactor.go:3)  CALL  runtime.morestack_noctxt(SB)
  0x0070 00112 (tailfactor.go:3)  PCDATA  $0, $-1
  0x0070 00112 (tailfactor.go:3)  JMP 0  

忽略开头和尾部检查栈扩容的代码,两者的核心代码没有区别区别,关于 go 汇编的知识请跳到 29 节查看,如果加大参数会发现 2 个版本都会栈溢出(事实上大部分语言都不支持尾递归,支持的语言(编译器)有 gcc/clang、erlang、lua、scheme、haskell 等):

 // factor2(100000000)
// tailFactor(100000000-1, 100000000)

runtime: goroutine stack exceeds 1000000000-byte limit
runtime: sp=0xc0200f0398 stack=[0xc0200f0000, 0xc0400f0000]
fatal error: stack overflow  

很多时候在函数返回之前,都需要清理释放资源,比如我们打开文件处理完之后应该关闭它。

 func readfile() string {
  file, _ := os.Open("/tmp/command.txt") // 示例忽略错误
  buf := make([]byte, 10)
  size, _ := file.Read(buf)
  str := string(buf[0:size])
  fmt.Println(str)

  if str == "start" {
    file.Close()
    return "execute service start"
  } else if str == "stop" {
    file.Close()
    return "execute service stop"
  }

  file.Close()
  return "unknow"
}
  

想提前返回的时候我们需要关闭这个文件流,不但写起来繁琐,很可能在 return 的时候忘了调用 close 方法,造成内存泄露,go 语言使用 defer 语句来处理这类问题,defer 关键字后面跟一个函数调用,它在 return 后但在函数结束前被调用,多个 defer 反序调用。上面的函数可以修改成:

 func readfile2() string {
  file, _ := os.Open("/tmp/command.txt")
  defer file.Close()

  buf := make([]byte, 10)
  size, _ := file.Read(buf)
  str := string(buf[0:size])
  fmt.Println(str)

  if str == "start" {
    return "execute service start"
  } else if str == "stop" {
    return "execute service stop"
  }

  return "unknow"
}  

不过 defer 有很多坑。首先在 return 之后函数结束之前调用啥意思? 因为 return 是一个复合语句不是原子操作,先赋值,再跳转,下面的例子说明这个情况:

 var globalFlag int = 0

func flagFunc() int {
  defer func() {
    globalFlag = 1
  }()

  fmt.Printf("callAfterReturn-flag = %d\n", globalFlag) // 0
  return globalFlag
}

func callAfterReturn() {
  localFlag := flagFunc()
  fmt.Printf("callAfterReturn-localFlag = %d\n", localFlag)   // 0
  fmt.Printf("callAfterReturn-globalFlag = %d\n", globalFlag) // 1
}  

localFlag 的结果是 0,说明函数 return 的结果不是 defer 语句修改过的,但 globalFlag 最后是 1,说明的确被 defer 修改了,要确认需要反汇编代码,事实上 defer 是插入在结果赋值和返回之间。

 rval = xxx  // 返回值赋值给rval
defer_func  // 执行defer函数
ret         // 函数返回  

defer 立刻对表达式求值,对参数进行拷贝,而且 defer 语句只有执行过才会被调用。

 func readName(name string) {
  fmt.Println("changeName " + name) // changeName zhangsan
}

func passName() {
  name := "zhangsan"
  defer readName(name) // 拷贝 name
  name = "lisi"
  fmt.Println("passName " + name) // passName lisi
}  

当然如果 defer 参数拷贝的是变量的内存地址,影响就不一样了。

 func readNumberRef(ptrNumber *int) {
  fmt.Printf("readNumberRef %d\n", *ptrNumber) // readNumberRef 1
}

func passNumberRef() {
  number := 0
  defer readNumberRef(&number) // 拷贝 number 的地址
  number = 1
  fmt.Printf("passNumberRef %d\n", number) // passNumberRef 1
}  

下面的函数 defer 后的函数不会被执行,因为 defer 本身并没有被执行过。

 func wontRun() {
  if false {
    defer fmt.Println("should not see me") // 不会执行
  }

  fmt.Println("see me")
}  

猜一猜下面的函数打印什么结果?

 func printBook(b *book) {
  fmt.Println(*b)
}

func walkBooks() {
  books := []book{
    {Name: "计算机"}, {Name: "电脑"},
  }
  for _, b := range books {
    defer printBook(&b)
  }
}  

defer 反序执行对吧,应该会打印

 {电脑}
{计算机}  

实际情况是只会打印 2 个电脑,原因是 for-range 的循环变量 b 始终是一个内存地址,它每次拷贝循环的值,defer 的 printBook 的函数每次调用都传递的是这个 b 的内存地址,修改如下:

 for index := range books {
  defer printBook(&books[index])
}  

下面的代码打印了什么?

 func loopForDelay() {
  for i := 0; i < 10; i++ {
    defer func() {
      fmt.Println(i) // 10
    }()
  }
}  

全部打印 10,因为循环结束后 i = 10,defer 的打印语句封闭了外部 i 变量(闭包),修改为参数拷贝的形式就没问题了:

 defer func(number int) {
  fmt.Println(number) // 9 8 7 6 5 4 3 2 1 0
}(i)  

函数的值传递和引用传递,前面的章节已经多次遇到过了,不再详述。

go 语言里一类特殊的函数叫方法,依附于一个 struct 实例上,go 中 struct 其实具有和其他语言 class 一样的地位,在面向对象的章节将详细的介绍方法。

本章节的代码

相关文章