函数式编程:如何高效简洁地对数据查询与变换

案例1

案例一,代码摘抄来自一企业培训材料,主要代码逻辑是打印每课成绩,并找出学生非F级别课程统计平均分数:

class CourseGrade {
    public String title;
    public char grade;
}

public class ReportCard {
    public String studentName;
    public ArrayList<CourseGrade> cliens;

    public void printReport() {
        System.out.println("Report card for " + studentName);
        System.out.println("------------------------");
        System.out.println("Course Title       Grade");
        Iterator<CourseGrade> grades = cliens.iterator();
        CourseGrade grade;
        double avg = 0.0d;
        while (grades.hasNext()) {
            grade = grades.next();
            System.out.println(grade.title + "    " + grade.grade);
            if (!(grade.grade == 'F')) {
                avg = avg + grade.grade - 64;
            }
        }
        avg = avg / cliens.size();
        System.out.println("------------------------");
        System.out.println("Grade Point Average = " + avg);
    }
}

上面的代码有哪些问题呢:

  • 成员变量采用public,缺少数据封装性
  • 没有判断cliens是否为空,可能除以0值。注:假定它不会为空,另外逻辑可能有问题,为什么统计总分是非F课程,除数却是所有课程Size,先忽略这个问题
  • avg这个变量多个用途,即是总分,又是平均分
  • cliens变量名难以理解
  • !(grade.grade == 'F') 有点反直觉
  • while循环干了两件事,打印每课的成绩,也统计了分数

培训材料并未给标准解题,尝试优化一下代码,采用Java8的Stream来简化计算过程,并对代码进行了分段:

public void printReport2() {
        System.out.println("Report card for " + studentName);
        System.out.println("------------------------");

        System.out.println("Course Title       Grade");
        cliens.forEach(it -> System.out.println(it.title + "    " + it.grade));

        double total = cliens.stream().filter(it -> it.grade != 'F')
                .mapToDouble(it -> it.grade - 64).sum();
        System.out.println("------------------------");
        System.out.println("Grade Point Average = "  + total / cliens.size());
    }

进一步优化,把各类打印抽取各自函数:

 private void printHeader() {
        System.out.println("Report card for " + studentName);
        System.out.println("------------------------");   
    }

    private void printGrade() {
        System.out.println("Course Title       Grade");
        cliens.forEach(it -> System.out.println(it.title + "    " + it.grade));
    }

    private void printAverage() {
        double total = cliens.stream().filter(it -> it.grade != 'F')
                .mapToDouble(it -> it.grade - 64).sum();
        System.out.println("------------------------");
        System.out.println("Grade Point Average = "  + total / cliens.size());
    }

    public void printReport3() {
        printHeader();
        printGrade();
        printAverage();
    }   

注:如果只算非F的平均分,可以一行搞定:

double avg = cliens.stream().filter(it -> it.grade != 'F').mapToDouble(it -> it.grade - 64).average().orElse(0.0d);

案例二:再看一段代码:

List<Integer> tanscationsIds = transcations.parallelStream()
        .filter(it -> it.getType() == Transcation.GROCERY)
        .sorted(comparing(Transcation::getValue).resersed())
        .map(Transcation::getId)
        .collect(Collectors::toList());

代码非常清晰:

  • 过滤出类型为GROCERY的交易记录
  • 按其value值进行倒排序
  • 各自取其Id字段
  • 输出Id列表

这看起来是不是像这样一条SQL语句:

select t.id from tanscations t where t.type == 'GROCERY' order by t.value desc

1.1 背后的知识

目前Java8已广泛使用,对于Stream与Lambda应习以为常了,而不再是一种炫技。网上也有非常多的教程,若有同学还不熟悉他们的用法,可以多找找材料熟悉一下。

Stream正如其名,像一条数据生产流水线,逐步叠加中间操作(算法和计算),把数据源转换为另一个数据集。

笔者很早以前学过C#,接触过LINQ(Language Integrated Query),它比Java的Stream和Lambda用法更为清晰简洁,先给个简单示例:

var result = db.ProScheme.OrderByDescending(p => p.rpId).Where(p => p.rpId > 10).ToList();

