要想更好的使用函数式编程,仅仅熟悉其语法结构是远远不够的。必须从思想和设计层面,去考虑它,去接纳它。这种编程范式和大多数开发人员所熟知的面向对象编程范式是不同的。
下面我们从以下几个方面来回顾一下使用函数式编程的要点:
多用声明式,少用命令式
要想更好的使用函数式编程,首先必须要提升代码的抽象程度。之所以使用函数式编程在完成同样任务时需要的代码量比命令式要少,很大程度上就是源于函数式风格的代码的抽象程度更高,能够表达的语义也就更丰富。
当我们使用命令式风格的代码时,我们习惯很快地就进入到细节上的考量,比如解决这个问题需要使用多少次循环,每次循环需要进行的操作有哪些等。这是命令式风格的必然,正是由于较低的抽象程度而导致我们的考虑方式也陷入了琐碎的细节中。此情此景,应了那句话“只见树木不见森林”。所以稍有经验的开发人员,都会特别看重代码封装,目的也就是为了将抽象层次低的琐碎细节给隐藏起来,减少代码的噪声。
而使用函数式风格进行编码时,首先我们应该明确这段代码的目标。比如,对集合根据某种条件进行过滤,对集合中的每个元素进行某种操作。这才是代码的目标,不要陷入到代码的底层细节中。比如,遍历集合并逐一判断,将符合要求的元素放入一个新的集合中,最后返回这个新的集合。一个用来简单判断你是否陷入到对细节的思考的方法是:仔细审视你的描述中有没有出现“遍历”或者“循环”这样的字眼。如果出现了这些字眼,多半表示你的思考方式过于底层了。在进行函数式编程时,这是一种需要避免的思维习惯。所以,这也许就是之前提到过的内部遍历器(Internal
Iterator)的意义,它将遍历的底层逻辑给封装起来,给你的思维腾出空间,让你有机会去从更宏观,更抽象的角度来审视需要解决的问题。
比如,当我们需要拿到一组价格中的最高价格时,99.9%的Java开发人员都会这样实现:
int max = 0; for(int price : prices) { if(max < price) max = price; }
以上就是最最典型的命令式思考方式,严重依赖于循环与遍历这类语法结构。可是仔细想想,循环和遍历只不过是一种语法现象,它和我们要解决的实际问题并没有直接的关系。我们为何不从更高的角度来审视这个问题呢?输入的参数是一个集合,而输出的参数是一个元素。这就是典型的规约操作(Reduction Operation),它已经被JDK提供了,我们只需要使用它即可:
final int max = prices.stream().reduce(0, Math::max);
代码变的简洁了很多,没有额外创造出任何“轮子”,使用的都是已经存在的基础方法。比如reduce方法和Math.max的方法引用。这也是函数式风格的另一个吸引人的地方:可重用性。
青睐不变性(Immutability)
对于并发程序设计而言,变量的可变性可以说是万恶之源。当多个线程同时对某个变量进行修改的时候,由于可能发生的竞态条件(Race Condition),在很多情况下会得到错误的结果。这也从另一个角度解释了为何在命令式的代码中,实现高效而正确的并发代码是非常困难的。要处理的细节太多,各种小陷阱和过于底层的实现方式,能不困难吗?
而使用函数式编程时,就从本质上避免了创建过多的可变变量。程序的执行并不会改变对象的状态,而是通过输入的对象根据逻辑直接创建出了全新的对象。这也是为什么函数式代码更容易被并行化的原因,从根源上杜绝了破坏并行化的罪魁祸首 - 可变变量(Mutable Variable)。
因此,当你发现代码中定义了可变变量时,考虑是否有办法对它们进行重构来避免之。
减少副作用(Side
Effects)
所谓副作用,就是这段代码对其外部的程序状态有影响。比如修改了某个实例的状态,修改了全局变量等等。
所谓的副作用,最常见的就是返回值为void的方法,这种方法一般都是通过修改对象的状态来完成计算逻辑。这也意味着程序中存在着可变量。因此,方法的副作用往往和可变量有着千丝万缕的联系。当消除了方法的副作用时,多半也意味着可变量的数量也减小了。如果一个方法没有副作用,意味着只要输入的参数不变,输出的结果就永远是一样的。
当使用Lambda表达式时,需要确保代码没有副作用。如果代码存在副作用,也就违背了Lambda表达式的初衷,毕竟Lambda就是为了函数式编程而生的,而函数式编程的特点之一就是函数的实现应该无副作用。
使用表达式(Expression),而不是语句(Statement)
表达式和语句都是程序中用来执行某些操作的指令。只不过它们之间存在一些区别:
- 语句:执行了操作,但是不会返回任何值
- 表达式:执行了操作,并且有返回值
因此,优先使用表达式也就是希望减少副作用和可变量。因为语句虽然没有返回值,但是它仍然会执行某些操作,而这些操作通常就是对程序状态进行修改,否则语句的意义何在?
同时,表达式和语句不一样也在于表达式是可以组合起来的,也就是前面提到过的函数链(Function Chaining)。在函数式编程中,函数链十分强大,并且拥有很好的可重用性,每个函数就像一块乐高积木,而这些积木则可以拼装出无穷无尽的组合。
设计高阶函数
高阶函数是Java 8中引进的一个重大特性,过去我们只能向方法传入对象或者值作为参数。而高阶函数的引入,让我们也可以将函数作为参数传入。这无疑能够大大地提高代码的抽象程度,同时减少代码的噪声,比如过去频繁使用的匿名内部类,当它的定义符合函数接口的规范时,就可以直接使用Lambda表达式或者更为简练的方法引用。
比如,这样的代码在Java 8之前的GUI程序中层出不穷:
button.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent event) { JOptionPane.showMessageDialog(frame, "you clicked!"); } });
而现在我们可以让它插上Lambda表达式的翅膀:
button.addActionListener(event -> JOptionPane.showMessageDialog(frame, "you clicked!"));
这不仅让这部分代码变的简练,还减少了源代码中需要的import语句。 因为此时需要实现的接口ActionListener是不需要引入的,同时ActionEvent也可以通过借助类型推导来自动引入。
合理地设计和应用高阶函数,可以让我们非常巧妙和简洁地实现一些常用的设计模式,而不需要像面向对象设计那样创建一大批的类型和接口。
关于性能
有些有些开发人员会担心,大规模的将命令式代码替换成以Lambda表达式和方法引用为基础的声明式代码会对程序性能造成一些影响。但是实际上,这个担心可以说是多余的:在大多数情况下,性能只会变的更好。
Java 8规范提供了一些方法用来帮助编译器进行优化。其中比较和Lambda表达式关系比较密切的是一个叫做”调用动态优化的字节码“(Invoke Dynamic Optimized Bytecode)的指令。结合这个指令可以让Lambda的表达式的执行更快。
比如,下面是一段用来统计集合中质数个数的命令式程序:
long primesCount = 0; for(long number : numbers) { if(isPrime(number)) primesCount += 1; }
将命令式重构成下面的声明式:
final long primesCount = numbers .stream() .filter(number -> isPrime(number)) .count();
当这个集合是1-100000的整型数时,无论是命令式还是声明式的运行时间都是0.025秒左右。但是即便耗时相同,由于使用声明式的种种优势,我们还是可以认为声明式的风格更好:更简练,没有副作用,易于并行化。
而且,由于对每个整型数值判断其是否是质数都是独立的任务。因此,上述程序也非常容易被并行化,仅仅是将stream方法替换成parallelStream就行:
final long primesCount = numbers .parallelStream() .filter(number -> isPrime(number)) .count();
此时,执行时间缩短到了0.006秒!也就是说,性能上升了大约400%。
采用函数式编码风格
从Java 8开始,Java也是一门像Scala等语言那样的混合范式编程语言了。了解在Java 8中如何通过Lambda表达式,方法引用和高阶函数等语法来实现函数式编码本身并不困难。
困难的地方在于,如何灵活地将这种新型编程范式和现有的命令式面向对象范式有机地融合在一起。这需要你对手头的问题进行仔细的思考,甚至是颠覆性的思考。当然,通过寻找一些典型的用例也是一个非常好的学习方式。
我们可以采取一种循序渐进的方式来对编写代码。首先让它能够工作,再让它变的更好,变的更优美。而毫无疑问,函数式编码会让代码更加优美,更加充满诗意。