速学 Go 笔记 (三) : 函数与方法

588 阅读18分钟

第五章 函数

5.1 函数声明

对于 Go 语言而言,完整的函数声明至少要包含:名字,形参列表,函数体。当函数不通过 return 关键字返回任何值时,声明部分可以将返回列表省略。

func name(params-list) [result-list] {
	...
	[return <value>]
}

一个不返回值的函数,它的作用应当体现在副作用上 ( 这包括通过指针修改了某个地址上的值,甚至说是修改了远程的数据库数据 )。既不返回值,又不具备副作用的函数是没有任何意义的。

// 这个函数的作用体现在了其副作用上:通过 fmt 向屏幕输出了 "hello,go"。
func greet() {
	fmt.Print("hello,go")
}

// 这个函数的作用只体现在返回值上,它是一个纯函数。
func add(a int,b int) (result int) {
	result = a + b
	return result
}

当形参列表的某些连续个形参是同一种类型时,可以参考以下的简写方式:

func add(a,b int,c,d float64){}
// 等价于
// func(a int,b int,c float64,d float64){...}

其中,ab 都是 int 类型,而 cd 则均为 float64 类型。

Go 语言将函数视作是一等公民,这意味着函数可以被当作值在任意一处传递。函数的形参列表返回值构成了函数签名,它将用于规范函数的类型。函数签名和变量名,返回值的命名都没有关系。

通过格式化输出 fmt.Printf("%T",func) 可以查看任意一个函数的函数签名:

fmt.Printf("%T",greet)
// 输出 func()
fmt.Printf("%T",add)
// 输出 func(int,int) int

函数可以递归。经典的例子就是递归方式遍历树结构,因为树结构本身的定义就是递归的:

func seek(t *Tree){
	if t != nil {fmt.Print(t.data," ")} else {return}
	if t.left != nil {seek(t.left)}
	if t.right != nil {seek(t.right)}
}

type Tree struct {
	data int8
	left *Tree
	right *Tree
}

如果选择了用递归解决问题,那么它最好是尾递归的,因为这可以避免在深度递归时的栈溢出问题。

5.2 函数返回多个值

这一点和其它语言不大相同。下面用一个简单的 "除法" 函数举个例子,它的结果有商和余数:

// 函数签名 func(int,int) (int,int)
func div (a,b int) (result,rem int) {
	result = a / b
	rem = a - b * result
	return
}

这里存在两个形参列表,第一个是 "入参",而第二个则是 "出参"。这是一个具名的 "出参" 列表,因此在函数体内可以通过 = 形式为返回值赋值,然后仅使用一个 return 关键字将 "出参" 列表返回 ( 该写法在 Go 语言中称之为 "裸返回" ) 。外界接受到的将是 "出参" 列表中赋好值的 resultrem 变量:

result, rem := div(5, 2)

"出参" 列表的局部变量可以是匿名的:

func div(a,b int)(int,int){
	result := a/b
	return result,result * b
}

如果是这样的写法,那么在程序的结尾处,return 后面需要按照声明的类型顺序排列好返回值。

某个函数 f 的返回值可以是通过调用另一个 "出参" 列表相同的函数 g 获得的:

// 函数 f 和 g 具有相同的返回值列表。因此可以直接返回 g() 的调用结果。
func f()(r1,r2 int){
	return g() 
}

func g()(r1,r2 int){return 1,2}

再或者,如果函数 f 的 "出参" 列表和函数 g 的 "入参" 列表一致,那么可以直接将函数 f 作为形参传递到函数 g 中去。

// 函数 f 的出参和 g 的入参相对应,因此函数 f 可以直接作为函数 g 的形参。
func f()(r1,r2 int){
	return r1,r2
}

func g(r1,r2 int) int {return r1 + r2}
// 可以在主函数中这样调用:
b g(f())

5.3 变长函数

变长函数指某个函数的最后一个参数是可变长度的,这在其它语言中也十分常见。在 Go 语言中,它这样表示:

func moreInt8s (aInt int8,int8s ...int8){
	//....
}

其中,int8... 表示 int8s 参数可能表示一个多个 int8 类型的值。在函数内部,这个变长的参数将被视作一个切片被处理,因此可以使用 for 循环去遍历它:

func moreInt8s(int8s ...int8){
	fmt.Printf("%v",int8s)
	for _,v := range int8s { fmt.Println(v)}
}

尽管如此,它和下面的函数 someInt8s 仍然被视作是两类函数,因为它们的函数签名并不相同。

func main() {

	//func([]int8)
	fmt.Printf("%T\n",someInt8s)
	
	//func(...int8)
	fmt.Printf("%T\n",moreInt8s)
}

func moreInt8s(int8s ...int8){
	fmt.Printf("%v",int8s)
	for _,v := range int8s { fmt.Println(v)}
}

func someInt8s(int8s []int8){
	fmt.Printf("%v",int8s)
	for _,v := range int8s { fmt.Println(v)}
}