LINQ为数据查询而生,可以算是DSL(Domain Specific Language)了,背后也是函数式编程(FP)一套理念,先记住其中两点:

  • Monad 是一种设计模式,表示将一个运算过程,通过函数拆解成互相连接的多个步骤
  • Lambda表达式 是一个匿名函数,Lambda表达式基于数学中的λ演算得名


FP还有其它的特性:模式匹配,柯里化,偏函数,闭包,尾递归等。对FP感觉兴趣的同学不妨找找材料学习一下。

现在的主流语言,都引入一些FP特性来提升语言在数据上的表达能力。

C++11引入Lambda表达式,并提供<algorithm><functional>两个基础库,一个简单示例:

int foo[] = { 10, 20, 5, 15, 25 };
std::sort(foo, foo+5, [](int a,int b){return a > b;});

Python提供functools库来简化一些函数式编程(还是相当的弱),一个简单示例:

foo = ["A", "a", "b", "B"]
sorted(foo, key=functools.cmp_to_key(locale.strcoll))

2 函数式编程

当然,面向对象语言中增加lambda这类特征不能就称为函数式编程了,大部分只不过是语法糖。是采用什么编程范式不在于语言的语法,而是在于思维方式。

面向对象编程(OOP)在过去20多年非常成功,而函数式编程(FP)也不断地发展,他们相生相息,各自解决不同的场景问题:

  • 面向对象可以理解为是对数据的抽象,比如把一个事物抽象成一个对象,关注的是数据。
  • 函数式编程是一种过程抽象的思维,就是对当前的动作去进行抽象,关注的是动作。

现实业务需求往往体现为业务活动,它是面向过程的,即先输入数据源,在一定条件下,进行一系列的交互,再输出结果。那面向过程与函数式的的区别是什么:

  • 面向过程可以理解是把做事情的动作进行分解多个步骤,所以有if/while这类语法支撑,走不同的分支步骤。
  • 函数式相比面向过程式,它更加地强调执行结果而非执行过程,利用若干个简单的执行单元让计算结果不断渐近,逐层推导复杂的运算,而不是像面向过程设计出复杂的执行过程,所以纯函数式编程语言中不需要if/while这类语法,而是模式匹配,递归调用等。

面向对象的编程通过封装可变的部分来构造能够让人读懂的代码,函数式编程则是通过最大程度地减少可变的部分来构造出可让人读懂的代码。

我们从Java的Stream实现也看到函数式的另一个特点:

  • 函数不维护任何状态,上下文的数据是不变的,传入的参数据处理完成之后再扔出来。

结合上面的理解,我们可以先把世界事物通过OOP抽象为对象,再把事物间的联系与交互通过FP抽象为执行单元,这种结合或许是对业务活动的实现一种较好的解决方式。

3 避免单一范式

一提到编程范式,很容易联想到宗教的虔诚,每种宗教所表达信条都有一定合理性,但如果一直只遵循一种教条,可能也被让自己痛苦不堪。编程范式也是如此,正如Java在1.8之前是纯面向对象式,你就会觉得它非常繁琐。也如Erlang是纯函数式,你就会发现有时简单的逻辑处理会非常复杂。

近些年来,由于数据分析、科学计算和并行计算的兴起,让人认识到函数式编程解决数据领域的魅力,它也越来越受欢迎。在这些领域,程序往往比较容易用数据表达式来表达,采用函数式可以用很少代码来实现。

现实的业务软件,很多的逻辑其实也是对数据的处理,最简单是对数据的CURD,以及数据的组合、过滤与查询。所以函数式编程在许多语言中都得到支持,提升了对数据处理的表达能力。

了解新的编程范式在适当的时候使用它们,这会使你事半功倍。无论什么编程范式,他们都是工具,在你的工具箱中,可能有锤子,螺丝刀…,这个工具在什么时候使用,取决待解决的问题。

4 结语

本文的案例只是一个引子,主要是想给你带来函数式编程的一些理念,函数式给我们解决业务问题提供了另一种思维方式:如何高效简洁地对数据查询与变换。许多语言都支持函数式一些能力,需要我们不断地学习,在合理的场景下使用他们。

点击关注,第一时间了解华为云新鲜技术~

本文分享自华为云社区《飞哥讲代码16:函数式让数据处理更简洁》,原文作者:华为云专家。