函数和方法是 Go 程序逻辑的基本承载单元,这篇文章将聚焦于函数与方法的设计与实现。
+ + +在 init 函数中检查包级别变量的初始状态
包是 Go 程序逻辑封装的基本单元,每个包都可以立即为一个 自治的
、封装良好的、对外部暴露有限接口的基本单元。一个 Go 程序就是由一组包组成的。
Go 包中包含了常量、包级别变量、函数、类型和类型方法、接口等,我们要保证包内部这些元素在被使用之前处于合理有效的状态,尤其是包级别变量。在 Go 中一般通过 init 函数来完成这一工作。
+认识 init 函数
Go 中有两个特殊函数,一个是 main 包中的 main 函数,它是所有 Go 可执行程序的入口函数;另一个就是包的 init 函数。init 函数是一个无参数、无返回值的函数:
+1 | func init() { |
如果一个包定义了 init 函数,Go 运行时会负责在该包初始化时调用它的 init 函数。在 Go 程序中不能显式调用 init,否则会在编译期间报错。
+1 | package main |
1 | # ./main |
-
+
- 一个 Go 包可以拥有多个 init 函数,每个组成 Go 包的 Go 源文件可以定义多个 init 函数 +
- 在初始化 Go 包时,Go 运行时会按照一定的次序逐一调用该包的 init 函数。 +
- Go 运行时不会并发调用 init 函数,总是串行执行一个个 init 函数 +
- 每个 init 函数在整个 Go 程序生命周期内仅会被执行一次 +
因此,init 函数非常适合做一些包级数据的初始化及初始状态的检查工作。一般来说,先被传递给 Go 编译器的源文件中的 init 函数先被执行,同一个源文件中的多个 init 函数按照声明顺序依次执行。但是 Go 的惯例告诉我们:不要依赖 init 函数的执行次序。
+程序初始化顺序
Go 程序由一组包组合而成,程序的初始化就是这些包的初始化。下图展示了 Go 程序的初始化顺序:
+ + + +-
+
- Go 运行时按照包导入的顺序,先去初始化 main 包的第一个依赖包 pkg1 +
- Go 运行时按照
深度优先
的原则,查找到pkg1 -> pkg2 -> pkg3
,由于 pkg3 没有依赖包,因此在pkg3
中按照常量-变量-init
函数的顺序进行初始化
+ pkg3
初始化完毕后,Go 运行时回到 pkg2 并对 pkg2 初始化,之后再回到 pkg1 并对 pkg1 进行初始化
+- 之后 Go 运行按照类似的规则继续对 main 包中的第二个依赖包 pkg4 进行初始化 +
- 当 main 包所有依赖包都初始化完毕后,会开始 main 包自身的初始化,Go 运行时会按照
常量-变量-init
函数顺序完成 main 包的初始化
+
完成以上初始化工作后,才正式进入程序入口函数 main 函数。下面这个程序展示了包内初始化工作是按照 常量-变量-init
函数顺序完成的:
1 | import "fmt" |
1 | # ./main |
使用 init 函数检查包级别变量的初始状态
init 函数就好比 Go 包真正投入使用之前唯一的 质检员
,负责对包内部以及暴露到外部的包级数据(主要是包级变量)的初始状态进行检查。init()
使用场景主要:
-
+
- 重置包级别变量值 +
- 对包级别变量进行初始化,保证其后续使用 +
- 在 init 函数进行注册:在 init 函数中注册自己的实现模式降低了 Go 包对外的直接暴露,尤其是包级别变量的暴露,避免了外部通过包级别变量对包状态的修改。有时候我们仅仅以空别名的方式导入某个包,就是为了让 Go 运行时运行该包的 init 函数,从而完成相关的注册工作 +
init 函数是一个无参数、无返回值的函数。如果 init 函数中遇到错误,则说明该包其实处于一个不可用的状态,因此这种情况下快速失败是最佳选择。一般选择直接调用 panic 或者 log.Fatal 等函数记录异常日志,然后让程序快速退出。
+让自己习惯于函数是一等公民
在 Go 中,函数是唯一一种基于特定输入、实现特定任务并可反馈任务执行结果的代码块(方法本质上是函数的一个变种)。本质上,我们可以说 Go 程序就是一组函数的集合。
+Go 的函数具有以下特点:
+-
+
- 以 func 关键字开头 +
- 支持多返回值 +
- 支持具名返回值 +
- 支持递归调用 +
- 支持同类型的可变参数 +
- 支持 defer,实现函数优雅返回 +
而且 Go 中的函数是一等公民。
+什么是一等公民
如果一门编程语言对某种语法元素的创建和使用限制,可以像对待值一样对待这种语法元素,那么就称这种语法元素是这门编程语言的 一等公民
。这意味着拥有 一等公民
待遇的语法元素可以存储在变量中,可以作为参数传递给函数,可以在函数内部创建并作为返回值从函数返回。
在 Go 中,我们可以正常创建函数、在函数内部创建函数、使用函数来创建自定义类型、将定义好的函数存储到变量中、将函数作为参数传入函数中、作为返回值从函数返回,函数也可以放入数组、切片或 map 结构中,可以像其他变量一样赋值给 interface{}
,甚至可以建立元素为函数的 channel。
因此在 Go 中,作为 一等公民
的 Go 函数拥有很大的灵活性。
函数作为 一等公民
的特殊作用
函数是一等公民,意味着函数也可以被显式类型转换,并且这样的类型转换在特定的领域具有奇妙的作用。如下是一个典型例子:
+1 | import ( |
这段代码值得深入分析,可以看到 ListenAndServer
的第二个参数其实一个 Handler
接口类型,该接口类型只有一个方法 ServeHTTP()
:
1 | type Handler interface { |
虽然我们的 greeting()
的函数原型和 ServerHTTP()
原型一致,但是我们不能直接将 greeting 作为参数值传入,因为 greeting()
函数类型并没有实现接口 Handler 的方法,无法将其赋值给 Handler 接口类型。
为了解决这个问题,我们将 http.HandlerFunc(greeting)
作为参数传递给 ListenAndServe()
,而 HandlerFunc
的定义如下:
1 | type HandlerFunc func(ResponseWriter, *Request) |
HandlerFunc 其实就是一个基于函数定义的新类型,它的底层类型就 func(ResponseWriter, *Request)
。而 HandlerFunc
类型实现了 ServeHTTP()
方法,也就是说 HandlerFunc
实现了 Handler 接口。
所以 http.HandlerFunc(greeting)
其实是将函数 greeting 显式转型为 HandlerFunc
类型,而后者实现了 Handler 接口,这样转型后的值就满足了 ListenAndServe()
的第二个参数类型要求。而这个转型是可以通过编译器检查的,因为 HandlerFunc 底层类型与 greeting 的函数原型是一致的。
如下是一个更简单的例子:
+1 | type Adder interface { |
Go 其实对函数式编程也提供一定程度的支持,有时候应用函数式编程风格可以编写出更优雅、更简洁、更易维护的代码。
+函数式编程的一种典型应用是 函数柯里化
,这是指把接受多个参数的函数变换成接受一个单一参数(原函数的第一个参数)的函数,并返回接受余下参数和返回结果的新函数的技术。如下是一个例子:
1 | package main |
这里也用到了 Go 函数支持的另一个特性 闭包
。闭包是在函数内部定义的匿名函数,并且允许匿名函数访问定义它的外部函数的作用域。本质上,闭包是将函数内部和函数外部连接起来的桥梁。
函数式编程里的另一个典型应用是函子(functor)。函子本身是一个容器类型,以 Go 语言为例,这个容器可以是切片、map 甚至 channel。该容器类型需要实现一个方法,该方法接受一个函数类型参数,并在容器上的每个元素上应用那个函数,得到一个新函子,原函子容器内部的元素值不受影响。
+1 | package main |
函子非常适合用于对容器元素进行批量同构处理,而且代码比每次都对容器中的元素进行循环处理要优雅、简洁许多。
+再看一个函数式编程风格,即 延续传递式
(Continuation-passing Style,CPS)。在该风格中,函数式不允许有返回值的。一个函数 A 应该将其想返回的值显式传递给一个 continuation 函数(一般接受一个参数),而 continuation 函数自身是函数 A 的一个参数。如下是一个例子:
1 | import "fmt" |
可以看到,这种编程风格其实理解起来更为困难。所以我们不能为了函数式而进行函数式编程。
+使用 defer 让函数更简洁、更健壮
有时候我们会在函数中申请一些资源并在函数退出前释放或关闭这些资源。函数的实现需要确保这些资源在函数退出时被及时正确地释放,无论函数的执行流程是按预期顺利进行还是出现错误提前退出。但即便如此,如果函数实现中的某段代码逻辑抛出 panic,传统的错误处理机制依然没有办法捕获它并尝试从 panic 中恢复。
+解决上面这些问题正是 Go 语言引入 defer 的初衷。
+defer 的运作机制
defer 的运作离不开函数,这意味着:
+-
+
- 在 Go 中,只有函数和方法内部才能使用 defer +
- defer 关键字后面只能接受函数或者方法,这些函数被称为 deferred 函数。defer 将它们注册到其所在 goroutine 用于存放 deferred 函数的栈数据结构中,这些 deferred 函数将在执行 defer 的函数退出前按照后进先出(LIFO)的顺序调度执行 +
无论是执行到函数体尾部,还是在某个错误处理分支显式调用 return 返回,抑或是出现 panic,已经存储到 deferred 函数栈中的函数都会被调度执行。因此 deferred 函数是一个在任何情况下都可以为函数进行收尾工作的好场合。
+1 | func writeToFile(fname string, data []byte, mu *sync.Mutex) error { |
这里资源释放函数的 defer 注册动作紧临着资源申请成功的动作。这样成对出现的惯例极大降低了遗漏资源释放的可能性。这样再也不用小心翼翼地在每个错误处理分支中检查是否遗漏了某个资源的释放动作。
+defer 的常见用法
除了释放资源这个最基本的用法之外,defer 的运作机制决定了它还可以在其他一些场合发挥作用。
+defer 的第二个重要用途就是拦截 panic,并按需要对 panic 进行处理。可以尝试从 panic 中恢复(这也是 Go 中唯一从 panic 中恢复的手段),也可以触发一个新的 panic。
+下面的代码演示了如何拦截 panic 并恢复程序的执行:
+1 | package main |
而如下代码则为触发一个新的 panic:
+1 | // $GOROOT/src/bytes/buffer.go |
deferred 函数在出现 panic 的情况下依旧能够调度执行,所以如下两个函数在程序触发 panic 时是不一样的。当 bizoperation
出现 panic 时,函数 g 无法释放 mutex,而函数 f 则可以正确释放。
1 | var mu sync.Mutex |
虽然 deferred 函数可以拦截大部分 panic,但是无法拦截并恢复一些运行时之外的致命问题,例如通过 C 代码引发的 panic,deferred 函数就无能为力。
+defer 还可以用于修改函数的具名返回值,例如用于修改 error 错误值。如下是一个更简单的例子。
+1 | package main |
1 | # ./main |
defer 函数被注册即调度执行的时间点使得它十分适合用来输出一些调试信息。一个典型的例子是在出入函数时打印 trace 日志,例如:
+1 | package main |
1 | # ./main |
defer 还有一种比较小众的用法,用于还原变量的旧值。如下是一个来自标准库的例子:
+1 | // $GOROOT/src/syscall/fs_nacl.go |
关于 defer 的几个关键问题
对于自定义的函数或方法,defer 可以无条件的支持,但是对于有返回值的自定义函数或方法,返回值会在 deferred 函数被调度执行的时候被自动丢弃。对于有返回值的内置函数,如果将其作为 defer 函数,则编译器会给出错误提示。此时可以将它们包装到一个匿名函数中,例如:
+1 | defer func() { |
使用 defer 时,需要牢记,defer 关键字后面的表达式是将 deferred 函数注册到 deferred 函数栈的时候进行求值的。如下是一个典型例子:
+1 | package main |
1 | # ./main |
这里最需要注意的是 foo3,因为压入 deferred 函数栈的函数是:
+1 | func() |
而每个 deferred 函数被调度执行的时候,匿名函数以闭包的方式访问外部函数变量 i,而此时 i 的值是 4,因此最后都是输出 4。
+下面还是一个例子:
+1 | package main |
1 | # ./main |
虽然 defer 让资源释放的过程变得优雅的多,也不容易出错,但是在性能敏感的程序中,defer 带来的性能开销也是 Gopher 必须要知晓和权衡的。Go 1.14 开始,defer 性能提升巨大,已经和不用 defer 的性能相差很小了。
+理解方法的本质以选择正确的 receiver 类型
和函数相比,Go 语言的方法在声明形式上仅仅多了一个参数,Go 称之为 receiver 参数。receiver 参数是方法与类型之间的纽带。Go 方法的声明形式如下:
+1 | func (receiver T/*T) MethodName(params) (results) |
在上述方法声明中的 T 称为 receiver 的基类型。通过 receiver,上述方法被绑定到类型 T 上。或者说,上述方法是类型 T 的一个方法。可以通过类型 T 或者 *T 的实例调用该方法。例如:
+1 | var t T |
Go 方法具有如下特点:
+-
+
- 方法名的首字母是否大写决定了该方法是不是导出方法 +
- 方法定义要与类型定义放在同一个包内,因此不能为原生类型(例如 int、float、map 等)添加方法,只能为自定义类型添加方法,也不能横跨 Go 包为其他包内的自定义类型定义方法 +
- 每个方法只能有一个 receiver 参数。不支持多 receiver 参数列表或者变长 receiver 参数。一个方法只能绑定一个基类型 +
- receiver 参数的基类型本身不能是指针类型或者接口类型。例如如下都是错误的 +
1 | type MyInt *int |
方法的本质
Go 语言没有类,方法与类型之间通过 receiver 联系在一起。可以为任何非内置原生类型定义方法。对于 Go 来说,会将 receiver 作为第一个参数传入方法的参数列表,因此如下方法其实和如下函数是等价的:
+1 | type T struct { |
1 | func Get(t T) int { |
这种转换后的函数就是方法的原型,只不过在 Go 中,这种等价是由 Go 编译器在编译和生成代码时自动完成的。Go 语言规范中提供了一个新的概念,可以让我们更充分理解上面的等价转换。
+Go 方法的使用方式如下:
+1 | var t T |
也可以按照如下方式等价替换上述调用方式:
+1 | var t T |
这种直接以类型名 T 调用的方法的表达式称为方法表达式。**类型 T 只能调用 T 的方法集合中的方法,同理,类型 T 只能调用 T 方法集合中的方法
+这种通过方法表达式对方法进行调用与我们之前所做的方法到函数的等价转换如出一辙。这就是 Go 方法的本质:一个以方法所绑定类型实例为第一个参数的普通函数。Go 方法自身的类型就是一个普通函数,我们甚至可以将其作为右值赋值给函数类型的变量:
+1 | var t T |
选择正确的 receiver 类型
因为方法和函数的如下等价变换关系:
+1 | func (t T) M1() <=> M1(t T) |
当以 T 作为 receiver 参数类型时,由于其等价于 M1(t T)
,Go 函数的参数采用的是值复制传递。当选择以 *T
作为 receiver 参数类型时,由于其等价于 M2(t *T)
,因此传递给 M2 函数的 t 是 T 类型实例的地址。
如下示例演示了选择不同 receiver 类型对原类型实例的影响:
+1 | package main |
1 | # ./main |
**无论是 T 类型实例还是 *T 类型实例,都既可以调用 receiver 为 T 类型的方法,也可以调用 receiver 为 *T 类型的方法**。
+1 | package main |
1 | # ./main |
可以看到 T 类型实例调用 receiver 为 T 的 M2 方法是 ok 的,同样,T 类型实例 pt 调用 receiver 类型为 T 的 M1 方法也是可以的。实际上,这都是 Go 的语法糖,Go 编译器在编译和生成代码时为我们自动做了转换。
+因此,可以得出 receiver 类型选用的初步结论:
+-
+
- 如果要对类型实例进行修改,那么为 receiver 选择 *T 类型(只与方法 receiver 类型有关,与调用实例的类型无关) +
- 如果没有对类型实例进行修改的需求,那么 receiver 选择 T 类型或者 *T 类型均可,但考虑到 Go 方法调用时,receiver 是以值复制形式传入方法中,如果类型 size 较大,以值形式传入会导致较大的损耗,这是选择 *T 作为 receiver 类型更好 +
关于 receiver 类型选择还有一个重要因素,就是类型是否要实现某个接口。这个会在下一条详细介绍。
+如下是一个非常容易出错的实例,这里也用到了我们之前介绍过 for 循环中变量复用
的知识:
1 | package main |
1 | # ./main |
方法集合决定接口实现
自定义类型的方法和接口都是 Go 中重要概念,并且它们之间存在千丝万缕的联系。如下是一个示例:
+1 | package main |
上述代码会触发编译错误,这个错误其实就和类型的方法集合有关。
+1 | compiler: cannot use t (variable of type T) as Interface value in assignment: T does not implement Interface (method M2 has pointer receiver) |
方法集合
Go 中自定义类型与接口之间是松耦合的:如果某个自定义类型 T 的方法集合是某个接口类型的方法集合的超集,那么就说类型 T 实现了该接口,并且类型 T 的变量可以被复制给该接口类型的变量。这也是我们所说的方法集合决定了接口实现。
+要判断一个自定义类型是否实现了某接口类型,首先要识别出自定义类型的方法集合和接口类型的方法集合。如下函数可以打印某个类型的方法集合:
+1 | package main |
这个示例很好地解释了 Go 语言的规范:
+-
+
- 对于非接口类型的自定义类型 T,其方法集合由所有 receiver 为 T 类型的方法组成 +
- 而类型
*T
的方法集合则包含所有 receiver 为 T 和 *T 类型的方法
+
所以在为 receiver 选择类型时需要考虑是否支持将 T 类型实例赋值给某个接口类型变量。如果需要支持,我们就要实现 receiver 为 T 类型的接口类型方法集合中的所有方法。
+类型嵌入与方法集合
Go 的设计哲学之一是偏好组合。Go 支持用组合的思想来实现一些面向对象领域经典的机制,比如继承。而具体方式就是类型嵌入。Go 支持以下嵌入:
+-
+
- 在接口类型中嵌入接口类型 +
- 在结构体类型中嵌入接口类型 +
- 在结构体类型中嵌入结构体类型 +
通过在接口类型中嵌入其他接口类型可以实现接口的组合,这是 Go 语言中基于已有接口类型构建新接口类型的惯用法。通过嵌入其他接口类型而创建的新接口类型的方法集合包含了被嵌入接口类型的方法集合。
+在结构体类型中嵌入接口类型后,该结构体类型的方法集合中将包含被嵌入接口类型的方法集合。例如:
+1 | package main |
嵌入了其他接口类型的结构体类型的实例在调用方法时,Go 选择方法的次序:
+-
+
- 优先选择结构体自身实现的方法 +
- 如果结构体自身并未实现,那么将查找结构体中的嵌入接口类型的方法集合中是否有该方法,如果有,则提升(promoted)为结构体的方法 +
- 如果结构体嵌入了多个接口类型且这些接口类型的方法集合存在交集,那么 Go 编译器将报错,除非结构体自己实现了交集中的所有方法 +
结构体类型在嵌入某接口类型的同时,也实现了这个接口。这一特性在单元测试中尤为有用。
+在结构体类型中嵌入结构体类型为 Gopher 提供了一种实现 继承
的手段。外部的结构体类型 T 可以 继承
嵌入的结构体类型的所有方法的实现,并且无论是 T 类型的变量实例还是 *T 类型的变量实例,都可以调用所有继承的方法。
1 | package main |
1 | # ./main |
从输出结果来看,无论是 T 类型的变量实例还是 *T 类型变量实例,都可以调用所有 继承
的方法。**但是 T 和 *T 类型的方法集合是有差别的**:
-
+
- T 类型的方法集合 = T1 的方法集合 + *T2 的方法集合 +
- *T 类型的方法集合 = *T1 的方法集合 + *T2 的方法集合 +
defined 类型的方法集合
Go 语言支持基于已有的类型创建新类型,例如:
+1 | type MyInterface I |
已有的类型(比如上面的 I、T)被称为 underlying 类型,而新类型称为 defined 类型。
+-
+
- 基于接口类型创建的 defined 类型与原接口类型的方法集合是一致的 +
- 而基于自定义非接口类型创建的 defined 类型则并没有继承
原类型
的方法集合,新的 defined 类型的方法集合是空的
+
方法集合决定接口实现,基于自定义非接口类型的 defined 类型的方法集合为空,这决定了即便原类型实现了某些接口,基于其创建的 defined 类型也没有继承这一隐式关联。新 defined 类型想要实现那些接口,仍然要重新实现接口的所有方法。
+类型别名的方法集合
Go 也支持类型别名,支持为已有类型定义别名。例如:
+1 | type MyInterface = I |
类型别名与原类型几乎等价。类型别名与原类型拥有完全相同的方法集合,无论原类型是接口类型还是非接口类型。
+了解变长参数函数的妙用
什么是变长参数函数
变长参数函数就是指调用时可以接受零个、一个或多个实际参数的函数。例如 Println 的原型:
+1 | func Println(a ...interface{}) (n int, err error) |
这种接受 ...T
类型形式参数的函数就称为 变长参数函数
。一个变长参数函数只能有一个 ...T
类型的形式参数,并且该形式参数应该为函数参数列表的最后一个形式参数。变长参数函数的 ...T
类型形式参数在函数体内呈现为 []T
类型的变量。
...T
类型形式参数可以匹配和接受的实参类型有两种:
-
+
- 多个 T 类型的变量 +
- t…(t 为 T 类型变量) +
只能选择上述两种实参类型中的一种:要么是多个 T 类型的变量,要么是 t...
(t 为 T 类型变量)。
1 | package main |
使用变长参数函数时,最容易出现的错误是实参与形参不匹配,例如如下例子:
+1 | func dump(args ...interface{}) { |
这里的变长参数类型为 ...interface{}
,因此匹配该形参的要么是 interface{}
类型变量,要么是 t...
(t 为 []interface{})。而这里传入 []string...
,并不匹配。这里需要注意的是,虽然 string 类型变量可以直接赋值给 interface{} 类型变量,但是 []string
类型变量并不能直接赋值给 []interface{}
类型变量。要消除该错误,只需要这样定义 s 即可:
1 | s := []interface{}{"Tony", "John", "Jim"} |
有一个例外是 Go 内置的 append 函数,支持通过如下方式将字符串附加到一个字节切片后面:
+1 | func main() { |
这里是因为编译器自动将 string 隐式转换为了 []byte
,如果是我们自定义的函数,是无法支持这种用法的。
模拟函数重载
Go 语言不允许在同一个作用域下定义名字相同但函数原型不同的函数,也就是说 Go 不支持类似于 C++ 中的函数重载机制。在 Go 的类型系统中,仅按照名称进行匹配并要求类型一致是一个主要的简化决策。
+但是在 Go 中我们也可以对 重载函数
进行模拟:
-
+
- 如果要重载的函数参数是相同类型,仅参数个数是变化的,那么变长参数函数就可以实现 +
- 如果参数类型不同且个数可变,那么还要结合
interface{}
类型的特性
+
如下是一个例子:
+1 | package main |
模拟实现函数的可选参数与默认参数
如果参数在传入时有隐式要求的固定顺序(这点由调用者保证),我们还可以利用变长参数函数模拟实现函数的可选参数和默认参数。
+如下是一个例子:
+1 | package main |
在这个函数中,city 和 country 的默认值是在 record 类型实例创建时被赋予的初值。实现这样一个 enroll 函数的前提是其调用方要负责正确的顺序传入参数并保证参数类型满足函数要求。当然这种 Go 实现的可选参数和默认参数是有局限的,调用者只能从右侧的参数开始逐一进行省略的处理。
+实现功能选项模式
在日常 Go 编程中,我们经常会实现一些带有设置选项的创建型函数,我们可以通过如下方式实现这个函数:
+-
+
- 版本 1:通过参数暴露配置选项,优点是能够快速实现,缺点则是该接口无法扩展 +
- 版本 2:通过结构体封装配置选项,这种方式也是目标比较常见的做法。 +
这里要介绍的是第三种方式,即功能选项模式,这种模式应该是目前进行功能选项设计的最佳实践。
+1 | package main |
使用功能选项模式的好处是:
+-
+
- 更漂亮的、不随时间变化的 API +
- 参数可读性更好 +
- 配置选项高度可扩展 +
- 提供使用默认选型的最佳方式 +
- 使用更安全(不像版本 2 那样在创建函数被调用后,调用者仍然可以修改 Options) +