• 函数类型、函数签名
  • 高阶函数
  • 闭包
  • 函数传参

专栏:12 | 使用函数的正确姿势

— zher,杭州图书馆 2019-01-06 日 16 时许

函数是一等公民

函数:一等的(first-class)公民,函数类型也是一等的数据类型。可从一下几方面来理解:

  • 函数不但可以用于封装代码、分割功能、解耦逻辑,还可以化身为普通的值,在其他函数间传递、赋予变量、做类型判断和转换等等,就像切片和字典的值那样。
  • 更深层次的含义:函数值可以由此成为能够被随意传播的独立逻辑组件(或者说功能模块)
    • 对于函数类型具体来说:它是一种对一组输入、输出进行模板化的重要工具,它比接口类型更加轻巧、灵活,它的值也借此变成了可被热替换的逻辑组件。

来看一个 Demo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main

import "fmt"

// Printer 把输入打印到标准输出
type Printer func(args ...interface{}) (int, error)

func main() {
	var p Printer

	p = PrintToStd
	p("Hello, ", "Nice to meet you!")

	fmt.Printf("%T\n", PrintToStd)
}

// PrintToStd
func PrintToStd(args ...interface{}) (int, error) {
	return fmt.Printf("%v\n", args)
}

这里引出一个概念:函数签名

  • 函数签名:其实就是函数的参数列表和结果列表的统称,它定义了可用来鉴别不同函数的那些特征,同时也定义了我们与函数交互的方式。(备注:函数参数的变量名称和结果变量的名称其实不属于函数签名的一部分,只有类型被包含了, 更严格来讲,函数名称也不能算作函数签名的一部分)

  • 只要两个函数的参数列表和结果列表中的元素顺序及其类型是一致的,我们就可以说它们是一样的函数,或者说是实现了同一个函数类型的函数。

高阶函数

高阶函数具有两个特征点,只要满足其一即可视作高阶函数,即:

  • 接收其他函数作为参数传入
  • 把其他函数作为结果返回

来看一个 Demo, 展示了高阶函数的简单用法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package main

import (
	"errors"
	"fmt"
)

// operate 对输入参数做加减运算,并将执行结果返回
type operate func(x, y int) int

func main() {
	functionalA()
}

// **************** Case1:高阶函数用法,将函数类型作为函数参数传入
//
// 问题描述:具体的问题是,我想通过编写函数来实现两个整数间的加减乘
// 除运算,但是希望两个整数和具体的操作都由该函数的调用方给出,那么,
// 这样一个函数应该怎样编写呢。

// calculate
func calculate(x, y int, op operate) (int, error) {
	if op == nil { // 注意检查传入的函数类型的变量其值是否为 nil,
		// 在值为 nil 的函数上执行调用会引发 panic,此时应该立即终
		// 止该代码块的继续执行

		// Go 里的 if 语句,也称 `卫述语句`, 是指被用来检查关键的先
		// 决条件的合法性,并在检查未通过的情况下立即终止当前代码块执
		// 行的语句。
		return 0, errors.New("invalid operation")
	}

	return op(x, y), nil // 当代码运行到 op 调用时,calculate 函数
	// 具体执行了什么样的运算才能确定下来。
}

func functionalA() {
	// 声明一个 operate 类型(函数类型) 的变量 op
	var op operate

	// 打印 op 的值,可以看到结果为 nil,说明:函数类型属于引用类型,
	// 其值可以为 nil 的,在使用过程中要注意检查函数类型的变量其值是
	// 否为 nil,以防止引发 panic
	fmt.Println("op =", op) // op = <nil>

	op(1, 2)
	// panic: runtime error: invalid memory address or nil pointer dereference
	// [signal 0xc0000005 code=0x0 addr=0x0 pc=0x4905fc]

	op = func(x, y int) int { return x + y }
	fmt.Println("op =", op) // op = 0x490990

	ret, err := calculate(2, 3, op)
	if err != nil {
		fmt.Printf("calculate failed: %v\n", err)
		return
	}
	fmt.Printf("result: %v\n", ret)
}

Note: 顺便说一下,函数类型属于引用类型,它的值可以为 nil,而这种类型的零值恰恰是 nil。(在上面 Demo 中也有体现)

知识扩展

问题 1:如何实现闭包?

闭包又是什么?你可以想象一下,在一个函数中存在对 外来标识符 的引用。所谓的外来标识符,既不代表当前函数的任何参数或结果,也不是函数内部声明的,它是直接从外边拿过来的。

还有个专门的术语称呼它,叫 自由变量,可见它代表的肯定是个变量。实际上,如果它是个常量,那也就形成不了闭包了,因为常量是不可变的程序实体,而闭包体现的却是由 “不确定” 变为 “确定” 的一个过程。

我们说的这个函数(以下简称闭包函数)就是因为引用了自由变量,而呈现出了一种“不确定”的状态,也叫“开放”状态。