因此不可以直接将一个切片传递到变长函数内:

ints := []int8{1,2,3,4,5,6}
moreInt8s(ints)

但是有一个语法糖可以解决这个需求:将传入的切片后边加上 ... 表示将这个切片视作是变长参数传递到函数内部。

ints := []int8{1,2,3,4,5,6}
moreInt8s(ints...)

5.4 函数即变量

函数可以作为变量而被直接声明。下面的代码块创建了一个匿名函数 ( 或者称表达式 ) ,它被赋值给了变量 f

// 这个函数将数值 x2。
var f = func(i int8) int8 {return 2*i}

// 4.
fmt.Println(f(2))

匿名函数的声明方式是:首先给定一个函数签名,然后跟上一个由 {} 括起来的语句块,语句块内的 return 内容取决于函数签名。注意,如果匿名函数要进行递归,形式上必须先将这个匿名函数赋值给变量,然后通过调用这个变量来实现递归。

var factorial func(int8) int8

// 通过匿名函数实现的阶乘函数
factorial = func(n int8) int8 {
   if n >= 1 {
      return n * factorial(n-1)
   }else{
      return 1
   }
}

fmt.Print(factorial(5))

任何一个将函数视作一等公民的编程语言都很容易实现高阶函数,或者是一个被柯里化的函数。比如说下面的 foreach 算子 ( 一个最简单的 Monad ) 接收另一个函数 to[]int8 进行转换操作:

// foreach 是一个高阶函数,它接收另一个函数 to 对 ints 切片做变换。
func foreach(ints *[]int8,to func(int8) int8){
	for i,v := range *ints{
		(*ints)[i] = to(v)
	}
}

再比如说,通过函数柯里化在 Go 语言内实现了 "伪" while 循环。这里将条件语句抽象成了 func() bool 函数,而内部的循环体语句块抽象成了 func () 函数:

func While(condition func() bool) func(block func()){
	
	// 外部的 While 函数相当于内部函数 _while_ 的闭包。
	var _while_  func(func())

	// 1. 这是一个没有任何返回值的尾递归函数,因此它可以被调用任意的深度。
	// 2. 对于这个匿名函数而言,condition() 是一个自由变量,因为它不受 _while() 的控制。
	_while_ = func(b func()) {
		if condition() {b();_while_(b)}
	}

	return _while_
}

使用它的方式是这样的:

/*
	想象它是这样的语句:
	while(flag < 100){
 		flag ++
		fmt.Print(...)
	}
	flag 在 while(...) 语句的作用域外,while 内部通过副作用修改 flag 的值。
 */
var flag = 1
While(func() bool {
    return flag < 100
})(func() {
    flag++
    fmt.Printf("loop :[%v/100]\n",flag)
})

5.5 延迟调用函数 defer

这里引入一个新的关键字 defer。跟在 defer 关键字后面的函数调用会在外界执行并返回 ( 无论是正常 return 还是因为异常情况而宕机 ) 之后才会被执行。这里举一个简单的例子:

func testDefer(){
	defer fmt.Println("after")
	fmt.Println("before")
	return
}

按照一般的顺序执行逻辑,after 应该先于 before 被打印到控制台。但是 defer 声明使得打印 after 的输出语句推迟到 testDefer() 返回之后才执行,实际上控制台的打印顺序将是:

before
after

下面通过一个示例来介绍什么场合下适合使用它:

func openFile(){
	open, err := os.Open("C:\\Users\\i\\go\\src\\awesomeProject\\vars\\a.go")
	if err != nil {
		log.Fatal(err.Error())
		return
	}

	fmt.Printf(open.Name())
	// 假定 Close() 是一定执行成功的。
	_ = open.Close()
	return
}

这段程序会因 err 出现两种 return 行为:在正常打开文件的情况下,程序将打印出文件名,释放资源并退出;否则。打印错误信息而直接退出。这并不是一段严谨的代码,因为当程序出现错误时,它不会执行到 _ = open.Close()。这样会到导致 open 指向的文件地址就不能够被回收重用 ( 内存泄漏 ) 。

使这段逻辑更健壮的做法是:在判 err 非空的语句块内补充上 Close() 语句。

func openFile(){
	open, err := os.Open("C:\\Users\\i\\go\\src\\awesomeProject\\vars\\a.go")
	if err != nil {
		log.Fatal(err.Error())
		_ = open.Close()
		return
	}

	fmt.Printf(open.Name())
	_ = open.Close()
	return
}

进一步,很容易联想到:在逻辑更加复杂的函数当中,为了及时地释放资源,我们必须手动在所有会使程序短路退出的语句块内都补充 Close() 函数。

if con1() {
    //...
    _ = open.Close()
    return
}else {
    
    _ = open.Close()
    return
}

if con2() {
    _ = open.Close()
    return
}

....

