Go 函数式编程

Go 函数式编程

函数式编程的一个重要特性是「函数是第一等公民」,本文以 Go 为例解释这个概念,之后介绍高阶函数、匿名函数、闭包等概念,最后介绍一些标准库和项目中的实践例子。

函数是第一等公民

先看一个普通变量赋值的例子

v := 100
fmt.Printf("value %v type %T\n", v, v)

输出

value 100 type int

如果我们将变量改为函数呢,比如

fn := func(x int) int { return x * x }
fmt.Printf("value %v type %T\n", fn, fn)

输出

value 0x10a8da0 type func(int) int

这里说明函数也可以赋值给变量。

所谓「函数是第一等公民」,也就是函数与其他数据类型一样,处于平等地位,可以

  • 赋值给同类型变量
  • 作为入参传递给函数
  • 作为函数的返回结果

函数作为入参传递给函数

当函数的主要逻辑固定,但是某个子逻辑需要自定义的时候,这个特性就比较方便了。

比如现在有一个画图的逻辑,包含如下步骤

  1. 准备图纸、画笔、颜料
  2. 画具体的对象
  3. 图纸晾干

其中第 2 步会随着画画的主题不同产生变化,这时就可以将画对象的逻辑作为函数传递到主逻辑函数中。

主逻辑函数 Draw()

func Draw(drawObjFns ...func()) {
    fmt.Println("prepare paper, brush, pigment")
    for _, drawObjFn := range drawObjFns {
        drawObjFn()
    }
    fmt.Println("dry the paper")
}

怎么使用

func drawObjRose() { fmt.Println("draw obj rose") }
func drawObjLily() { fmt.Println("draw obj lily") }

func main() {
    Draw(drawObjRose, drawObjLily)
}

输出

prepare paper, brush, pigment
draw obj rose
draw obj lily
dry the paper

可以看到 Draw() 的主逻辑不变,只要传入不同的 drawObjXxx 函数就能画出不同的画。

函数作为函数的返回结果

在对主逻辑进行定制后,有时还需要对定制的逻辑进行重复调用,这时可以封装主逻辑将其作为函数返回。比如

func DrawFn(drawObjFns ...func()) func() {
    fn := func() { draw(drawObjFns...) }
    return fn
}

func main() {
    flowerDrawFn := DrawFn(drawObjRose, drawObjLily)
    flowerDrawFn()
}

DrawFn() 是对 Draw() 的一个封装,将定制后的主逻辑作为函数返回。入口方法中,调用 DrawFn() 定制了一个以花为主题的主逻辑 flowerDrawFn(),之后要画以花为主题的画的时候,直接调用 flowerDrawFn() 即可。

高阶函数

当有函数作为入参传入函数 A,或者函数 A 要返回函数时,A 就可以看作一个高阶函数,比如前面例子中的 Draw()、DrawFn()。高阶函数的意义就是抽象通用的问题,屏蔽细节,具体可以结合前面的两个例子思考下。

匿名函数

所谓匿名函数,即没有名称的函数,比如

func() { draw(drawObjFns...) }

就定义了个匿名函数。当然,也是可以直接调用的

func() { draw(drawObjFns...) }()

前面例子中定义了这样一个函数

func DrawFn(drawObjFns ...func()) func() {
    fn := func() { draw(drawObjFns...) }
    return fn
}

可以使用匿名函数改写为

func DrawFn(drawObjFns ...func()) func() {
    return func() { draw(drawObjFns...) }
}

闭包

闭包是携带状态的函数,通过闭包可以调用一个函数内部的函数并访问到前者作用域中的变量。举个例子

func GetIdGenerator() func() int {
    id := 0
    return func() int {
        id++
        return id
    }
}

func main() {
    idGenerator := GetIdGenerator()
    fmt.Print(idGenerator(), " ")
    fmt.Print(idGenerator(), " ")
    fmt.Println()
    idGenerator2 := GetIdGenerator()
    fmt.Print(idGenerator2(), " ")
    fmt.Print(idGenerator2(), " ")
}

函数 GetIdGenerator() 返回了一个闭包,该闭包携带外层函数中 id 这个状态,调用这个闭包的时候可以访问修改它。所以执行后有如下输出

1 2 
1 2 

实践

标准库中的实践