也就是说,它的内部逻辑并不是完整的,有一部分逻辑需要这个自由变量参与完成,而后者到底代表了什么在闭包函数被定义的时候却是未知的。

来看一个 Demo, 展示了高阶函数的另一个特征以及闭包应用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package main

import (
	"errors"
	"fmt"
)

// operate 对输入参数做加减运算,并将执行结果返回
type operate func(x, y int) int

func main() {
	functionalB()
}

// **************** CaseB: 高阶函数用法:将函数类型分别作为函数的参数和返回值

// calculateFn
type calculateFn func(x, y int) (int, error)

// genCalculator 定义一个匿名的、calculateFn 类型的函数并把它作为
// 结果值返回,而这个匿名的函数就是一个闭包函数。
//
// 匿名函数里面使用的变量 op  既不代表它的任何参数或结果也不是它自己// 声明的,而是定义它的 genCalculator 函数的参数,所以是一个自由变// 量。
//
// 这个自由变量 op 究竟代表了什么,这一点并不是在定义这个闭包函数的
// 时候确定的(其实,这时候知道该变量的类型),而是在 genCalculator
// 函数被调用的时候确定的。
func genCalculator(op operate) calculateFn {
	return func(x, y int) (int, error) { // 形成闭包
		// op: 外来标识符:既不代表当前函数的任何参数或者结果,也不
		// 是函数内部声明的,它是直接从 外边(genCalculator 函数域)
		// 拿来的。
		//
		// 还有个专门术语称呼它,叫 `自由变量`,可见它代表的肯定是个
		// 变量。 体现了一种由 `不确定` 变为 `确定` 的一个过程。

		if op == nil { // Go 语言编译器读到这里时会试图去寻找 op
			// 所代表的东西,它会发现 op 代表的是 genCalculator 函数
			// 的参数,然后,他会把这两者联系起来。这时可以说,自由变量
			// op 被 `捕获` 了
			//
			// 程序执行到这里时,op 就是那个参数值了。如此一来,该闭包函
			// 数的状态就由 `不确定` 变为了 `确定`,或者说转到了 `闭合`
			// 状态,至此也就真正的形成了一个闭包。
			return 0, errors.New("invalid operation")
		}
		return op(x, y), nil
	}
}

func functionalB() {
	x, y := 98, 62

	op := func(x, y int) int {
		return x - y
	}
	sub := genCalculator(op)

	ret, err := sub(x, y)
	if err != nil {
		fmt.Printf("calculate failed: %v\n", err)
		return
	}
	fmt.Printf("result: %v\n", ret)
}

看一下图示:

问题 2:传入函数的那些参数值后来怎么样了?

这个问题会关系到程序的稳定和安全, 又可引出 Go 中另一个知识点:Go 语言传参都是传值

看一个 Demo, 将数组作为函数参数,并在这个函数内部对数组元素进行修改,想想会输出什么?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package main

import "fmt"

func main() {
	array1 := [3]string{"a", "b", "c"}
	fmt.Printf("The array: %v\n", array1)
	array2 := modifyArray(array1)
	fmt.Printf("The modified array: %v\n", array2)
	fmt.Printf("The original array: %v\n", array1)
}

func modifyArray(a [3]string) [3]string {
	a[1] = "x"
	return a
}

答案是:原数组不会改变。为什么呢?原因是,所有传给函数的参数值都会被复制,函数在其内部使用的并不是参数值的原值,而是它的副本。

由于数组是值类型,所以每一次复制都会拷贝它,以及它的所有元素值。我在 modify 函数中修改的只是原数组的副本而已,并不会对原数组造成任何影响。

Note: 对于引用类型,比如:切片、字典、通道像上面那样复制它们的值,只会拷贝它们 本身 而已,并不会拷贝它们引用的底层数据结构。也就是说,这时只是 浅表复制,而不是深层复制

  • 以切片值为例,如此复制的时候,只是拷贝了它指向底层数组中某一个元素的指针,以及它的长度值和容量值,而它的底层数组并不会被拷贝。

  • 另外还要注意,就算我们传入函数的是一个值类型的参数值,但如果这个参数值中的某个元素是引用类型的,那么我们仍然要小心。

比如:

1
2
3
4
5
complexArray1 := [3][]string{
	[]string{"d", "e", "f"},
	[]string{"g", "h", "i"},
	[]string{"j", "k", "l"},
}

变量 complexArray1 是[3][]string 类型的,也就是说,虽然它是一个数组,但是其中的每个元素又都是一个切片。这样一个值被传入函数的话,函数中对该参数值的修改会影响到 complexArray1 本身吗 ?

思考题

  • complexArray1 被传入函数的话,这个函数中对该参数值的修改会影响到它的原值吗?

关于这个问题的答案参见这里的 Demo

  • 函数真正拿到的参数值其实只是它们的副本,那么函数返回给调用方的结果值也会被复制吗?

关于这个问题的答案参见这里的 Demo

总结:Go 只会传值,而值的特性决定了是否会对原始数据产生影响

See Also

Thanks to the authors 🙂