编程范式(七)--- 函数式编程

41 阅读11分钟

函数式编程

认识函数式编程

先从一个例子演进认识函数式编程:

// 单个学生的定义
class Student {
  // 实体 ID
  private long id;
  // 学生姓名
  private String name;
  // 学号
  private long sno;
  // 年龄
  private long age;
}

// 一组学生的定义
class Students {
  private List<Student> students
}
  1. 需要按照姓名找出其中一个学生

    Student findByName(final String name) {
      for (Student student : students) {
        if (name.equals(student.getName())) {
          return student;
        }
      }
      return null;
    }
    
  2. 新需求,按照学号来找人

    Student findBySno(final long sno) {
      for (Student student : students) {
        if (sno == student.getSno()) {
          return student;
        }
      }
      return null;
    }
    
  3. .....

这三段代码,除了查询的条件不一样,剩下的结构几乎一模一样,这就是一种重复。消除这个重复呢,可以引入查询条件这个概念,返回一个真假值

interface Predicate<T> {
  boolean test(T t);
}

Student find(Predicate<Student> predicate) {
  for (Student student : students) {
    if (predicate.test(student)) {
      return student;
    }
  }
  return null;
}

Student findByName(final String name) {
  return find((stu)->name.equals(student.getName());
}

有了新的查询,只需要传入不同的判断条件即可,也可以做一层封装,把查询条件做成一个方法

static Predicate<Student> byName(final String name) {
  return stu -> name.equals(student.getName();
}
                            
find(byName(name));
find(bySno(sno));
...

甚至,如果有组合查询,只需要组合多个查询条件即可,但是并不是byNameAndSno这样,而是将多个查询条件组合成一个条件,像这样:

find(and(byName(name), bySno(sno)));

static <T> Predicate<T> and(final Predicate<T>... predicates) {
    return t -> {
      for (Predicate<T> predicate : predicates) {
                if (!predicate.test(t)) {
                    return false;
                }
            }
            return true;
    };
}

这样,以后需求改动,无论是什么条件,都可以按需要任意组合了。

虽然这里都是常规的Java代码(lamdba展开写也可以),只是提供了各种基本的元素,但是通过组合这些元素可以完成真正的需求。这种做法完全不同于常规的面向对象的做法,其背后的思想就源自函数式编程。在上面这个例子里面,让代码产生质变的地方就在于Predicate的引入,而它实际上就是一个函数。

函数式编程是什么

函数式编程是一种编程范式,它提供给我们的编程元素就是函数。只不过,这个函数是来源于数学的函数,有着不变性,无副作用等特性。

函数式编程第一个需要了解的概念就是函数。在函数式编程中,函数是一等公民(first-class citizen)。一等公民是什么意思呢?

  • 它可以按需创建;
  • 它可以存储在数据结构中;
  • 它可以当作实参传给另一个函数;
  • 它可以当作另一个函数的返回值。

对象,是面向对象程序设计语言的一等公民,它就满足所有上面的这些条件。在函数式编程语言里,函数就是一等公民。

很多语言虽然不把自己归入函数式编程语言,但它们也提供了函数式编程的支持,比如支持了Lambda的Java8,Ruby,JavaScript等。Lambda这个词在函数式编程中经常出现,你可以简单地把它理解成匿名函数

当然,如果语言没有这种一等公民的函数支持,完全可以用某种方式模拟出来。比如上述的例子,就用对象模拟了一个函数。例子中,既然函数是用对象模拟出来的,自然就符合一等公民的定义,可以方便将其传来传去。

现在越来越多的语言加入了对函数式编程的支持,也就不需要写的那么麻烦,Predicate本身就是JDK自带的,and方法也不用自己写,加上有Lambda语法,写法就很简洁,省去创建匿名内部类的过程。

无论是面向对象也好,函数式编程也是,都是提供了基础的构造块,然后由基础的构造逐步的组合,用小组件逐步的叠加构建世界,只不过函数式变成的出发点是函数。

函数式编程在设计上帮助最大的两个特性:组合性和不变性。

组合性

在函数式编程中,有一类比较特殊的函数,它们可以接收函数作为输入,或者返回一个函数作为输出。这种函数叫做高阶函数(High-order function)。就像数学上的f(g(x))高阶函数的一个重要作用在于,可以用它去做行为的组合

如前面的find(and(byName(name), bySno(sno)))find方法就是一个高阶函数,它接收了一个函数作为参数,由此,一些处理逻辑就可以外置出去。这段代码的使用者,就可以按照自己的需要任意组合。

传统的方式,程序库的提供者要提供一个又一个的完整功能,就像findByNameAndBySno这样,但按照函数式编程的理念,提供者提供的就变成了一个又一个的构造块,像findbyNamebySno这样。然后,使用者可以根据自己的需要进行组合,非常灵活,甚至可以创造出我们未曾想过的组合方式。高阶函数的出现,让程序的编写方式出现了质变。

这就是典型的函数式编程风格。模型提供者提供出来的是一个又一个的构造块,以及它们的组合方式。由使用者根据自己需要将这些构造块组合起来,提供出新的模型,供其他开发者使用。就这样,模型之间一层又一层地逐步叠加,最终构建起我们的整个应用。一个好模型的设计就是逐层叠加。函数式编程的组合性,就是一种好的设计方式

把模型拆解成小的构造块,如果构造块足够小,我们自然就会发现一些通用的构造块。就可以进行组合叠加了。

(有一篇文章《The Roots of Lisp》(中文版),其中用了七个原始操作符加上函数定义的方式,构建起一门LISP语言。)

列表转换

早期的函数式编程探索是从LISP语言开始的。LISP这个名字源自“List Processing”,这个名字指明了这个语言中的一个核心概念:List,也就是列表。这是一种最为常用的数据结构,现在的程序语言几乎都提供了各自List的实现。LISP 的一个洞见就是,大部分操作最后都可以归结成列表转换,也就是说,数据经过一系列的列表转换会得到一个结果,如下图所示:

想要理解这一系列的转换,就要先对每个基础的转换有所了解。最基础的列表转换有三种典型模式,分别是map、filter和reduce

  • map就是把一组数据通过一个函数映射为另一组数据。
  • filter是把一组数据按照某个条件进行过滤,只有满足条件的数据才会留下。
  • reduce就是把一组数据按照某个规则,归约为一个数据。

如要计算一个班男生的人数,如果按照内标转换的思维,将过程进行分解,对应着map、filter和reduce:

  • 取出性别字段,对应着map,其映射函数是取出学生的性别字段;
  • 判别性别是否为男性,对应filter,其过滤函数是,性别为男性;
  • 计数加1,对应着reduce,其归约函数是,加1。

映射到代码实现上,Java8提供的Stream接口,增加了对列表转换的支持:

long countMale() {
  return students.stream()
    .map(student -> student.getGender())
    .filter(Student::byGender)
    .map(gender -> 1L)
    .reduce(0L, (sum, element) -> sum + element);
}

static Predicate<Gender> byGender(final Gender target) {
    return gender -> gender == target;
}

Stream中的接口都是基于这三个基本操作的封装,提供一种快捷的方式,如count(),就是进行计数的快捷方法。

同样是一组数据的处理,更鼓励使用函数式的列表转换,而不是传统的 for 循环。一方面因为它是一种更有表达性的写法,从前面的代码就可以看到,它几乎和我们想做的事是一一对应的。另一方面,这里面提取出来比较性别的方法,它就是一个可以用作组合的基础接口,可以在多种场合复用。

之前在讲DSL的时候就谈到过代码的表达性,其中一个重要的观点就是,有一个描述了做什么的接口之后,具体怎么做就可以在背后不断地进行优化。比如,如果一个列表的数据特别多,可以考虑采用并发的方式进行处理,而这种优化在使用端完全可以做到不可见。MapReduce 甚至将运算分散到不同的机器上执行,其背后的逻辑是一致的。

面向对象和函数式编程组合

面向对象有组合,函数式编程也有组合,面向对象组合的元素是类和对象这些结构的组合;而函数式编程组合的是函数。实际工作中,我们可以用面向对象编程的方式对系统的结构进行搭建,然后,用函数式编程的理念对函数接口进行设计。可以把它理解成盖楼,用面向对象编程搭建大楼的骨架,用函数式编程设计门窗。

不变性

编程范式中说过,学习编程范式不仅要看它提供了什么,还要看它约束了什么。函数式编程提供的约束就是不变性。

变量是可变的

SimpleDateFormat在多线程中访问是不安全的,如果一个SimpleDateFormat成员变量,由于多线程下共享,就会因为这一句calendar.setTime(date);出现问题。这里的calendarSimpleDateFormat这个类的一个字段,正是因为在format的过程中修改了calendar字段,才会出问题。所以应该这样写:

public class Sample {
  public String getCurrentDateText() {
    DateFormat format = new SimpleDateFormat("yyyy.MM.dd");
    return format.format(new Date()); 
  }
}

这样一来,不同线程之间共享变量的问题就得到了根本的解决。但是,这类非常头疼的问题在函数式编程中却几乎不存在,这就依赖于函数式编程的不变性。

理解不变性

函数式编程的不变性主要体现在值和纯函数上。值,你可以将它理解为一个初始化之后就不再改变的量,换句话说,当你使用一个值的时候,值是不会变的。纯函数,是符合下面两点的函数:

  • 对于相同的输入,给出相同的输出;
  • 没有副作用。

把值和纯函数合起来看,值保证不会显式改变一个量而纯函数保证的是不会隐式改变一个量

函数式编程中的函数源自数学中的函数。在这个语境里,函数就是纯函数,一个函数计算之后是不会产生额外的改变的,而函数中用到的一个一个量就是值,它们是不会随着计算改变的。在函数式编程中,计算天然就是不变的。所以编写纯函数的重点是不修改任何字段也不调用修改字段内容的方法

因为在实际的工作中,使用的大多数都是传统的程序设计语言,而不是严格的函数式编程语言,不是所有用到的量都是值。所以,站在实用性的角度,如果要使用变量,就使用局部变量。还有一个实用性的编程建议,就是使用语法中不变的修饰符,比如,Java就尽可能多使用final。无论是修饰变量还是方法,它们的主要作用就是让编译器提醒你,要多从不变的角度思考问题。

有了用不变性思考问题的角度,就会发现之前的很多编程习惯是极其糟糕的,比如,Java程序员最喜欢写的setter,它就是提供了一个接口,修改一个对象内部的值。

不过,纯粹的函数式编程是很困难的,我们只能把编程原则设定为尽可能编写不变类和纯函数。但仅仅是这么来看,也会发现,自己从前写的很多代码,尤其是大量负责业务逻辑处理的代码,完全可以写成不变的。

绝大多数涉及到可变或者副作用的代码,应该都是与外部系统打交道的。能够把大多数代码写成不变的,这已经是一个巨大的进步,也会减少许多后期维护的成本。

而正是不变性的优势,有些新的程序设计语言默认选项不再是变量,而是值。Java也在尝试将值类型引入语言,有一个专门的Valhalla 项目就是做这个的。可以看出,不变性,是减少程序问题的一个重要努力方向。

函数式编程,限制使用赋值语句,它是对程序中的赋值施加了约束。

Java8中引入的惰性求值、Optional等诸多内容也是有函数式编程的思想在其中,也是值得深入学习的,Java 8实战比较全面的介绍了Java8提供的这些新特性。

同时,如果想了解纯粹的函数式编程思想,可以了解Scala,Clojure,Haskell等语言