19.1 无处不在的函数

第18章中使用术语函数式编程意指函数或者方法的行为应该像“数学函数”一样——没有任何副作用。对使用函数式语言的程序员而言,这个术语的范畴更加宽泛,它还意味着函数可以像任何其他值一样随意使用:可以作为参数传递,可以作为返回值,还能存储在数据结构中。能够像普通变量一样使用的函数称为一等函数(first-class function)。这是Java 8补充的全新内容:通过::操作符,你可以创建一个方法引用,像使用函数值一样使用方法,也能使用Lambda表达式(比如(int x) -> x + 1)直接表示方法的值。Java 8中使用下面这样的方法引用将Integer.parseInt方法保存到一个变量中是合理合法的1:

1如果只打算在Integer::parseInt方法内保存变量strToInt,那么你可能希望将strToInt声明为ToIntFunction类型来避免封装的开销。这里你并没有这么做,因为即便采用这种方法能改善基本类型的处理效率,对Java基本类型的这种封装也可能会干扰你对程序的逻辑理解。

  1. Function<String, Integer> strToInt = Integer::parseInt;

19.1.1 高阶函数

目前为止,我们使用函数值属于一等这个事实只是为了将它们传递给Java 8的流处理操作(正如在第4~7章看到的一样),达到行为参数化的效果,类似在第1章和第2章中将Apple::isGreenApple作为参数值传递给filterApples方法那样。但这仅仅是个开始。另一个有趣的例子是静态方法Comparator.comparing的使用,它接受一个函数作为参数同时返回另一个函数(一个比较器),代码如下所示。图19-1对这段逻辑进行了解释。

  1. Comparator<Apple> c = comparing(Apple::getWeight);

19.1 无处不在的函数 - 图1

图 19-1 comparing方法接受一个函数作为参数,同时返回另一个函数

第3章我们构造函数创建流水线时,做了一些类似的事:

  1. Function<String, String> transformationPipeline
  2. = addHeader.andThen(Letter::checkSpelling)
  3. .andThen(Letter::addFooter);

函数式编程的世界里,如果函数,比如Comparator.comparing,能满足下面任一要求就可以被称为高阶函数(higher-order function):

  • 接受至少一个函数作为参数;
  • 返回的结果是一个函数。

这些都和Java 8直接相关。因为在Java 8中,函数不仅可以作为参数传递,还可以作为结果返回,能赋值给本地变量,也可以插入到某个数据结构。比如,一个迷你计算器程序可能有这样的一个Map>,它将字符串sin映射到方法Function,实现对Math::sin的方法引用。第8章在介绍工厂方法时进行过类似的操作。

对于喜欢第3章结尾的那个微积分示例的读者,由于它接受一个函数作为参数(比如,(Double x) -> x * x),又返回一个函数作为结果(这个例子中返回值是(Double x) -> 2 * x),你可以用不同的方式实现类型定义,如下所示:

  1. Function<Function<Double,Double>, Function<Double,Double>>

把它定义成Function类型(最左边的Function),目的是想显式地向你确认可以将这个函数传递给另一个函数。但是,最好使用差异化的类型定义,函数签名如下:

  1. Function<Double,Double> differentiate(Function<Double,Double> func)

其实二者说的是同一件事。

副作用和高阶函数

第7章中我们了解到传递给流操作的函数应该是无副作用的,否则会发生各种各样的问题(比如错误的结果,有时由于竞态条件甚至会产生无法预期的结果)。这一原则在你使用高阶函数时也同样适用。编写高阶函数或者方法时,你无法预知会接收什么样的参数——一旦传入的参数有某些副作用,我们将会一筹莫展!如果作为参数传入的函数可能对你程序的状态产生某些无法预期的改变,一旦发生问题,你将很难理解程序中发生了什么;它们甚至会用某种难于调试的方式调用你的代码。因此,将所有你愿意接受的作为参数的函数可能带来的副作用以文档的方式记录下来是一个不错的设计原则,最理想的情况下你接收的函数参数应该没有任何副作用!

下面讨论柯里化:它是一种可以帮助你模块化函数、提高代码重用性的技术。

19.1.2 柯里化

给出柯里化的理论定义之前,先来看一个例子。应用程序通常都会有国际化的需求,将一套单位转换到另一套单位是经常碰到的问题。

单位转换通常都会涉及转换因子以及基线调整因子的问题。比如,将摄氏度转换到华氏度的公式是CtoF(x) = x*9/5 + 32。 所有的单位转换几乎都遵守下面这种模式:

(1) 乘以转换因子;

(2) 如果需要,进行基线调整。

可以使用下面这段通用代码表达这一模式:

  1. static double converter(double x, double f, double b) {
  2. return x * f + b;
  3. }

这里x是你希望转换的数量,f是转换因子,b是基线值。但是这个方法有些过于宽泛了。通常,你还需要在同一类单位之间进行转换,比如公里和英里。当然,你也可以在每次调用converter方法时都使用三个参数,但是每次都提供转换因子和基准比较烦琐,并且你还极有可能输入错误。

当然,你也可以为每一个应用编写一个新方法,不过这样就无法对底层的逻辑进行复用了。

这里我们提供一种简单的解法,它既能充分利用已有的逻辑,又能让converter针对每个应用进行定制。你可以定义一个工厂方法,它生产带一个参数的转换方法,我们希望借此来说明柯里化。下面是这段代码:

  1. static DoubleUnaryOperator curriedConverter(double f, double b){
  2. return (double x) -> x * f + b;
  3. }

现在,你要做的只是向它传递转换因子和基准值(fb),它会不辞辛劳地按照你的要求返回一个方法(使用参数x)。比如,你现在可以按照你的需求使用工厂方法产生需要的任何converter

  1. DoubleUnaryOperator convertCtoF = curriedConverter(9.0/5, 32);
  2. DoubleUnaryOperator convertUSDtoGBP = curriedConverter(0.6, 0);
  3. DoubleUnaryOperator convertKmtoMi = curriedConverter(0.6214, 0);

由于DoubleUnaryOperator定义了方法applyAsDouble,你可以像下面这样使用你的converter

  1. double gbp = convertUSDtoGBP.applyAsDouble(1000);

这样一来,你的代码就更加灵活了,同时它又复用了现有的转换逻辑!

一起回顾下你都做了哪些工作。你并没有一次性地向converter方法传递所有的参数xfb,相反,你只是使用了参数fb并返回了另一个方法,这个方法会接受参数x,最终返回你期望的值x * f + b。通过这种方式,你复用了现有的转换逻辑,同时又为不同的转换因子创建了不同的转换方法。

柯里化的理论定义

柯里化2是一种将具备两个参数(比如,xy)的函数f转化为使用一个参数的函数g,并且这个函数的返回值也是一个函数,它会作为新函数的一个参数。后者的返回值和初始函数的返回值相同,即f(x,y) = (g(x))(y)

当然,这个定义是一种概述。你可以将一个使用了六个参数的函数柯里化成一个接受第2、4、6号参数,并返回一个接受5号参数的函数,这个函数又返回一个接受剩下的第1号和第3号参数的函数。

当一个函数使用的所有参数仅有部分(少于函数的完整参数列表)被传递时,通常我们说这个函数是部分求值(partially applied)的。

2柯里化的概念最早由数学家Moses Schönfinkel引入,而后由著名的数理逻辑学家哈斯格尔·柯里(Haskell Curry)丰富和发展,柯里化由此得名。它表示一种将一个带有n元组参数的函数转换成n个一元函数链的方法。

现在讨论函数式编程的另一个方面:数据结构。如果你不能修改数据结构,那么还能用它们编程吗?