本章要点

  • 理解现实世界中的函数

  • 在 Java 中表示函数

  • 使用lambda

  • 使用高阶函数

  • 使用柯里化函数

  • 用函数式接口编程

学习目标

为了理解函数式编程是如何工作的, 我们会使用一些函数式库提供的函数式组件,还有Java 8 标准库里可用的少许组件。

不仅如此, 我们还会关注如何构建它们,而不仅仅是如何使用

一旦掌握了这些概念, 你便可以自行决定是使用自己的函数还是 java8 的标准函数, 或者是现有的外部库之一。

在本章你将会创建一个与 Java 8 的 Function 非常相似的Function。

为了让代码便于阅读, 这个 Function 在处理类型参数上(避免通配符〉会比较简单, 但它会有一些 Java 8 缺失的强大能力。

除了这些不同以外,它们可以互换。

你可能会在理解本章的某些代码时遇到麻烦。请不必担心, 这是正常现象。

出于介绍函数的目的,我们需要引入一些诸如List 、Option 等函数式的结构。请耐心看完。所有未解释的组件都会在后续的章节中一一道来。

现在我要从现实世界和编程语言两方面入手,来详细解释什么是函数。

函数并不仅是数学上或编程上的概念,函数是日常生活的一部分。

我们经常会对所在的世界建模,这可不仅是编程。我们构建周围世界的表征,而这些表征通常都以随时改变自己状态的对象为基础。

这种看待事物的方式是人类的天性。 从状态A 到B 的迁移需要时间,并且需要耗费时间、精力或金钱。

还是以加法为例。大多数人将其视为耗时的计算(有时还得消耗脑力〉。

它有一个开始状态, 一个转换过程(计算过程)和一个结果状态(加法的结果) 。

为了对345 、765 和34523 求和,我们当然需要进行计算。有些人可以在很短的时间内算出来,而有些人可能会耗时很久。

有些人可能永远也计算不出来,或者算出一个错误的结果。

有些人在他们的大脑中计算,有些人需要在纸上写下来。

不管是用纸还是用脑,所有人可能都需要改变一些状态才能得到结果。

但是对于2 加3,以上的这些我们全都不需要。大多数人都早已记住了答案,所以无须任何计算就可以立即给出结果。

这个例子展示了运算并不是必不可少的要素。它仅仅是计算一个函数的结果。但是在我们运算之前,结果就已经存在了。

ps: 虽然有些哲学,但是结果就是如此。这个结果被我们做了缓存,省去了计算的过程

我们只是无法事先知道结果罢了。

函数式编程就是用函数来编程。

为了能够做到这一点,首先我们需要知道函数是什么,不管是在现实世界中还是在我们选择的编程语言中。

现实世界里的函数

在现实世界里,函数主要是数学上的概念。

它是被称为函数定义域( function domain )的源集( source set )和被称为函数陪域(也nction codomain )的目标集( target set )之间的关系。

定义域和陪域无须完全不同。

ps: 这一段是一些数学定义和概念,你也可以跳过。

例如,一个函数的定义域和陪域可以有相同的整数集。

如何让两个集之间的关系成为函数

为了成为函数,关系需要满足一个条件:

定义域内的所有元素都必须在陪域内有且仅有一个对应元素,如图 2. 1 所示。

image

这个条件有一些有趣的含义:

  • 定义域里不存在在陪域里没有对应值的元素。

  • 定义域里的一个元素在陪域里不会有两个对应的元素。

  • 陪域里的元素可能在源集里没有相对应的元素。

  • 陪域里的元素可能在源集里有多个相对应的元素。

  • 在定义域里存在对应元素的陪域元素集被称为函数的像( image of the function )。

例子

例如,你可以定义函数

f(x) = x + 1

其中x 是一个正整数。

这个函数表示每个正整数和它的后继( successor )之间的关系。

你可以给这个函数起任何名称。尤其是你可以赋予它一个能帮助记忆的名称,例如

successor(x) = x + 1

这似乎是一个好主意,但是你不应该无条件地信任一个函数名。

你可以用另一个函数名取而代之,如下所示:

predecessor(x) = x + 1

这里不会发生任何错误,因为函数名称和定义之间是没有任何强制关系的。不过很明显,用这样的名称真是一个馒主意。

请注意,我们正在讨论函数是什么(它的定义)而不是函数能做什么。

一个函数什么也不做。

函数successor 并不会对自己的参数加l 。你可以对整数加l 来计算后继,但那样就不是函数了。

这个函数successor(x)并不会对x 加1 。它只是与x + 1 等价而己。

简单来说,每当你碰到successor(x)表达式,就可以将其替换为(x+1)。

请注意用来分隔表达式的括号。

在单独使用这个表达式的时候不需要括号,但是在某些场合中它们还是有用的。

逆函数

函数未必会有逆函数(inverse function)。

如果f(x)是一个从A 到B(A 为定义域,B 为陪域)的函数,它的逆函数为 f^-1(x), B 为定义域而A 为陪域。

如果你用 A->B 来表示函数,那逆函数(如果存在的话)就是B-> A 。

函数的逆函数在满足函数要求的情况下也是一个函数:

每个源值有且仅有一个目标值。

因此,对 successor(x) 取逆,你可以将其命名为 predecessor(x) (尽管你可以随便将它命名为xyz)的关系,它在N (包含0 的正整数集)上并不是一 个函数。

因为,在 N 里并没有前驱 (predecessor); 反过来,如果successor(x)被认为是带符号的整数集(正数和负数,标记为Z),那么successor 的逆函数也 是一个函数。

有些简单的函数没有逆函数。

例如,这个函数

f (x) = ( 2 * x)

在 N 到 N 的定义上没有逆函数。如果你定义的是 N 到偶数集,那它就有逆函数了。

偏函数

没有在定义域中定义所有元素但是满足其他需求(定义域里不存在任何在陪域里有多个元素与之相对应的元素)的关系一般称为偏函数(partial function)。

关系 predecessor(x) 在N (包含0 的正整数集)上是一个偏函数;

但是在 N*(不包含0 的正整数集)上是一个全函数(total function),其陪域为N 。

偏函数在编程中相当重要,因为许多 BUG 都是由于将偏函数当作全函数使用而产生的。

例如, f(x) = 1/x 是一个从N 到Q (有理数)的偏函数,因为它对0 没有定义。

它是一个从 V 到 Q 的全函数,也是一个从N 到( Q 与eηor )的全函数。

通过在陪域里增加一个元素(错误条件〉,可以将偏函数转化为全函数。

但是这样做的话,这个函数需要有一种返回错误的方法。

你能发现它与计算机编程的相似之处吗?

你将会看到把偏函数转换成全函数是函数式编程里的一个重要组成部分。

复合函数

函数就像积木,可以复合为其他函数。

函数 f 和 q 的复合函数标记为 f · g, 读作f round g 。

如果 f(x) = x + 2 并且 g(x) = x * 2 ,可得 f · g (x) = f(g(x)) = f(x*2) = (x * 2 ) + 2

请注意f 。g (x )和f (g (x ))是等价的。

但是写成复合函数 f(g(x)) 指出了用 x 来表示参数的占位符。

使用f 。q 来表示复合函数的话,可以省略这个占位符。

如果你把这个函数应用于 5, 就会得到以下结果:

f · g (5 = f(g(5)) = f(5*2) = (5 * 2 ) + 2

有意思的是, f 。q 一般与q 。f 不同,虽然它们有时是等价的。

例如:

9 。f (5) = g(f(5)) = g(5 + 2) = 7 * 2 = 14

请注意,应用函数的顺序与写函数的顺序正好相反。

如果你写f 。q ,首先应用q ,然后才是f。

标准的Java 8 函数定义了 compose() 和 andThen() 方法来表示这两种例子(顺便提一句,它们是冗余的,因为 f.andThen(g) 与 g.compose(f) 或q 。f 等价) 。

多参函数

迄今为止, 我们只是讨论了单参函数。

如果函数有多个参数会如何?简单来说,并没有多参函数这回事。

还记得函数的定义吗? 一个函数是源集和目标集之间的关系。

它并不是多个源集与一个目标集之间的关系。一个函数不允许有多个参数。

但是两个集的乘积本身是一个集,所以这样的函数可能确实有多个参数。

让我们看看下面的函数:

f(x , y) = x + y

这似乎是一个 N x N 与N 之间的关系,在这种情况下,它是一个函数。但是它只有一个参数,即N x N 的元素。

N x N 是所有可能的整数对的集。这个集的元素就是一对整数,更通用的元组(Tuple)表示多个元素的组合,而一对整数其实就是元组的一个特例。

一对就是持有两个元素的元组。

元组用括号来表示,所以 (3, 5) 是一个元组,也是一个N x N 的元素。

函数 f 可以应用于这个元组:

f((3,5)) = 3 + 5 = 8

在这种情况下,你可以按照惯例删除一对括号:

f(3, 5) = 3+5 = 8

然而,它仍然是一个接收一个元组的函数,而不是两个参数的函数。

函数柯里化

元组函数可以用另一种方式来思考。

可以认为函数 f(3, 5) 是一个从N 到N的函数集的函数。

所以先前的例子可以这样重写 f(x)(y) = g(y)

其中 g(y) = x + y

在这种情况下,可以这样写

f(x) = g

它的意思是将函数 f 应用于参数x, 结果是一个新的函数 g。

将函数g 应用于 y 将会得到:

g(y) = x + y

当应用 g 的时候, x 就不再是一个参数了。它并不依赖于参数或者其他什么东西。

它就是一个常量。如果将其应用于 (3, 5),你就会得到:

f(3)(5) = g(5) = 3 + 5 = 8

这里唯一的新知识就是 f 的陪域现在不是数字集了,而是函数集。

将 f 应用于个整数的结果是一个函数。将这个新函数应用于一个整数的结果是一个整数。

函数 f(x)(y) 是 f(x, y) 的柯里化形式

对一个元组函数(如果你喜欢可以称为多参函数)应用这种转换就称为柯里化(currying),源于数学家Haskell Curry(虽然他并非是这种转换的发明者)。

偏应用函数

加法函数的柯里化形式可能看起来不那么自然,而且你可能会疑惑它是否对应着现实世界里的什么东西。

毕竟,通过柯里化的版本,你需要单独考虑不同的参数。

参数之一被视为第一个,将函数应用于它能得到一个新函数,这个新函数自己真的有用吗?

还是说它仅仅是整体计算的一个步骤?

在加法的例子里,看起来没什么用。

并且你其实可以从两个参数中的任意一个开始,并没有什么不同。

虽然中间函数会不一样,但是最终结果总会是一样的。

例子

现在思考一个接收一对值的新函数:

f (rate , price) = price / 100 * ( 100 + rate)

这个函数看起来跟以下函数等价:

g (price, rate) = price / 100 * (100 + rate)

现在让我们思考一下这两个函数的柯里化版本:

f (rate) (price )

g (price) (rate)

你知道f 和q 都是函数。

但是 f(rate)和 q (price)都是什么呢?

没错,它们的确是 f 应用于 rate 和 q 应用于 price 的结果。

但是这些结果的类型都是什么?

f(rate)是接收一个价格并返回一个价格的函数。

如果 rate = 9 ,这个函数对一个价格应用9% 的税,得到一个新价格。

你可以将中间函数命名为 apply9PercentTax(price),由于税率并不会经常变化,它可能还是一个挺有用的工具。

另一方面, q(price) 是一个接收税率并返回一个价格的函数。

如果价格是 100 美元,它的新函数就是对 100 应用一个可变税率。

你会如何命名这个函数呢?如果你想不出来一个有意义的名字,通常就意味着它没有什么用,虽然这取决于我们要解决的实际问题。

诸如f(rate)和 q(price)这样的函数有时被称为偏应用函数,与 f(rate, price)和 g(price, rate)的形式有关。

视参数的值而定,偏应用函数可能会有非常庞大的结果。

我们会在后续章节再回到这个主题。

如果还不是很理解柯里化的概念,想象你正在外国旅行,用一个手持计算器(或者你的智能手机〉宋转换货币。

当要计算价格的时候,你希望每次都需要输入汇率,还是把汇率存储在内存里?哪种办法更不容易出错?

ps: 个人理解就是可以将变量的一份封装为一个固定的函数,这样便于后续的计算。

没有作用的函数

请记住,纯函数只会返回一个值,不会做任何其他事情。

它不会改变外界(外界是相对函数本身而言)的任何元素,不会改变自己的参数,也不会在出错时爆发(或者抛出异常等)。

它可以返回一个异常或者其他的什么东西,例如一个错误消息,但必须将其返回,而不是将其抛出,不是写日志,也不是打印。

参考资料

《java 函数式编程》