defer 则保证了某个函数调用只会在被 return 之后执行 ( 不管是在何处 return ,哪怕是因为因为宕机 ),现在 Close() 函数只需要写一次。从逻辑上来看,它的功能类似于一个 "finally " 语句块,或者将其理解成创建了类似 AOP 的 after(..) 切面也可以。

func openFile(){
	open, err := os.Open("C:\\Users\\i\\go\\src\\awesomeProject\\vars\\a.go")
	// 无后顾之忧, 因为它总是在 openFile() 返回之后执行.
    // defer 后面只能跟进函数调用,这意味着该写法下 open.Close() 的返回值被忽略了。
	defer open.Close()
	
	if err != nil {
		log.Fatal(err.Error())
		return
	}

	fmt.Printf(open.Name())
	return
}

defer 在需要成对操作的场合会很有用:比如某个连接的握手和断开,资源的打开和关闭,同步锁的加锁和放锁。利用它可以有效保证某些资源总是在函数运行之后就会被释放,而和具体的逻辑无关。

延迟调用函数可以接收返回值列表并操作,比如修改它的值 ( 但这并不是 defer 本身的特性,这实际上是通过函数闭包达到的效果 ) 。除此之外,defer 后面可以直接跟进一个对匿名函数的调用,但是写法上要注意一点,匿名函数声明之后一定要主动带上 () 括起来的参数列表,表示 "声明且调用" 。

// 示例1,接收返回值
func add (a int, b int) (result int){
	// 利用 defer 实现日志记录操作:
	defer log.Printf("the reuslt is %v",result)
	result = a + b
	return
}

// 示例2,在得到 add2 的返回值之后更改它,以此改变调用该函数得到的最终结果。
func add2(a,b int8) (result int8) {

	result = a+b
	
	// 这里延迟调用的是一个匿名函数,可以利用它去承载一个语句块,
    // 因为一段语句块可以被抽象成无入参且无返回值的 func() 。
    // 后面必须要再加上一个() 来表示这是一个函数的 (延迟) 调用。
	defer func() {
		result += 1
	}()
	
    return
}

5.5.1 多个 defer 调用会倒序执行

一个函数内部可以有任意多个 defer 延迟调用,它们在函数 return 之后按照声明的倒序执行,可以想象成:每一个 defer 调用都是将一个延迟调用的函数压入了一个栈内存储,函数 reuturn 之后的 "善后" 工作,是通过从这个栈中不断 pop 并执行的。

这是出于 "先打开的资源后关闭 " 的角度考量的。举个例子来说,我们生活中正常开机并工作的顺序是这样的:

(1) Open PC => (2) Open OS => (3) Start Coding

等到下班时,是 "先关电脑后保存文件",还是 "先保存文件再关电脑" 呢?显然答案是后者。

(3) Shut Down PC <= (2) Shut Down OS <= (1) Commit Code

另一方面,程序员习惯于 "在局部代码块内关闭最近使用过的资源",因此代码结构更倾向于这个样子:

// 一段伪代码
{
	os = pc.Open()
	defer pc.Close()
	
	if os != nil {
		app = os.open()	
         defer os.Close()
		
        if app != nil {
            app.Coding()									
            defer app.Git()
		}
	}
}

从声明的层次顺序来看,pc.Close() 是最 "前面的"。假使程序按照 defer 声明的顺序逐个关闭资源,那么显然依赖于 pcos.Close()app.Git() 都会因为 pc.Close() 提前执行而导致出现 "空指针异常" 。因此 Go 干脆将 defer 设计成 FILO 的栈结构,让程序员保持习惯的同时,让资源也能够被正确地按顺序关闭。

// 程序总是输出 123 而非 321.
func testDefer() {
	a, b, c := 1, 2, 3
	if a == 1 {
		defer fmt.Printf("3")
		if b == 2 {
			defer fmt.Printf("2")
			if c == 3 {
				defer fmt.Printf("1")
			}
		}
	}
}

5.5.2 defer 延迟调用函数的赋参时机

我们传统印象中的 "函数调用" 就是 "传参并执行" 的连续操作,但是对于 defer 声明的延迟函数调用而言,这个过程被分开了:传参过程早在程序执行到 defer 声明时就已经完成,只有执行函数体的过程被推迟了

这是一个简单的验证:

func testDefer() {
   a := 1
   // a 在程序执行到这条 defer 声明时就被赋值给了 fmt.Print(..) 函数,显然此刻的 a == 1 ,传入函数的形参值也是 1。
   defer fmt.Print(a)

   // 因此,后续的代码块无论对 a 做何修改,最终控制台打印的结果仍然是 1 .
   a += 10
}

下面的例子巧妙地利用了这个特性,通过计算 defer 延迟调用函数从 "被赋值" 到 "被执行" 的这段时间差 (delta time) 来测量另一个函数的执行耗时。