先看一个标准库中的实践,比如 sort.Slice()

func Slice(slice interface{}, less func(i, j int) bool)

其中

  • 第一个参数传入的是待排序的切片
  • 第二个参数传入一个函数用于控制顺序,如果第 i 个元素应该排在第 j 个的前面,less() 应该返回 true,否则返回 false

sort.Slice() 执行时,排序的主逻辑固定不变,只是在需要确定 2 个元素的前后位置时调用 less() 进行确认。

如果需要对字符串切片按照字符串长度进行排序,可以这样

func main() {
    s := []string{"apple", "pear", "banana"}
    sort.Slice(s, func(i, j int) bool {
        return len(s[i]) < len(s[j])
    })
    fmt.Println(s)
}

执行后,s 有序,输出内容如下

[pear apple banana]

项目中的实践

再看一个项目中的实践。最近碰到这样的情况,很多地方都需要记录修改日志,生成日志内容的逻辑比较麻烦,主要有几个原因

  • 字段类型有简单的,比如整型、布尔型等,也有复合型的,比如切片、映射、结构体等
  • 不同场景对空的判定不一致
  • 不同场景相同字段值表示不同意思

由于以上原因,目前项目中的修改日志的生成基本上是不同场景独立编码的,这样存在代码冗余,不好维护的问题。

其实,仔细想想不管什么场景,生成修改日志的本质是一样的,就是比较前后的值,如果非空到非空,那么是修改;如果非空到空或者反过来,就是删除或者新增。然后就是日志格式的控制,注意细节,统一下就行。这一块是生成日志的主要逻辑。

而那些不确定的地方,比如空的判定、相等的判定、字段值怎么展示等,可以要求调用方传入函数进行定制。当然,项目中可以预定义一些通用的函数,比如整型、字符串判空,切片判定相等等,避免在各个场景中重复定义相同逻辑的函数。

通过上面的分析可以得到日志生成的主要方法 GenModifyLogRow()

// GenModifyLogRow 生成修改日志中的一行
// 比如修改判责结果会有以下三种情况
// 如果是非空改为非空,则内容为 判责结果:【查实】修改为【虚假】
// 如果是空改为非空,则内容为  判责结果:新增【虚假】
// 如果是非空改为空,则内容为  判责结果:删除【虚假】
func GenModifyLogRow(arg *GenModifyLogRowArg) (string, error) {
    // ...
}

其参数结构体如下

type GenModifyLogRowArg struct {
    FieldName        string           // 修改字段的描述信息
    OldValue         interface{}      // 旧值
    NewValue         interface{}      // 新值
    IsValueEmptyFunc returnBoolFunc   // 判定值是否为空
    IsValueEqualFunc returnBoolFunc2  // 判定值是否相等
    ValueToNameFunc  returnStringFunc // 将值转为描述信息
}

type returnBoolFunc func(v interface{}) bool
type returnBoolFunc2 func(v1 interface{}, v2 interface{}) bool
type returnStringFunc func(v interface{}) string

使用方式如下

row, _ := GenModifyLogRow(&GenModifyLogRowArg{
    FieldName:        "判责结果",
    OldValue:         taskInfo.ResultCode,
    NewValue:         req.ResultCode,
    IsValueEmptyFunc: com.IsIntEmpty,
    IsValueEqualFunc: com.IsEqual,
    ValueToNameFunc: func(v interface{}) string {
        return constsTicket.ResultCodeMap[v.(int)]
    },
})

这里只处理了一个字段修改日志的生成,通常一次修改可能涉及多个字段,这个根据业务场景逐个生成就行,这里不作过多说明。

这样一来,我们就统一了修改日志的生成逻辑,并且定制灵活,有很强的扩展性。

注意:为便于描述,上面的方法和结构体我做了一些删改,直接复制到本地是不能运行的,大家理解思路就行。

小结

「函数是第一等公民」是函数式编程的一个重要特性,包含三层意思,函数和其他数据类型一样,处于平等地位,可以

  • 赋值给同类型变量
  • 作为入参传递给函数
  • 作为函数的返回结果

之后又过了下高阶函数、匿名函数、闭包等常见概念。

最后介绍了函数式编程在标准库以及项目中的一些实践,合理的运用这种编程模式可以增加代码的复用性、扩展性,提高开发效率。

Comments are closed.