Press "Enter" to skip to content

Go 函数式编程篇(六):引入 Map-Reduce-Filter 模式处理集合元素

在前面两篇教程中,学院君给大家介绍函数式编程中高阶函数递归函数等编程技术在 Go 语言中的实现,今天我们看另一个函数式编程技术 Map-Reduce 在 Go 语言中的使用。

从处理集合元素聊起

日常开发过程中,要处理数组、切片、字典等集合类型,常规做法都是循环迭代进行处理。比如将一个字典类型用户切片中的所有年龄属性值提取出来,然后求和,常规实现是通过循环遍历所有切片,然后从用户字典键值对中提取出年龄字段值,再依次进行累加,最后返回计算结果:

package main

import (
    "fmt"
    "strconv"
)

func ageSum(users []map[string]string) int {
    var sum int
    for _, user := range users {
        num, _ := strconv.Atoi(user["age"])
        sum += num
    }
    return sum
}

func main() {
    var users = []map[string]string{
        {
            "name": "张三",
            "age": "18",
        },
        {
            "name": "李四",
            "age": "22",
        },
        {
            "name": "王五",
            "age": "20",
        },
    }
    fmt.Printf("用户年龄累加结果: %d\n", ageSum(users))
}

执行上述代码,打印结果如下:

-w676

注:为了简化流程,这里忽略了程序出错的处理。

针对简单的单个场景,这么实现没什么问题,但这是典型的面向过程思维,而且代码几乎没有什么复用性可言:每次处理类似的问题都要编写同样的代码模板,比如计算其他字段值,或者修改类型转化逻辑,都要重新编写实现代码。

引入 Map-Reduce

在函数式编程中,我们可以通过 Map-Reduce 技术让这个功能实现变得更优雅,代码复用性更好。

Map-Reduce 并不是一个整体,而是要分两步实现:Map 和 Reduce,这个示例也正好符合 Map-Reduce 模型:先将字典类型切片转化为一个字符串类型切片(Map,字面意思就是一一映射),再将转化后的切片元素转化为整型后累加起来(Reduce,字面意思就是将多个集合元素通过迭代处理减少为一个)。

为此,我们先要实现 Map 映射转化函数:

func mapToString(items []map[string]string, f func(map[string]string) string) []string {
    newSlice := make([]string, len(items))
    for _, item := range items {
        newSlice = append(newSlice, f(item))
    }
    return newSlice
}

再编写 Reduce 求和函数:

func fieldSum(items []string, f func(string) int) int {
    var sum int
    for _, item := range items{
        sum += f(item)
    }
    return sum
}

通过 Map-Reduce 重构后没有什么硬编码,类型转化和字段获取逻辑都封装到两个函数支持的函数类型参数中实现了,在 main 函数中编写新的调用代码如下:

ageSlice := mapToString(users, func(user map[string]string) string {
    return user["age"]
})
sum := fieldSum(ageSlice, func(age string) int {
    intAge, _ := strconv.Atoi(age)
    return intAge
})
fmt.Printf("用户年龄累加结果: %d\n", sum)

计算结果和之前一样,看起来代码实现比之前的简单迭代更复杂了,但是代码复用性、可读性和后续可维护性更好,毕竟,对于长期维护的项目而言,业务代码不可能一次编写好就完事了。目前来看,只要是符合上述约定参数类型的切片数据,现在都可以通过这段代码来实现指定字段值的累加功能,并且支持自定义字段和数值类型转化逻辑。

当然了,Go 语言现在还不支持泛型,否则我们可以编写出抽象性更好的 Map-Reduce 代码,后面介绍完接口和反射部分后,我们再尝试在运行时通过泛型来重构这段代码的实现。

采用 Map-Reduce 技术编写类似的集合处理代码为我们引入了新的编程模式,将编程思维升级到描述一件事情要怎么干的高度,就像面向对象编程中引入设计模式那样,从而摆脱面向过程编程那种代码只是用来描述干什么,像记流水账一样的编程窠臼。

下面这张图非常形象地描述了 Map-Reduce 技术在函数式编程中扮演的角色和起到的作用:

引入 Filter 函数

有的时候,为了让 Map-Reduce 代码更加健壮(排除无效的字段值),或者只对指定范围的数据进行统计计算,还可以在 Map-Reduce 基础上引入 Filter(过滤器),对集合元素进行过滤。

我们在上面的代码中新增一个 Filter 函数:

func itemsFilter(items []map[string]string, f func(map[string]string) bool) []map[string]string {
    newSlice := make([]map[string]string, len(items))
    for _, item := range items {
        if f(item) {
            newSlice = append(newSlice, item)
        }
    }
    return newSlice
}

接下来,我们可以在 main 函数中应用 Filter 函数对无效用户年龄进行过滤,或者排除指定范围年龄:

func main() {
    var users = []map[string]string{
        {
            "name": "张三",
            "age": "18",
        },
        {
            "name": "李四",
            "age": "22",
        },
        {
            "name": "王五",
            "age": "20",
        },
        {
            "name": "赵六",
            "age": "-10",
        },
        {
            "name": "孙七",
            "age": "60",
        },
        {
            "name": "周八",
            "age": "10",
        },
    }
    //fmt.Printf("用户年龄累加结果: %d\n", ageSum(users))

    validUsers := itemsFilter(users, func(user map[string]string) bool {
        age, ok := user["age"]
        if !ok {
            return false
        }
        intAge, err := strconv.Atoi(age)
        if err != nil {
             return false
        }
        if intAge < 18 || intAge > 35 {
            return false
        }
        return true
    })
    ageSlice := mapToString(validUsers, func(user map[string]string) string {
        return user["age"]
    })
    sum := fieldSum(ageSlice, func(age string) int {
        intAge, _ := strconv.Atoi(age)
        return intAge
    })
    fmt.Printf("用户年龄累加结果: %d\n", sum)
}

上述代码的计算结果依然是 60,说明过滤器生效了。

不过分开调用 Map、Reduce、Filter 函数不太优雅,我们可以通过装饰器模式将它们层层嵌套起来,或者通过管道模式(Pipeline)让这个调用逻辑可读性更好,更优雅,下篇教程,学院君就来给大家演示管道模式在 Go 函数式编程中的实现。

本教程源码可以在 Github 代码仓库获取:nonfu/golang-tutorial

7 Comments

  1. 冰糕不冰
    冰糕不冰 2021年2月10日

    itemsFilter 函数 newSlice := make([]map[string]string, len(items)) 应该改为:
    newSlice := make([]map[string]string, 0,len(items))

      • KevinJiao
        KevinJiao 2021年4月22日

        为切分类型分配和初始化时,make的第二个参数是表示长度吧,学院君?

        • 学院君
          学院君 2021年11月26日

          是的 第二个参数表示长度 不传容量的话 默认容量和长度一样 如果需要指定容量 通过第三个参数传入 容量值不能小长度值 我表述有误

发表回复