// 这个例子中用到了 Go 的官方库 time:
// time.Time 		=> time 包中通用的,表示 "时间" 的类型。
// time.Since(..)	=> 计算从现在到过去某一个时间的时间差。
// time.Now()		=> 生成当前时间。

func trace(before time.Time){
	log.Printf("takes time:%s",time.Since(before))
}

想要使用它测试某个函数的执行时间,只要在此函数的开头部分通过 defer "设下埋点" 就可以了:

func openFile(){
	defer trace(time.Now())
	//..openFile() 的逻辑
}

下面的例子则是更 "花哨" 的版本,因为它是配合函数闭包来使用的 defer 延迟调用:

// 这个函数用于测试并记录某个程序的运行时间
func trace() (deltaTime func()) {
    // time 是官方库,Now() 函数可以获取当前时间
	start := time.Now()
	// Since(...) 函数可以获取当前时间到传入时间的这段时间差。
    deltaTime = func(){log.Printf("takes time:%s",time.Since(start))}
    return
}

这相当于将真正希望推迟执行的函数 ( 暂且称之为 "目标函数" ) 以及依赖的参变量提取到了另一个高阶函数内,实现了 "打包" 的效果 ( 它确实就叫 “闭包” )。这样,defer 只需要简单地调用这个高阶函数就可以直接完成一系列复杂的初始化步骤,然后拿到目标函数等待被延迟调用

使用它测试某个函数的执行时间,写法是这样的:

defer trace()()

这里有两层 () 小括号的原因是:在这里期望延迟调用的是 trace() 函数返回的 deltaTime() ,而不是 trace() 函数本身。

当传入的参数需要一系列更加复杂的计算时,显然不太可能只通过一行 defer 声明将复杂工作全部完成 ,此时需要将这一系列操作打包到一个匿名函数调用,或者是直接使用具名的高阶函数。

下面的例子演示了如何利用 defer 做一个环绕型通知:

func greet() {
	defer AOP()()
	log.Printf("hello")
}


func AOP() func(){

	func(){
		log.Printf("before ...")
	}()

	return func(){
		log.Printf("after ...")
	}
}

程序的运行结果将是:

2021/03/07 16:56:59 before ...
2021/03/07 16:56:59 hello
2021/03/07 16:56:59 after ...

5.6 循环内的匿名函数共享迭代遍历地址

原版的 《 Go 程序设计语言》 函数章节的 警告:捕获迭代变量 段落使用了较为复杂的案例做陈述,这里笔者做了简化。

通过循环语句创建出的多个匿名函数,它们将共享迭代变量 ( 如下面代码块的 iv ) 的地址。

// 这个循环将创建出 三个匿名函数副本,它们将共享循环变量 i,v 的地址。
// 在有些情况下,这会导致一些预料之外的错误。
for i,v := range []int8{1,2,3} {
    // 注意,这个表达式声明后面带上了参数列表(),表示声明并且调用。
   func(){
      fmt.Printf("index is %v and the value is %v\n",i,v)
   }()
}

运行代码,看起来没有任何问题:

index is 0 and the value is 1
index is 1 and the value is 2
index is 2 and the value is 3

现在换个做法:每创建出一个匿名函数,不立刻执行它,而是先将其暂存到一个 []func() 切片内,等到循环结束之后再统一运行。

var jobs []func()

for i,v := range []int8{1,2,3} {

    job := func(){
        fmt.Printf("index is %v and the value is %v\n",i,v)
    }
    jobs = append(jobs,job)
}

for _,job := range jobs {
    job()
}

问题出现了。程序打印的是:

index is 2 and the value is 3
index is 2 and the value is 3
index is 2 and the value is 3

如果笔者用顺序结构复现上述代码的逻辑,它是这样的:

func forBlock(Range []int8) {

    // 模拟 for 的迭代变量 //
    var i int8 = 0
    var v int8 = 0

    // 第一次迭代
    v = Range[i]
    expr1 := func() {
        fmt.Printf("index is %v and the value is %v\n", i, v)
    }

    // 第二次迭代
    i++
    v = Range[i]
    expr2 := func() {
        fmt.Printf("index is %v and the value is %v\n", i, v)
    }

    // 第三次迭代
    i++
    v = Range[i]
    expr3 := func() {
        fmt.Printf("index is %v and the value is %v\n", i, v)
    }

    // 模拟从刚才的 jobs 当中读取每一个 job() 执行
    expr1()
    expr2()
    expr3()
}

可以这么理解,在刚才的 for 循环中,三个匿名函数其实全部处于一个闭包下 ( 在这里你可以理解为它们都在一个大的 forBlock 函数内部 ) ,它们内部的自由变量 iv 其实是全部指向了最开始声明的那两个 iv 变量。

那为什么在本节的最开始,程序是按既定的规则输出的?原因就在于之前的匿名函数是 "边声明边调用的"。这样,即便是 iv 被改动了,它也不会影响到已经执行完毕的结果。

