函数式编程
认识函数式编程
先从一个例子演进认识函数式编程:
// 单个学生的定义
class Student {
// 实体 ID
private long id;
// 学生姓名
private String name;
// 学号
private long sno;
// 年龄
private long age;
}
// 一组学生的定义
class Students {
private List<Student> students
}
-
需要按照姓名找出其中一个学生
Student findByName(final String name) { for (Student student : students) { if (name.equals(student.getName())) { return student; } } return null; }
-
新需求,按照学号来找人
Student findBySno(final long sno) { for (Student student : students) { if (sno == student.getSno()) { return student; } } return null; }
-
.....
这三段代码,除了查询的条件不一样,剩下的结构几乎一模一样,这就是一种重复。消除这个重复呢,可以引入查询条件这个概念,返回一个真假值
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
这样,但按照函数式编程的理念,提供者提供的就变成了一个又一个的构造块,像find
、byName
、bySno
这样。然后,使用者可以根据自己的需要进行组合,非常灵活,甚至可以创造出我们未曾想过的组合方式。高阶函数的出现,让程序的编写方式出现了质变。
这就是典型的函数式编程风格。模型提供者提供出来的是一个又一个的构造块,以及它们的组合方式。由使用者根据自己需要将这些构造块组合起来,提供出新的模型,供其他开发者使用。就这样,模型之间一层又一层地逐步叠加,最终构建起我们的整个应用。一个好模型的设计就是逐层叠加。函数式编程的组合性,就是一种好的设计方式。
把模型拆解成小的构造块,如果构造块足够小,我们自然就会发现一些通用的构造块。就可以进行组合叠加了。
(有一篇文章《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);
出现问题。这里的calendar
是SimpleDateFormat
这个类的一个字段,正是因为在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等语言