读书笔记——

Functional Thinking

作者:[美] Neal Ford 著 (郭晓刚 译)

第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 多范式语言的后顾之忧

代码重用在面向对象的世界里多表现为对结构的重用,而函数式的世界则多以组合高阶函数作为重用的手段。

8.4 上下文型抽象与复合型抽象的对比

8.5 函数式金字塔