func forBlock(Range []int8) {

   // 模拟 for 的迭代变量 //
   var i int8 = 0
   var v int8 = 0

   // 第一次迭代,这个匿名函数已经创建被执行完了,后续 i,v 的更改和它无关。
   v = Range[i]
   func() {
      fmt.Printf("index is %v and the value is %v\n", i, v)
   }()

   // 第二次迭代
   i++
   v = Range[i]
   func() {
      fmt.Printf("index is %v and the value is %v\n", i, v)
   }()

   // 第三次迭代
   i++
   v = Range[i]
   func() {
      fmt.Printf("index is %v and the value is %v\n", i, v)
   }()

}

5.7 错误处理

我们在之前已经见识过 Go 标准库如何利用函数返回多值的特性来实现 "抛出异常":通常前者是期望的计算结果,后者则是实现了 Error 接口的返回值。比如:

file,err := os.Open("/usr/share/music.mp3")

Go 没有像其它语言一样设计类似 try ... catch ... fianlly 的异常捕捉语句块,而是将异常视作是一个普通的值,我们因此只要简单地使用一个 if 语句块来捕获异常:

// 在 Go 语言中,通用的处理异常的办法。
if err != nil {
	log.Fatal("something was wrong!")
	return
}

对于捕获到的 Error 异常有两种处理策略,除了像上述代码块直接通过 return 退出程序以外,在面对一些 "因临时的不可抗力导致的异常" 时,如请求的资源正在被加锁,或因当时的网络状态差导致请求网络资源失败等情况,更合理的做法是选择有限地重试,直到超出重试次数或等待时间为止,而不应该是立刻退出程序。

func get() *http.Response {
   // 设置超时等待时间
   limitTime :=  1 * time.Minute
   ddl := time.Now().Add(limitTime)
   
   // 如果失败,则该程序通过退避策略不断重试。
   for tries:=0;time.Now().Before(ddl);tries++{
      resp,err := http.Get("http://www.baidu.com")
      if err != nil {
         
         // 每失败一次,就退避更长时间重试。
         time.Sleep(time.Second << tries)
         continue
      }
      return resp
   }
   return nil
}

5.8 宕机

Go 语言的类型系统会捕获异常错误,但是有些错误只能在运行期间进行检查,比如数组是否进行了越界访问,或者引用了一个空指针。这些错误是严重的内部错误,因此它们会导致程序宕机。

一旦发生宕机,程序就相当是立刻 return 返回, defer 声明的调用会开始执行,并且在最后留下异常退出的日志消息。和抛出一个 Error 异常不同,宕机时输出的日志包含了完整的栈跟踪消息,我们通常可以借助它来排查代码出错的原因。

并不是所有的宕机都是程序运行时的内部错误,比如可以主动通过 panic(...) 函数的方式来触发一个宕机,该函数可以接收任何值。发生宕机通常是意味着 "遇到了比 Error 还棘手的麻烦"—— 只有出现一些逻辑上不应该出现的,或者是预料之外的错误时才应该使用它。

str := "go1"
switch str {
    case "go":
    	fmt.Print("hello,go")
    case "java":
    	fmt.Print("hello,java")
    case "c":
    	fmt.Print("hello,c")
    default:
         // 其实这个异常是可通过判断检查的,因此使用 Error 更合适一些。
    	panic("value of str should be one of : (go,java,c)")
}

发生宕机不一定会导致程序立刻退出,因为程序仍然可以在执行 defer 延迟调用函数时进行一步 "自救" —— 在延迟调用函数内可以通过调用 recover() 令程序回到正常状态。

func main() {
	unsafe()
	println("主函数正常执行并退出")
}

func unsafe() {
	defer func() {
		err := recover()
		log.Printf("unsafe 在执行时曾发生了一个严重的错误。%v\n",err)
		bytes := [4096]byte{}
		runtime.Stack(bytes[:],false)
		s := string(bytes[:])
		print(s)
	}()
	panic("错误信息")
}

recover() 函数还能够接受到 panic(...) 留下的错误信息并返回 ( 如果外部函数是正常执行并返回的,则该返回值为 nil) ,以便于程序员为分析代码错误留下线索。除此之外,本例通过 runtime.Stack(...) 函数主动向控制台输出了栈跟踪信息。

直接使用 recover(...) 的场合其实并不多,因为发生宕机意味着程序大概率将不能正常继续运行。

假设下面的函数用于检查用户名。文件内部定义了三个空结构体来表示错误类型 "字符过长","包含特殊字符","首字符非大写" ,它们均会导致宕机。延迟调用函数能够处理 "首字母非大写" 的情况并使程序恢复到正常状态。但如果是前两种情形,则延迟调用函数会重新进入宕机状态。

不过,这个例子虽然展现出了宕机和恢复的工作机制,但是违背了宕机不应该用于 "预期的错误" 的准则。更合理的做法是将 "可预料的错误" 当作 Error 来处理或者是抛出。

