函数(function)作为现代编程语言的基本语法元素存在于支持各种范式(paradigm)的主流编程语言当中。无论是命令式语言 C、多范式通用编程语言 C++,还是面向对象编程语言 Java、Ruby,亦或是函数式语言 Haskell、动态脚本语言 Python、PHP、JavaScript,函数这一语法元素都是当仁不让的核心。
Go 语言以“成为新一代系统级语言”为目标而诞生,但在演化过程中,逐渐演化成了面向并发、契合现代硬件发展趋势的通用编程语言。Go 语言中没有那些典型的面向对象语言的语法,比如类、继承、对象等。Go 语言中的方法(method)本质上亦是函数的一个“变种”。因此,在 Go 语言中,函数是唯一一种基于特定输入、实现特定任务并可反馈任务执行结果的代码块。本质上我们可以说 Go 程序就是一组函数的集合。
和其他编程语言中的函数相比,Go 语言的函数具有如下特点:
- 以“func”关键字开头;
- 支持多返回值;
- 支持具名返回值;
- 支持递归调用;
- 支持同类型的可变参数;
- 支持 defer,实现函数优雅返回
更为关键的是函数在 Go 语言中属于“一等公民(first-class citizen)”。众所周知,并不是在所有编程语言中函数都是“一等公民”,本节中我就和大家一起来看看成为”一等公民“的函数都有哪些特质可以帮助我们写出优雅简洁的代码。
1. 什么是“一等公民”
关于什么是编程语言的“一等公民”,业界并没有教科书给出精准的定义。这里引用一下 wiki 发明人、C2 站点作者沃德·坎宁安(Ward Cunningham)对“一等公民”的诠释:
如果一门编程语言对某种语言元素的创建和使用没有限制,我们可以像对待值(value)一样对待这种语法元素,那么我们就称这种语法元素是这门编程语言的“一等公民”。拥有“一等公民”待遇的语法元素可以存储在变量中,可以作为函数传递给函数,可以在函数内部创建并可以作为返回值从函数返回。在动态类型语言中,语言运行时还支持对“一等公民”类型的检查。
基于上面关于“一等公民”的诠释,我们来看看 Go 语言的函数是如何满足上述条件而成为“一等公民”的。
1.1 正常创建
我们可以在源码顶层正常创建一个函数,如下面的函数 newPrinter:
// $GOROOT/src/fmt/print.go
func newPrinter() *pp {
p := ppFree.Get().(*pp)
p.panicking = false
p.erroring = false
p.wrapErrs = false
p.fmt.init(&p.buf)
return p
}
1.2 在函数内创建
在 Go 语言中,我们可以在函数内定义一个新函数,如下面代码中在 hexdumpWords 函数内部定义的匿名函数(被赋值给变量 p1)。在 C/C++中我们无法实现这一点,这也是 C/C++语言中函数不是“一等公民”的原因之一。
// $GOROOT/src/runtime/print.go
func hexdumpWords(p, end uintptr, mark func(uintptr) byte) {
p1 := func(x uintptr) {
var buf [2 * sys.PtrSize]byte
for i := len(buf) - 1; i >= 0; i-- {
if x&0xF < 10 {
buf[i] = byte(x&0xF) + '0'
} else {
buf[i] = byte(x&0xF) - 10 + 'a'
}
x >>= 4
}
gwrite(buf[:])
}
... ...
}
1.3 作为类型
我们可以使用函数来自定义类型,如下面代码中的 HandlerFunc、visitFunc 和 action:
// $GOROOT/src/net/http/server.go
type HandlerFunc func(ResponseWriter, *Request)
// $GOROOT/src/sort/genzfunc.go
type visitFunc func(ast.Node) ast.Visitor
// codewalk: https://tip.golang.org/doc/codewalk/functions/
type action func(current score) (result score, turnIsOver bool)
1.4 存储到变量中
我们可以将定义好的函数存储到一个变量中,如下面代码中的 apply:
// $GOROOT/src/runtime/vdso_linux.go
func vdsoParseSymbols(info *vdsoInfo, version int32) {
if !info.valid {
return
}
apply := func(symIndex uint32, k vdsoSymbolKey) bool {
sym := &info.symtab[symIndex]
typ := _ELF_ST_TYPE(sym.st_info)
bind := _ELF_ST_BIND(sym.st_info)
... ...
*k.ptr = info.loadOffset + uintptr(sym.st_value)
return true
}
... ...
}
1.5 作为参数传入函数
我们可以将函数作为参数传入函数,比如下面代码中函数 AfterFunc 的参数 f:
$GOROOT/src/time/sleep.go
func AfterFunc(d Duration, f func()) *Timer {
t := &Timer{
r: runtimeTimer{
when: when(d),
f: goFunc,
arg: f,
},
}
startTimer(&t.r)
return t
}
1.6 作为返回值从函数返回
函数还可以被作为返回值从函数返回,如下面代码中函数 makeCutsetFunc 的返回值就是一个函数:
// $GOROOT/src/strings/strings.go
func makeCutsetFunc(cutset string) func(rune) bool {
if len(cutset) == 1 && cutset[0] < utf8.RuneSelf {
return func(r rune) bool {
return r == rune(cutset[0])
}
}
if as, isASCII := makeASCIISet(cutset); isASCII {
return func(r rune) bool {
return r < utf8.RuneSelf && as.contains(byte(r))
}
}
return func(r rune) bool { return IndexRune(cutset, r) >= 0 }
}
我们看到:就像沃德·坎宁安(Ward Cunningham)对“一等公民”的诠释中所说的那样,Go 中的函数可以像普通整型值那样被创建和使用。
除了上面那些例子,函数还可以被放入数组/切片/map 等结构中、可以像其他类型变量一样被赋值给 interface{}、甚至我们可以建立元素为函数的 channel,如下面例子:
// function_as_first_class_citizen_1.go
package main
import "fmt"
type binaryCalcFunc func(int, int) int
func main() {
var i interface{} = binaryCalcFunc(func(x, y int) int { return x + y })
c := make(chan func(int, int) int, 10)
fns := []binaryCalcFunc{
func(x, y int) int { return x + y },
func(x, y int) int { return x - y },
func(x, y int) int { return x * y },
func(x, y int) int { return x / y },
func(x, y int) int { return x % y },
}
c <- func(x, y int) int {
return x * y
}
fmt.Println(fns[0](5, 6))
f := <-c
fmt.Println(f(7, 10))
v, ok := i.(binaryCalcFunc)
if !ok {
fmt.Println("type assertion error")
return
}
fmt.Println(v(17, 7))
}
和 C/C++这类语言相比,作为“一等公民”的 Go 函数拥有难得的"灵活性"。接下来我们需要考虑如何使用 Go 函数才能发挥出它作为“一等公民”的最大效用。
2. 函数作为“一等公民”的特殊运用
1). 像整型变量那样对函数进行显式转型
Go 是类型安全的语言,Go 语言不允许隐式类型转换,因此下面的代码是无法通过编译的:
var a int = 5
var b int32 = 6
fmt.Println(a + b) // 违法操作: a + b (不匹配的类型int和int32)
我们必须通过对上面代码进行显式的转型才能通过编译器的检查:
var a int = 5
var b int32 = 6
fmt.Println(a + int(b)) // ok。输出11
函数是“一等公民”,对整型变量进行的操作也同样可以用在函数上面,即函数也可以被显式转型,并且这样的转型在特定的领域具有奇妙的作用。一个最为典型的示例就是 http.HandlerFunc 这个类型,我们来看一下例子:
// function_as_first_class_citizen_2.go
package main
import (
"fmt"
"net/http"
)
func greeting(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Welcome, Gopher!\n")
}
func main() {
http.ListenAndServe(":8080", http.HandlerFunc(greeting))
}
上述代码是我们日常最为常见的一个用 Go 构建的 Web Server 的例子。其工作机制很简单,当用户通过浏览器或类似 curl 这样的命令行工具访问 Web server 的 8080 端口时,会收到“Welcome, Gopher!”一行文字版应答。很多 Gopher 可能并未真正深入分析过这段代码,这里用到的恰恰是函数作为“一等公民”的特性,我们来看一下。
我们先来看一下 ListenAndServe 的源码:
// $GOROOT/src/net/http/server.go
func ListenAndServe(addr string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
}
ListenAndServe 会将来自客户端的 http 请求交给其第二个参数 handler 处理,而这里 handler 参数的类型 http.Handler 接口:
// $GOROOT/src/net/http/server.go
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
该接口仅有一个方法:ServeHTTP,其原型为:func(http.ResponseWriter, *http.Request)。这与我们自己定义的 http 请求处理函数 greeting 的原型是一致的。但是我们没法直接将 greeting 作为参数值传入,否则会报下面错误:
func(http.ResponseWriter, *http.Request) does not implement http.Handler (missing ServeHTTP method)
即函数 greeting 并未实现接口 Handler 的方法,无法将其赋值给 Handler 类型的参数。
在代码中我们也并未直接将 greeting 传入 ListenAndServe,而是将 http.HandlerFunc(greeting)作为参数传给了 ListenAndServe。我们来看看 http.HandlerFunc 是什么?
// $GOROOT/src/net/http/server.go
type HandlerFunc func(ResponseWriter, *Request)
// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
HandlerFunc 其实就是一个基于函数定义的新类型,它的底层类型为 func(ResponseWriter, *Request)。该类型有一个方法 ServeHTTP,继而实现了 Handler 接口。也就是说 http.HandlerFunc(greeting)这句代码的真正含义是将函数 greeting 显式转换为 HandlerFunc 类型,后者实现了 Handler 接口,满足 ListenAndServe 函数第二个参数的要求。
另外,之所以 http.HandlerFunc(greeting)这个语句可以通过编译器检查,正是因为 HandlerFunc 的底层类型正是 func(ResponseWriter, *Request),与 greeting 的原型是一致的。这和下面整型变量的转型原理并无二致:
type MyInt int
var x int = 5
y := MyInt(x) // MyInt的底层类型为int,类比 HandlerFunc的底层类型为func(ResponseWriter, *Request)
为了充分理解这种显式转型的“技巧”,我们再来看一个简化后的例子:
// function_as_first_class_citizen_3.go
package main
import "fmt"
type BinaryAdder interface {
Add(int, int) int
}
type MyAdderFunc func(int, int) int
func (f MyAdderFunc) Add(x, y int) int {
return f(x, y)
}
func MyAdd(x, y int) int {
return x + y
}
func main() {
var i BinaryAdder = MyAdderFunc(MyAdd)
fmt.Println(i.Add(5, 6))
}
和 Web server 那个例子一样,我们想将 MyAdd 这个函数赋值给 BinaryAdder 这个接口,直接赋值是不行的,我们需要一个底层函数类型与 MyAdd 一致的自定义类型的显式转换,这个自定义类型就是 MyAdderFunc,该类型实现了 BinaryAdder 接口,这样在经过 MyAdderFunc 的显式转型后,MyAdd 被赋值给了 BinaryAdder 的变量 i。这样通过 i 调用的 Add 方法实质上就是我们的 MyAdd 函数。
2) 函数式编程
Go 语言演进到如今,对多种编程范式或多或少都有支持。比如:对函数式编程的支持就得意于函数是“一等公民”的特质。虽然 Go 不推崇函数式编程,但有些时候应用一些函数式编程风格可以写出更加优雅、更简洁、更易维护的代码。
柯里化函数
我们先来看一种函数式编程的典型应用:柯里化函数(currying)。在计算机科学中,柯里化是把接受多个参数的函数变换成接受一个单一参数(原函数的第一个参数)的函数,并且返回接受余下的参数和返回结果的新函数的技术。这个技术以逻辑学家 Haskell Curry 命名。
定义总是拗口难懂,我们来用 Go 编写一个直观的柯里化函数的例子:
// function_as_first_class_citizen_4.go
package main
import "fmt"
func times(x, y int) int {
return x * y
}
func partialTimes(x int) func(int) int {
return func(y int) int {
return times(x, y)
}
}
func main() {
timesTwo := partialTimes(2)
timesThree := partialTimes(3)
timesFour := partialTimes(4)
fmt.Println(timesTwo(5))
fmt.Println(timesThree(5))
fmt.Println(timesFour(5))
}
运行这个例子:
$ go run function_as_first_class_citizen_4.go
10
15
20
这里的柯里化是指将原接受两个参数的函数 times 转换为接受一个参数的 partialTimes 的过程。通过 partialTimes 函数构造的 timesTwo 将输入参数扩大为原先 2 倍、timesThree 将输入参数扩大为原先的 3 倍…。
这个例子利用了函数的几点性质:
- 在函数中定义,通过返回值返回
- 闭包
闭包是前面没有提到的 Go 函数支持的一个特性。 闭包是在函数内部定义的匿名函数,并且允许该匿名函数访问定义它的外部函数的作用域。本质上,闭包是将函数内部和函数外部连接起来的桥梁。
以上述示例来说,partialTimes 内部定义的匿名函数就是一个闭包,该匿名函数访问了其外部函数(partialTimes)的变量 x。这样当调用 partialTimes(2)时,partialTimes 实际上返回一个调用 times(2,y)的函数:
timesTwo = func(y int) int {
return times(2, y)
}
函子(Functor)
函数式编程范式最让人”望而却步“的就是首先要了解一些抽象概念,比如上面的柯里化,再比如这里的函子(Functor)。什么是函子呢?具体来说,成为函子需要两个条件:
- 函子本身是一个容器类型,以 Go 语言为例,这个容器可以是切片、map 甚至是 channel;
- 光是容器还不够,该容器类型还需要实现一个方法,该方法接受一个函数类型参数,并在容器的每个元素上应用那个函数,得到一个新的函子,原函子容器内部的元素值不受到影响
我们还是用一个具体的示例来直观看一下吧:
// function_as_first_class_citizen_5.go
package main
import (
"fmt"
)
type IntSliceFunctor interface {
Fmap(fn func(int) int) IntSliceFunctor
}
type intSliceFunctorImpl struct {
ints []int
}
func (isf intSliceFunctorImpl) Fmap(fn func(int) int) IntSliceFunctor {
newInts := make([]int, len(isf.ints))
for i, elt := range isf.ints {
retInt := fn(elt)
newInts[i] = retInt
}
return intSliceFunctorImpl{ints: newInts}
}
func NewIntSliceFunctor(slice []int) IntSliceFunctor {
return intSliceFunctorImpl{ints: slice}
}
func main() {
// 原切片
intSlice := []int{1, 2, 3, 4}
fmt.Printf("init a functor from int slice: %#v\n", intSlice)
f := NewIntSliceFunctor(intSlice)
fmt.Printf("original functor: %+v\n", f)
mapperFunc1 := func(i int) int {
return i + 10
}
mapped1 := f.Fmap(mapperFunc1)
fmt.Printf("mapped functor1: %+v\n", mapped1)
mapperFunc2 := func(i int) int {
return i * 3
}
mapped2 := mapped1.Fmap(mapperFunc2)
fmt.Printf("mapped functor2: %+v\n", mapped2)
fmt.Printf("original functor: %+v\n", f) // 原functor没有改变
fmt.Printf("composite functor: %+v\n", f.Fmap(mapperFunc1).Fmap(mapperFunc2))
}
运行这段代码:
$ go run function_as_first_class_citizen_5.go
init a functor from int slice: []int{1, 2, 3, 4}
original functor: {ints:[1 2 3 4]}
mapped functor1: {ints:[11 12 13 14]}
mapped functor2: {ints:[33 36 39 42]}
original functor: {ints:[1 2 3 4]}
composite functor: {ints:[33 36 39 42]}
分析这段代码:
- 这里我们定义了一个 intSliceFunctorImpl 类型,用来作为 functor 的载体;
- 我们把 functor 要实现的方法命名为 Fmap,intSliceFunctorImpl 类型实现了该方法。同时该方法也是 IntSliceFunctor 接口的唯一方法;可以看到在这个代码中真正的 functor 其实是 IntSliceFunctor,这符合 Go 的惯用法;
- 我们定义了创建 IntSliceFunctor 的函数:NewIntSliceFunctor。通过该函数以及一个初始切片,我们可以实例化一个 functor;
- 我们在 main 中定义了两个转换函数,并将这两个函数应用到上述 functor 实例;我们看到得到的新 functor 的内部容器元素值是在原容器的元素值经由转换函数转换后得到的;
- 在最后,我们还可以对最初的 functor 实例连续(组合)应用转换函数,这让我们想到了数学课程中的函数组合;
- 无论如何应用转换函数,原 functor 中容器内的元素值不受到影响。
functor 非常适合对容器集合元素做批量同构处理,而且代码也要比每次都对容器中的元素作循环处理要优雅简洁许多。但要想在 Go 中发挥 functor 的最大效能,还需要 Go 对泛型提供支持,否则我们就需要为每一种容器类型都实现一套对应的 Functor 机制。比如上面的示例仅支持元素类型为 int 的切片,如果元素类型换为 string 或元素类型依然为 int,但容器类型换为 map,我们还需要分别为之编写新的配套代码。
延续传递式(Continuation-passing Style)
函数式编程离不开递归,以求阶乘函数为例,我们可以轻易用递归方法写出一个实现:
// function_as_first_class_citizen_6.go
func factorial(n int) int {
if n == 1 {
return 1
} else {
return n * factorial(n-1)
}
}
func main() {
fmt.Printf("%d\n", factorial(5))
}
这是一个非常常规的求阶乘的实现思路,但是这种思路并未应用到函数作为”一等公民“的任何特质。函数式编程有一种被称为延续传递式(Continuation-passing Style,以下简称为 CPS)的编程风格可以充分运用函数作为”一等公民“的特质。
在 CPS 风格中,函数是不允许有返回值的。一个函数 A 应该将其想返回的值显式传给一个 continuation 函数(一般接受一个参数),而这个 continuation 函数自身是函数 A 的一个参数。概念太过抽象,我们用一个简单的例子来说明一下:
下面得 Max 函数的功能是返回两个参数值中较大的那个值:
// function_as_first_class_citizen_7.go
package main
import "fmt"
func Max(n int, m int) int {
if n > m {
return n
} else {
return m
}
}
func main() {
fmt.Printf("%d\n", Max(5, 6))
}
我们把 Max 函数看作是上面定义中的 A 函数在未 CPS 化之前的状态。接下来,我们来根据 CPS 的定义将其转换为 CPS 风格:
- 首先我们去掉 Max 函数的返回值,并为其添加一个函数类型的参数 f(这个 f 就是定义中的 continuation 函数)
func Max(n int, m int, f func(int))
将返回结果传给 continuation 函数,即把 return 语句替换为对 f 函数的调用
func Max(n int, m int, f func(int)) {
if n > m {
f(n)
} else {
f(m)
}
}
完整的转换后的代码如下:
// function_as_first_class_citizen_8.go
package main
import "fmt"
func Max(n int, m int, f func(y int)) {
if n > m {
f(n)
} else {
f(m)
}
}
func main() {
Max(5, 6, func(y int) { fmt.Printf("%d\n", y) })
}
接下来,我们使用同样的方法将上面的阶乘实现转换为 CPS 风格。
- 首先我们去掉 factorial 函数的返回值,并为其添加一个函数类型的参数 f(这个 f 也就是 CPS 定义中的 continuation 函数)
func factorial(n int, f func(y int))
- 接下来,将 factorial 实现中的返回结果传给 continuation 函数,即把 return 语句替换为对 f 函数的调用
func factorial(n int, f func(int)) {
if n == 1 {
f(n)
} else {
factorial(n-1, func(y int) { f(n * y) })
}
}
由于原 else 分支有递归,因此我们需要把未完成的计算过程封装为一个新的函数 f’作为 factorial 递归调用的第二个参数,f’的参数 y 即为原 factorial(n-1)的计算结果,而 n * y 是要传递给 f 的,于是 f’这个函数的定义就为:func(y int) { f(n * y) }。
转换为 CPS 风格的阶乘函数的完整代码如下:
// function_as_first_class_citizen_9.go
package main
import "fmt"
func factorial(n int, f func(int)) {
if n == 1 {
f(1) //基本情况
} else {
factorial(n-1, func(y int) { f(n * y) })
}
}
func main() {
factorial(5, func(y int) { fmt.Printf("%d\n", y) })
}
我们简单解析一下上述实例代码的执行过程(下面用伪代码阐释):
f1 = func(y int) { fmt.Printf("%v\n", y)
factorial(5, f1)
f2 = func(y int) {f1(5 * y)}
factorial(4, f2)
f3 = func(y int) {f2(4 * y)}
factorial(3, f3)
f4 = func(y int) {f3(3 * y)}
factorial(2, f4)
f5 = func(y int) {f4(2 * y)}
factorial(1, f5)
f5(1)
=>
f5(1)
= f4(2 * 1)
= f3(3 * 2 * 1)
= f2(4 * 3 * 2 * 1)
= f1(5 * 4 * 3 * 2 * 1)
= 120
读到这里很多朋友会提出心中的疑问:这种 CPS 风格虽然利用了函数作为”一等公民“的特质,但是其代码理解起来颇为困难,这种风格真的好吗?朋友们的担心是有道理的。这里对 CPS 风格的讲解其实是一个”反例“,目的就是告诉大家,尽管作为”一等公民“的函数给 Go 带来的强大的表达能力,但是如果选择了不适合的风格或者说为了函数式而进行函数式编程,那么就会出现代码难于理解,且代码执行效率不高的情况(CPS 需要语言支持尾递归优化,但 Go 目前并不支持)。
3. 小结
成为”一等公民“的函数极大增强了 Go 语言的表现力,我们可以像对待值变量那样对待函数,上述函数编程思想的运用就得益于此。
让自己习惯于函数是”一等公民“,请牢记本节要点:
- Go 函数可以像变量值那样被赋值给变量、作为参数传递、作为返回值返回和在函数内部创建等;
- 函数可以像变量那样被显式转型;
- 基于函数特质,了解 Go 中的几种有用的函数式编程风格:柯里化、函子;
- 不要为了符合特定风格而滥用函数特质。