
第1章 为什么
学习一种全新的编程范式,困难并不在于掌握新的语言。真正考验人的,是怎么学会用另一种方式去思考。
1.1 范式转变
交织(complect)
- 命令式编程风格尝尝迫使我们出于性能考虑,把不同的任务交织起来,以便能够用一次循环来完成多个任务。
- 函数式编程用map()、filter()这些高阶函数把我们解放出来,让我们站在更高的抽象层次上去考虑问题,把问题看得更清楚。
1.2 跟上语言发展的潮流
所有的主流语言都在进行函数式方面的扩充。
1.3 把控制权让渡给语言/运行时
将琐碎的细节交托给运行时,令繁冗的实现化作轻巧。
1.4 简洁
面向对象编程通过封装不确定因素来使代码能被人理解;函数式编程通过尽量减少不确定因素来使代码能被人理解。
假如语言不对外暴露那么多有出错可能的特性,那么开发者就不那么容易犯错。
第2章 转变思维
学习一种新的范式是困难的——我们必须学会为熟悉的问题找到新的解答方法。
2.1 普通的例子
函数式编程希望在算法编写上给予程序员帮助,一方面程序员得以在更高的抽象层次上工作,另一方面运行时也有了执行复杂优化的自由空间。
2.1.1 命令式解法
2.1.2 函数式解法
函数式编程将程序描述为表达式和变换,以数学方程的形式建立模型,并且尽量避免可变的状态。
return names.stream()
.filter(name -> name != null)
.filter(name -> name.length() > 1)
.map(name -> capitalize(name))
.collect(Collectors.joining(","));
向函数式思维靠拢,意味着我们逐渐学会何时何地应该求助于这些更高层次的抽象,不要再一头扎到实现细节里去。
- 促使我们换一种角度去归类问题,看到问题的共性。
- 让运行时有更大的余地去做智能的优化。
-
让埋头于实现细节的开发者看到原本视野之外的一些解决方案。
return names.parallelStream() .filter(n -> n.length() > 1) .map(e -> capitalize(e)) .collect(Collectors.joining(“,”));
2.2 案例研究:完美数的分类问题
2.2.1 完美数分类的命令式解法
2.2.2 稍微向函数式靠拢的完美数分类解法
一般来讲,面向对象系统里粒度最小的重用单元是类,开发者往往忘记了重用可以在更小的单元上发生。
2.2.3 完美数分类的Java 8实现
public static IntStream factorOf(int number) {
return range(1, number + 1)
.filter(potential -> number % potential == 0)
}
public static int aliquotSum(int number) {
return factorsOf(number).sum() - number;
}
public static boolean isPerfect(int number) {
return aliquotSum(number) == number;
}
public static boolean isAbundant(int number) {
return aliquotSum(number) > number;
}
public static boolean isDeficient(int number) {
return aliquotSum(number) < number;
}
缓求值(lazy evaluation)
2.2.4 完美数分类的Functional Java实现
高阶函数消除了摩擦。
不要增加无谓的摩擦。
2.3 具有普遍意义的基本构造单元
2.3.1 筛选
需要根据筛选条件来产生一个子集合的时候,用filter。
2.3.2 映射
需要就地变换一个集合的时候,用map。
2.3.3 折叠/化约
需要把集合分成一小块一小块来处理的时候,用reduce或fold。
2.4 函数的同义异名问题
2.4.1 筛选
2.4.2 映射
2.4.3 折叠/化约
第3章 权责让渡
函数式思维的好处之一,是能够将低层次细节(如垃圾收集)的控制权移交给运行时,从而消弥了一大批注定会发生的程序错误。
让开发者从繁琐的运作细节里解脱出来,去解答问题中非重要性的那些方面。
3.1 迭代让位于高阶函数
理解掌握的抽象层次永远要比日常使用的抽象层次更深一层。
3.2 闭包
所谓闭包,实际上是一种特殊的函数,它在暗地里绑定了函数内部应用的所有变量。换句话说,这种函数(或方法)把它引用的所有东西都放在一个上下文环境里”包”了起来。
让语言去管理状态。
闭包还是推迟执行原则的绝佳样板。我们把代码绑定到闭包之后,可以推迟到适当的时机再执行闭包。
闭包作为一种对行为的建模手段,让我们把代码和上下文同时封装在单一结构,也就是闭包本身里面,像传统数据结构一样可以传递到其他位置,然后在恰当的时间和低点完成执行。
Java8闭包:一种变量作用域,其实就是在lambda表达式中引用了外部变量时,这个外部变量自动变成final修饰的不可变变量了。也就是说这个变量在被赋予初始值后,不管是在lambda表达式外部还是内部都不可改变了。
3.3 柯里化和函数的部分施用
3.3.1 定义与辨析
柯里化指的是从一个多参数函数变成一连串单参数函数的变换。它描述的是变换的过程,不涉及变换之后对函数的调用。调用者可以决定对多少个参数实施变换,余下的部分将衍生为一个参数数目较少的心函数。
部分施用指通过提前代入一部分参数值,使一个多参数函数得以省略部分参数,从而转化为一个参数数目较少的函数。让函数先作用于其中一些参数,经过部分的求解,结果返回一个由余下参数构成签名的函数。
不同点:
- 函数柯里化的结果是返回链条中的下一个函数
- 部分施用是把参数的取值绑定到用户在操作中提供的具体值上,因而产生一个”元素”(参数的数目)较少的函数。
3.3.2 Groovy的情况
3.3.3 Clojure的情况
3.3.4 Scala的情况
偏函数:描述只对定义域中一部分取值或类型有意义的函数。
3.3.5 一般用途
1. 函数工厂
def adder = { x, y -> x + y}
def incrementer = adder.curry(1)
例中从adder()函数派生出了incrementer函数。
2. Template Method模式
部分施用技巧注入当前已经确定的行为,留下未确定的参数给具体实现去发挥。
3. 隐含参数
当我们需要频繁调用一个函数,而每次的参数值都差不多的时候,可以运用柯里化来设置隐含参数。
3.4 递归
利用递归,把状态的管理责任推给运行时。
递归对开发者的解放效果或许不像垃圾收集那么显著,不过它切实地揭示了编程语言的一个重要的发展方向:通过移交”不确定因素”的控制权给运行时来消解它们。如果我们不准备插手列表操作的中间结果,那么就不会引入那些在交互中产生的错误。
3.5 Stream和作业顺序重排
允许运行时发挥其优化能力的做法,再次印证了我们关于交出控制权的观点:放弃对繁琐细节的掌控,关注问题域,而非关注问题域的实现。
第4章 用巧不用蛮
我们转换范式的收获,表现在费更少的力气完成更多的事情。很多函数式编程构造的目的只有一个:从频繁出现的场景中消灭掉烦人的实现细节。
4.1 记忆
用更多的内存(我们一般不缺内存)去换取长期来说更高的效率。
纯(pure)函数是没有副作用的函数:它不引用其他值可变的类字段,除返回值之外不设置其他的变量,其结果完全由输入参数决定。只有纯函数才可以适用缓存技术。
4.1.1 缓存
负责编写缓存代码的开发者不仅要顾及代码的正确性,连它的执行环境也要考虑在内。所谓”不确定因素”说的就是这样的东西:代码中的状态。
4.1.2 引入”记忆”
语言设计者实现出来的机制总是比开发者自己做的效率更高,因为他们可以不受语言本身的限制。
手工建立缓存的工作不算复杂,但它给代码增加了状态的影响和额外的复杂性。而借助函数式语言的特性,例如记忆,我们可以在函数的级别上完成缓存工作,只需要微不足道的改动,就能取得比命令式做法更好的效果。在函数式编程消除了不确定因素之后,我们得以专注解决真正的问题。
请保证所有被记忆的函数:
- 没有副作用
- 不依赖任何外部信息
4.2 缓求值
缓求值(lazy evaluation)是函数式编程语言常见的一种特性,指尽可能地推迟求解表达式。
4.2.1 Java语言下的缓求值迭代子
4.2.2 使用Totally Lazy框架的完美数分类实现
4.2.3 Groovy语言的缓求值列表
4.2.4 构造缓求值列表
4.2.5 缓求值的好处
- 第一,我们可以用它创建无限长度的序列。
- 第二,减少占用的存储空间。
- 第三,缓求值集合有利于运行时产生更高效率的代码。
4.2.6 缓求值的字段初始化
第5章 演化的语言
函数式编程语言和面向对象语言对待代码重用的方式不一样。
- 面向对象语言喜欢大量地建立有很多操作的各种数据结构
- 函数式语言也有很多的操作,但对应的数据结构却很少。
- 面向对象语言鼓励我们建立专门针对某个类的方法,我们从类的关系中发现重复出现的模式并加以重用。
- 函数式语言的重用表现在函数的通用性上,它们鼓励在数据结构上使用各种共用的变换,并通过高阶函数来调整操作以满足具体事项的要求。
5.1 少量的数据结构搭配大量的操作
100个函数操作一种数据结构的组合,要好过10个函数操作10种数据结构的组合。
函数式编程语言用很少的一组关键数据结构(如list、set、map)来搭配专为这些数据结构深度优化过的操作。
5.2 让语言去迎合问题
让程序去贴合问题,不要反过来
5.3 对分发机制的再思考
5.3.1 Groovy对分发机制的改进
由于长串的if语句难以阅读,Java开发者通常需要依赖GoF模式集里面的Factory模式(或者Abstract Factory模式)来缓解问题。
5.3.2 “身段柔软”的Clojure语言
5.3.3 Clojure的多重方法和基于任意特征的多态
5.4 运算符重载
5.4.1 Groovy
Groovy将运算符自动映射成方法,从而令运算符重载变成了方法的实现问题。
5.4.2 Scala
Scala也支持运算符重载,它的做法是完全不区分运算符和方法:运算符不过是一些名字比较特殊的方法罢了。
5.5 函数式的数据结构
“异常”违背了大多数函数式语言所遵循的一些前提条件。
- 函数式语言偏好没有副作用的纯函数。抛出异常的行为本身就是一种副作用,会导致程序路径偏离正轨(进入异常的流程)。
- 函数式语言以操作值为其根本,因此喜欢在返回值里表明错误并作出响应,这样就不需要打断程序的一般流程了。
引用的透明性(referential transparency)是函数式语言重视的另一项性质:发出调用的例程不必关心它的访问对象真的是一个值,还是一个返回值的函数。可是如果函数有可能抛出异常的话,用它来代替值就不再是安全的了。
5.5.1 函数式的错误处理
用Map来返回多个值的设计存在一些明显的缺点。
- Map中放置的内容没有类型安全的保障,编译器无法捕捉到类型方面的错误。假如改用枚举类型来充当键,可以稍微弥补这个缺点,但效果有限。
- 方法的调用者无法直接得知执行是否成功,需要逐一比对所有可能键,给调用者带来负担。
- 没有办法强制结果只含有一对键值,届时将出现歧义。
5.5.2 Either类
package com.nealford.ft.errorhandling;
public class Either<A, B> {
private A left = null;
private B right = null;
private Either(A a, B b) {
left = a;
right = b;
}
public static <A, B> Either<A, B> left(A a) {
return new Either<A, B>(a, null);
}
public static <A, B> Either<A, B> right(B b) {
return new Either<A, B>(null, B);
}
public A left() {
return left;
}
public B right() {
return right;
}
public boolean isLeft() {
return left != null;
}
public boolean isRight() {
return right != null;
}
public void fold(F<A> leftOption, F<B> rightOption) {
if (right == null) {
leftOption.f(left);
} else {
rightOption.f(right);
}
}
}
有了Either这件利器,我们的代码就可以在确保类型安全的前提下,试情况返回异常或者有效结果(但不会同时返回两者)。按照函数式编程的传统习惯,异常(如果有的话)置于Either的左值上,正常结果则放在右值。
5.5.3 Option类
Option类表述了异常处理中较为简化的一种场景,它的取值要么是none,表示不存在有效值,要么是some,表示成功返回。
5.5.4 Either树和模式匹配
Tree数据结构:Either<Empty, Either<Leaf, Node»
第6章 模式与重用
6.1 函数式语言中的设计模式
传统设计模式在函数式编程的世界中大致有三种归宿:
- 模式已被吸收成为语言的一部分。
- 模式中描述的解决办法在函数范式下依然成立,但实现细节有所变化。
- 由于在新的语言或范式下获得了原本没有的能力,产生了新的解决方案(例如很多问题都可以用元编程干净利落地解决,但Java没有元编程能力可用)。
6.2 函数级别的重用
6.2.1 Template Method模式
class CustomerBlocks {
def plan, checkCredit, checkInventory, ship
def CustomerBlocks() {
plan = []
}
def process() {
checkCredit()
checkInventory()
ship()
}
}
原先需要特地按照规定格式来声明的算法步骤,现在成了累里面最普通不过的属性,可以像普通的属性一样赋值。
由语言直接提供的高阶函数特性可以让我们节约大量的八股代码。
6.2.2 Strategy模式
Strategy模式也是因为第一等函数而得到简化的一种常用模式。Strategy模式定义一个算法族,并将每一种算法都在相同的接口下封装起来,令同一族的算法能够互换使用。这样做的好处是算法的变化不影响使用方,也不受使用方的影响。第一等函数让建立和操纵各种策略的工作变得十分简单。
6.2.3 Flyweight模式和记忆
6.2.4 Factory模式和柯里化
在设计模式的语境下,柯里化相当于产出函数的工厂。第一等函数(或高阶函数)是函数式编程语言共同的特性,我们可以用函数来充当其他任何的语言成分。因此我们可以很容易地设立一个根据条件来返回其他函数的函数,也就是函数工厂。
柯里化可以把通用的函数改造成专用的函数。
6.3 结构化重用和函数式重用的对比
- 面向对象编程通过封装不确定因素来使代码能被人理解;
- 函数式编程通过尽量减少不确定因素来使代码能被人理解。
函数式编程不喜欢把结构耦合在一起,它依靠零件之间的复合来组织抽象,以达到减少不确定因素的目的。
我们通过复合(composition)而不是耦合(coupling)来达到代码重用的目的。
第7章 现实应用
7.1 Java 8
设计Java 8的工程师们很聪明,他们没有生硬地在语言中安插高阶函数,而是巧妙地让旧的接口也享受到函数式新特性的好处。
7.1.1 函数式接口
含有单一方法的接口是Java的一种习惯用法,称为SAM(Single Abstract Method,单抽象方法)接口。
Java 8还允许我们在接口上声明默认方法。”默认方法”是一些在接口类型中声明的,以default关键字标记的,非抽象、非静态的public方法(且带有方法体定义)。默认方法会被自动地添加到实现了接口的类中,这就为我们提供了一条在类上”装饰”默认功能的方便途径。
Comparator<Integer> c1 = (x, y) -> x - y;
Comparator<Integer> c2 = c1.reversed();
7.1.2 Optional类型
n.stream()
.min((x, y) -> x - y)
.ifPresent(z -> System.out.println("smallest is " + z));
7.1.3 Java 8的stream
- stream不存储值,只担当从输入源引出的管道角色,一直连接到终结操作上产出输出。
- stream从设计上就偏向函数式风格,避免与状态发生关联。
- stream上的操作尽可能做到缓求值
- stream可以没有边界(无限长)。
- stream像Iterator的实例一样,也是消耗品,用过之后必须重新生成新的stream才能再次操作。
stream的操作分为中间操作和终结操作。中间操作一律返回新的stream,并且总是缓求值的。终结操作遍历stream,产生结果值和副作用(如果我们让函数产生副作用的话;虽然不鼓励,但是允许)。
7.2 函数式的基础设施
7.2.1 架构
可变的状态与测试数量有直接的关联:可变的状态越多,要求测试的也越多。
实现一个值不可变的Java类,我们需要做以下事情:
- 把所有的字段都标记为final。
- 把类标记为final,防止被子类覆盖。
- 不要提供无参数的构造器。
- 提供至少一个构造器。
- 除了构造器外,不要提供任何制造变化的方法。
CQRS架构:读取与变更分离,承担读取职责的部分可以全面实现值不可变的性质。单要用最终一致性模型来取代事务性的模型。
7.2.2 Web框架
7.2.3 数据库
Datomic:一种值不可变的数据库。
Datomic的设计产生了一些有意思的结果:
- 永久地记录所有的schema变更和数据变更
- 读取和写入分离
- 事件驱动型架构中的值不可变性和时间戳
第8章 多语言与多范式
函数式编程是一种编程范式,它既是从特定角度去看待问题的思维框架,又是实现思维图景的配套工具。
正交,在计算机科学里,两个组件如果互相没有任何影响(或副作用),就可以称作是正交的。
8.1 函数式与元编程的结合
8.2 利用元编程在数据类型之间建立映射
8.3 多范式语言的后顾之忧
代码重用在面向对象的世界里多表现为对结构的重用,而函数式的世界则多以组合和高阶函数作为重用的手段。