func checkName(name string) {

   defer func() {
      maybeErr := recover()
      switch maybeErr {
      case firstCharNotUpperException{}:

         lowerRune := []rune(name)[0]
         upperRune := unicode.ToUpper(lowerRune)
         strings.Replace(name, string(lowerRune), string(upperRune), 1)
         fmt.Print("按照要求,首字母被转换为了大写。")
      default:
         panic(maybeErr)
      }

   }()

   // 如果下面任何一个表达式匹配成功都会导致程序退出。
   switch {

   case utf8.RuneCount([]byte(name)) > 6:
      panic(toLongCharacterError{})

   case strings.ContainsAny(name, "<>!?&"):
      panic(invalidCharacterError{})

   case unicode.IsUpper([]rune(name)[0]):
      panic(firstCharNotUpperException{})

   default:
      fmt.Print("验证通过,上传数据库")
   }

}

type toLongCharacterError struct{}
type invalidCharacterError struct{}
type firstCharNotUpperException struct{}

第六章 方法

6.1 方法声明

方法声明和函数声明类似,但是在前面增加了参数。下面的代码块演示了 Position 类型的声明,以及针对该类型的测距方法 distance(...)

func (p Position) distance(q Position){

	y := math.Abs(float64(p.Y - q.Y))
	x := math.Abs(float64(p.X - q.X))

	sqrt := math.Sqrt(x*x + y*y)
	fmt.Printf("%g",sqrt)
	
}

func main() {
    p := Position{3, 4}
	q := Position{0, 0}

    //(this).op(that)
	p.distance(q)
}

这种写法相当于是将 distance(...) 函数绑定到了 p 所属的类型上 ,称 p 是这个函数的接收者。值 p 语义上接近其它语言中的 "this" 或者是 "self",它代表了任意一个属于 Position 类的对象,而值 q 语义上是被操作数 "that" 。

上述代码块的 p.distance 被称为是 选择子,可以简单理解成是 "调用了 p 对象的 distance 方法" 。方法名和字段名共享一个命名空间,这意味着 Position 类不能再包含名为 Distance 的字段;同样,也不能再声明一个名为 XY 的方法,否则编译器就会提示错误。

通过 type 定义的 "类型别称" 也可以绑定方法:

type Paragraph []string

// 这个方法适用于 Paragraph 类型,但是不适用于 []string 类型。
func (p Paragraph) read(){
   for _,v := range p {
      fmt.Println(v)
   }
}

func main(){
    
	paragraph := Paragraph{
		"hello world",
		"hello go",
		"hello scala",
		"hello java",
	}

	paragraph.read()
}

6.2 接收者可以为指针

当一个结构体的数据量比较庞大时,最好使用指针,因为这样可以免去值复制的过程。下面的代码块是 distance(..) 方法的指针接收者版本。

type Position struct{X,Y int16}

func (p *Position) distance_(q *Position){

    // 重点: (*p).X 可以直接写成 p.X。
    // 这个语法糖带来的影响我们会在 接口 章节继续探讨。
	y := math.Abs(float64(p.Y - q.Y))
	x := math.Abs(float64(p.X - q.X))

	sqrt := math.Sqrt(x*x + y*y)
	fmt.Printf("%g",sqrt)

}

接收者的类型要么是一个命名类型,要么就是指向一个命名类型的指针,比如 Position*Position。注意,如果一个 "类型别称" 指向一个指针,那它就不能充当接收者,下面演示了这个反例:

type PointOfPosition *Position

// 编译不通过。
func (p PointOfPosition) distance(q PointOfPosition){
	//....
}

当接收者为指针时,应格外注意调用该方法时的写法:

// 如果该变量本身是字面量,则需要使用 & 号传地址。
p := Position{1, 3}
q := Position{2,4}
(&p).distance_(&q)


// 如果该变量本身是指针,则可以省去 * 号。
l := &Position{2,5}
k := &Position{4,6}

// 等效于 (*l).distance_(k)
l.distance_(k)

但是,如果想要通过调用方法来修改接收者自身成员的内容,此时的接收者必须为地址

// import ...
// 在主函数中测试运行的结果
func main(){

	position := Position{3, 4}

	// 没影响
	position.goLeft()
    fmt.Print(position)

	// 有影响
	position.goRight()
	fmt.Print(position)

}

type Position struct {
	X,Y int
}

// 注意,这个接收者是指针,这个修改会影响到接收者。
func (p *Position) goRight()  {
   p.X += 1
}

// 这个接收者是命名类型,因此接收者接收的仍然只是一个值副本,换句话说,
// 执行这个方法不会影响到原接收者本身。
func (p Position) goLeft(){
   p.X -= 1
}

6.2.1 警惕接收者为 nil 的情况

当方法的接收者是一个指针时,最好在方法体内加入判空的逻辑保证程序足够健壮。以上述的 distance_(...) 函数为例,如果用户无意间这样做了:

var p *Position 
q := &Position{4,6}

// p => nil
(p).distance_(q)

访问 nil 指针的成员会导致宕机:

panic: runtime error: invalid memory address or nil pointer dereference

最体面的解决方式是,当判断接收者为 nil 时,给出一个警告或返回 Error,避免访问空指针的成员。

func (p *Position) distance_(q *Position){

	if p == nil {println("warning: value p is a nil pointer.");return}
	y := math.Abs(float64(p.Y - q.Y))
	x := math.Abs(float64(p.X - q.X))

	sqrt := math.Sqrt(x*x + y*y)
	fmt.Printf("%g",sqrt)

}

但有些情况下,接收者为 nil 仍然是有意义的。比如下面的例子定义了一个 ListInt 链表类型,它内部有统计长度的 length() 方法。该方法允许接收者 p 是一个 nil ,这将表明 "统计一个空链表的长度",显然此时的返回值是 0

这里的 ListInt 是一个递归定义,因此 ( 包括后续的 ) 相关方法也尽可能采取尾递归的方式实现。

type ListInt struct {
	v    int16
	tail *ListInt
}

func (l *ListInt) length() int16 {

	var loop func(*ListInt, int16) int16

	loop = func(l *ListInt, acc int16) int16 {
		switch {
		case l == nil: {return acc}
		default: return loop(l.tail,acc+1)
		}
	}

	return loop(l, 0)
}

// 为了方便测试,这里给出其它的操作方法:
// append 方法接收一个元素,并返回被更改后的列表。
func (l *ListInt) append(x int16) *ListInt {

	if l == nil {
		return &ListInt{x, nil}
	}

	p := l
	q := l
	for p.tail != nil {
		p = p.tail
	}

	p.tail = &ListInt{x, nil}
	return q
}

// appends 是 append 方法的变长参数版本,允许一次性添加多个元素,内部通过递归实现。
func (l *ListInt) appends(xs ...int16) *ListInt {

	var loop func(l *ListInt, xxs ...int16) *ListInt
	loop = func(l *ListInt, xxs ...int16) *ListInt {
		switch {
		case len(xxs) == 0:
			return l
		default:
			l = l.append(xs[0])
			xs = xs[1:]
			return loop(l, xs...)
		}
	}

	return loop(l, xs...)
}

// toString 方法将列表转换成 "1,2,3,4..." 形式的字符串,以便查看内容。
func (l *ListInt) toString(sep string) string {

	var loop func(*ListInt, string) string

	loop = func(l *ListInt, buf string) string {
		switch l {
		case nil:
			return buf
		default:
			return loop(l.tail, buf+sep+strconv.Itoa(int(l.v)))
		}
	}

	return strings.Replace(loop(l, ""), sep, "", 1)

}

6.2.2 无法更改指针接收者的引用

方法内对指针接收者的引用本身做任何 "重定向" 操作都不会产生任何作用。比如说下面的 clear() 方法试图将接受者重新指向一个 nil 来达到将非空列表 "清除" 的目的:

func (l *ListInt) clear() {
	/*
	IDE 的警告:
	Assignment to method receiver propagates only to callees but not to callers
	Inspection info: Reports assignment to method receiver.
	When assigning a value to the method receiver it won't be reflected outside of the method itself.
	Values will be reflected in subsequent calls from the same method.
	*/
	l = nil
}

然而,通过实际运行代码证明,这个 clear() 并没有生效。

head := ListInt{0, nil}
head.appends(1, 2, 3, 4, 5, 6, 7)

head.clear()

// 长度非 0,说明 clear() 方法没起效果。
println(head.length())

6.3 通过结构体内嵌组合类型

相比 Java "is-a" 的 OOP 视角 ( 在这里主要是继承,多态 ),Go 则更倾向于使用 "has-a" 的逻辑去表达。在下面的示例中,有三个类型以及相关方法 ItemPositionAtlas

type Atlas struct {
	// 相当于 Atlas 本身具备了 Name, X, Y 成员。
	Item
	Position
}

type Item struct {
	Name string
}

type Position struct {
	X,Y int
}

func (a *Atlas) PinPoint() {
	fmt.Printf("Position(%v) has a (%v)", a.Position, a.Name)
}

// Distance 方法的指针版本,接收者和参数都是指针。
func (p *Position) DistanceP(q *Position) (d float64) {
	sqrt := math.Sqrt
	abs := math.Abs
	p2 := func(x float64) float64 {
		return math.Pow(x, 2)
	}

	x1 := p2(abs(float64(p.X - q.X)))
	y1 := p2(abs(float64(p.Y - q.Y)))

	d = sqrt(x1 + y1)
	return
}

