Go 函数是“一等公民”的理解

九路 等级 204 0 0

函数(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 中的几种有用的函数式编程风格:柯里化、函子;
  • 不要为了符合特定风格而滥用函数特质。
预览图
收藏
评论区