// Distance 方法的值传递版本。
func (p Position) DistanceV(q Position) (d float64){
	sqrt := math.Sqrt
	abs := math.Abs
	p2 := func(x float64) float64 {
		return math.Pow(x, 2)
	}

	x1 := p2(abs(float64(p.X - q.X)))
	y1 := p2(abs(float64(p.Y - q.Y)))

	d = sqrt(x1 + y1)
	return
}


func (i *Item) Info() string {
	return fmt.Sprintf("the name of this item: %s", i.Name)
}

其中,Atlas 相当于组合了 ItemPosition ,或者说 ItemPosition 被纳入到 Atlas 类型中。这样做的结果是,Atlas 获得了这两个类型的方法和成员:

atlas := Atlas{
    Item:     Item{"Apple"},
    Position: Position{0, 0},
}

// 实际上是,atlas.Position.Distance(...)
fmt.Println(atlas.Distance(&Position{3, 4}))

// 实际上是,atlas.Item.Name(...)
fmt.Println(atlas.Name)

不过值得注意的是,无论是对于 Distance 方法和 Name 方法,它们的接收者类型始终不变。Go 编译器相当于做了一步适配工作:

func (p *Atlas) Distance(q *Position) float64 {
	p.Position.Distance(q)
}

6.4 方法变量

在上述代码中,选择子 atlas.Distance 可以像匿名函数一样赋值给一个变量,这个变量称之为方法变量。它本质上是一个已经指派好接收者的函数。再或者可以理解成:atlas 是当前 Distance 函数的 "闭包",这个函数依赖的自由变量是 atlas 的成员。

atlas := Atlas{
    Item:     Item{"Apple"},
    Position: Position{0, 0},
}

distanceFromA := atlas.Distance
// 是否有点函数柯里化的既视感?
fmt.Println(distanceFromA(&Position{3, 4}))

由此可以进一步理解,方法是一种自带闭包的特殊函数,只是保存状态的职责交给了对象,而不再是另一个高阶函数。在 Go 语言中,方法类型和去掉接收者的函数类型完全一致。

atlas := Atlas{
    Item:     Item{"Apple"},
    Position: Position{0, 0},
}

distanceFromA := atlas.Distance

//func(*main.Position) float64,和去掉接收者之后的函数类型一致。
fmt.Printf("%T",distanceFromA)

一方面,如果想以 "面向对象" 的角度实现柯里化,又或者是想简化调用的表达式,方法变量会变得特别有用。另外一方面,如果想用一个符号 ( 变量 ) 来表达多个同种类型的方法,它也可以派上用场。

type Calculator struct {
	a,b int
	ifAdd bool
}

// 方法的类型仍然是 func() int
// 这个方法将两个成员 a,b 相加。
func (c Calculator) add() int {
	return c.a + c.b
}

// 方法的类型仍然是 func() int
// 这个方法将两个成员 a,b 相减。
func (c Calculator) sub() int {
	return c.a - c.b
}

func (c Calculator) do() int {
	// func() int 在此处代表了 Calculator 的一个可能的方法。
	// 其实,抛出语境来看,op 可以是函数变量,也可以是方法变量,这取决于你如何为它赋值。
	var op func() int
	if c.ifAdd {
		// c.add 是一个方法变量。
		op = c.add
	}else {
		op = c.sub
	}
	return op()
}

如果选择子的 . 操作符前面不是某个具体变量,而是命名类型 ( 或对应的指针类型 ),则它是一个方法表达式,如:Atlas.DistanceV(*Atlas).DistanceP。想要调用一个方法表达式,需要同时传入接收者和参数列表。

// 选择子的 . 符号前面是命名类型(以及指针),而非某个具体的变量。
// 因此,distanceByPointer 和 distanceByValue 被称作是方法表达式。
// distanceByPointer 要求接收者是 Atlas 类型,而后者要求 *Atlas 类型。这取决于方法中定义的接收者是值还是指针地址。
distanceByPointer := Atlas.DistanceV
distanceByValue := (*Atlas).DistanceP

// 使用方式发生了变化,使用方法表达式需要首先传入接收者,然后再传入后续的参数列表。
println(distanceByPointer(atlas, Position{3, 4}))
println(distanceByValue(&atlas, &Position{3, 4}))

Go 对方法表达式的参数列表做了以下处理:将接收者的值或者是地址 ( 这取决于方法表达式的写法 ) 头插到原方法的参数列表的首个位置。从表面上看,方法表达式 distance 变成了一个普通的函数,因为这种写法隐去了 . 操作符。

到这里,有必要做一些说明:假设某一个类型为 T ,那么接收者设置为 T*T 是存在区别的,比如在 接口 章节就会有所体现 ( 是 T 实现了接口还是 *T 实现了接口呢?)。由于 Go 语言的语法机制导致了这样的一个现象:假设某个方法的接收者为 T ,那么这个方法可以被 T*T 类型所用。但反之,如果某个方法的接收者为 *T ,那么这个方法只可以被 *T 或者辅以 & 取址符的方式调用。