回顾

后来又看了一下这本身,感觉过于学术+偏向于数据结构。

所以感觉不如 《java8 函数式编程》适合学习。

序言

学习 java 函数式编程的目的主要有 2 个:

(1)运用下 jdk8

(2)重新理解这种编程思想

实际上很多东西都要反复学些。

准备工作

你可以同时阅读下列内容:

java8 系列学习

本系列学习方式

(1)以书本为核心,做发散式学习。

统计 10 篇左右博客,直到没有特别新颖的内容为止。

(2)笔记

本系列博客就是一个笔记。

同时源码也会做备份,放在 github 上。

(3)教学相长

分享本系列内容,帮助别人,提升自己。

内容要点

函数式编程的优势

副作用的问题

什么是函数式

引用透明如何让程序更安全

使用代换模型编程的原因

充分利用抽象

函数式编程

并不是所有人都在函数式编程( functional programming ,即FP 〕的定义上达成了共识。

一般来说,函数式编程是用函数来编程的一种编程范式。

但这个定义并不能解释最重要的一点: 函数式编程和其他编程范式的区别,以及究竟是什么让它(可能)成为编程的更佳方式。

在 1990 年出版的Why Functional Programming Matters 一书中, John Hughes 写道:

函数式编程中没有赋值语句,因此变量一旦有了值,就不会再改变了。更通俗地说,函数式编程完全没有副作用。除了计算结果,调用函数没有别的作用。这样便消除bug 的一个主要来源,也使得执行顺序变得无关紧要一一因为没有能够改变表达式值的副作用,可以在任何时候对它求值。这样便把程序员从处理控制流程的负担中解脱出来。由于能够在任何时候对表达式求佳,所以可以用变量的值来自由替换表达式,反之亦然一一即程序是“引用透明”的。这样的自由度让函数式的程序比它们的一般对手在数学上更易驾驭。

在本章的其余内容中,我会简要地展示引用透明和代换模型的概念,还有函数式编程的其他精华概念。你将会在其他章节中多次应用这些概念。

是什么

理解事物是什么与不是什么往往都很重要。

如果函数式编程是一种编程范式,那么显然还会有其他不同的编程范式。

与一些人预料的可能相反,函数式编程与面向对象编程COOP )并不是非此即彼。

有些函数式编程语言是面向对象的,有些不是。

ps: 面向对象是一种思想,函数式编程也是一种思想,我们要学会灵活运用这种思想。个人觉得这和语言其实也没有绑定关系,只不过 java8 刚好开始支持而已。你甚至可以使用 java8 写出过程式的代码。

函数式编程有时被认为是一系列可以补充或替代其他编程范式的技术,例如

  • 函数是一等公民

  • 匿名函数

  • 闭包

  • 柯里化

  • 惰性求值

  • 参数多态

  • 代数数据类型

尽管大部分的函数式编程语言确实使用了一些这样的技术,但是对于每一种技术,你也都可以找到函数式编程语言不支持的例子。

同样的,非函数式编程语言也会支持一些这样的技术。

你会看到在本书中学习这些技术的时候,让程序更加函数化的并非是编程语言,而是你写代码的方式。

话虽如此,有些语言会对函数更加友好一些。

与函数式编程相对的应该算是命令式编程范式。在命令式编程的风格里,程序由“做”事情的要素构成。

“做”事情意味着一个初始状态、一个转换过程和一个终止状态。

有时这被称为状态改变( state mutation )。传统的命令式风格的程序通常描述了一系列由条件判断区分的改变。

例如, 正数 a 和 b 相加的程序,可以表示为如下伪代码:

  1. 如果 b==O ,返回 a。

  2. 否则 a 自增, 并且 b 自减。

  3. 用新的 a 和 b 重新计算。

在这段伪代码里,你可以识别出大多数命令式编程的传统指令:测试条件、可变变量、分支以及一个返回值。

另一方面,函数式编程由“是”什么的元素组成,而不是“做”什么。

a 与 b 的和井不会“造”出一个结果。

例如 2 与 3 的和,并不会造出5,它就是 5。

这个差异似乎不那么重要,但并非如此。最主要的结果是,每当你遇到2+3 时,就可以把它替换为5 。

如果你打算替换掉的表达式除了返回结果以外没有其他作用,那就可以把它安全地替换为结果。

但是你怎么知道它有没有其他作用呢?

在求和的例子里,你可以清楚地看到a 与b这两个变量被程序破坏掉了。

这就是程序除了返回一个结果以外的作用, 因而被称为副作用。(发生在Java 方法中的计算还不太一样,因为变量a 和b 可能是传进来的值,仅在当前作用域里发生变化,在方法外面并不可见。)

没有副作用

命令式编程和函数式编程的一个最大的不同是,函数式编程没有副作用。

这意味着其中

  • 没有变量改变。

  • 没有打印到控制台或其他设备。

  • 没有写入文件、数据库、网络或其他什么。

  • 没有抛出异常。

当我说“没有副作用”的时候,我是指没有可观测到的副作用。

函数式的程序是由接收参数并返回值的函数复合而成的,仅此而己。

你并不关心函数内部发生了什么,因为在理论上,什么都没有发生。

但是在实际上,程序是为完全不函数式的计算机而编写的。

所有的计算机都基于相同的命令式范式,所以函数就是如下黑盒:

  • 接收一个参数(一个单独的参数,一会儿你就会看到〉。

  • 内部做一些神秘的事情,例如改变变量的值,还有许多命令式风格的东西,但是在外界来看并没有什么作用。

  • 返回一个(单独的)值。

这只是理论。实际上,函数不可能完全没有副作用。

函数会在某个时候返回一个值,而这个值可能是变化的。这就是一个副作用。

它可能会造成一个内存耗尽的错误,或者是堆枝溢出的错误,导致应用程序崩惯,这在某种意义上就是一个可观测到的副作用。

并且它还会造成写内存、寄存器变化、加载线程、上下文切换和其他确实会影响外界观测的这类事情。

所以函数式编程其实是编写非故意的副作用的程序,我的意思是,副作用是程序预期结果的一部分。

非故意的副作用也应该越少越好。

编写没有副作用的程序

你可能想知道如何才能编写出既没有副作用又有用的程序。

显然这不太可能。

函数式编程并非关于编写没有可观测结果的程序。

它是关于编写除了返回值以外没有可观测结果的程序。

但如果这就是程序能做的全部,那就没有多大的用处。

最后,函数式编程需要有可观测的作用,例如把结果显示在屏幕上,写到文件或数据库里,或者通过网络发送出去。

与外界的这种交互不会发生在计算过程中,而只会发生在

计算完成后。换句话说,将会推迟副作用并单独应用。

以图1.1 的加法为例。

虽然描述的是命令式的风格,但程序也可能是函数式的,取决于如何实现。

想象一下用 Java 实现的以下代码:

public static int add (int a , int b) {
  while (b > 0 ) {
    a++;
    b--;
  }
  return a;
}

这段程序是彻头彻尾的函数式。

它接收一对整型a 和 b 为参,并返回一个值,完全没有其他可观测的作用。

它改变了变量,但事实上与需求并不矛盾,因为Java的参数是值传递的,所以参数的变化在外界不可见。

接下来你就可以选择应用一个作用,例如显示结果或是用结果做其他运算。

请注意,虽然结果可能不正确(在溢出的情况下),但是与没有副作用并不矛盾。

如果 a 和 b 太大了,程序可能默默地溢出并返回一个错误的结果,但是这仍然是函数式的。

另一方面,以下程序就不是函数式的:

public static int div(int a, int b) {
  return a / b;
}

虽然这段程序并不改变任何变量,但是当b 等于0 的时候,它会抛出异常。

抛出异常就是一个副作用。

与此相反,接下来的实现虽然有一点笨拙,但它却是函数式的:

public static int div(int a, int b) {
    return (int) (a / (float)b);
}
  • 测试结果
System.out.println(div(1, 0));
System.out.println(div(2, 0));

两次测试结果如下:

2147483647
2147483647

即使b 为0 ,这样的实现也不会抛出异常,但是它会返回一个特殊的值。

由你自行决定你的函数用返回特殊值来代表除数为零的做法是否可行。(很可能不行!)

无论抛异常是有意为之或是无意的,它终归是一个副作用。

尽管在命令式编程里,副作用一般正是我们想要的。

最简单的形式可能看起来如下:

public static int add (int a , int b) {
  while (b > 0 ) {
    a++;
    b--;
  }
  System.out.println(a);
}

这段程序并不返回值,而是把它打印到了控制台上。这就是期望的副作用。

请注意如下程序,既返回了值又有意加上了副作用:

public static int add (int a , int b) {
  while (b > 0 ) {
    a++;
    b--;
  }
  System.out.println(a);
  return a;
}

由于日志的副作用,上面这段代码并不是函数式的。

ps: 这里非常有趣,实际上还是取决于我们预期函数做什么。

引用透明如何让程序更安全

没有副作用(所以并不会改变外界的什么)并不足以让程序变成函数式的。

同样,函数式编程也不能被外界所影响。

换句话说,函数式程序的输出只能取决于自己的参数。

这就意味着函数式代码不能从控制台、文件、远程URL 、数据库甚至是系统里读取数据。不被外界所影响的代码就是引用透明的。

透明代码的意义

引用透明的代码有一些性质对程序员而言很有意思:

  • 它是独立的。

它并不依赖于任何外部的设备来工作。你可以在任何上下文中使用它一一你需要做的一切就是提供一个有效的参数。

  • 它是确定的。

意味着相同的参数总是返回相同的结果。在引用透明的代码中,不会有意外发生。它可能返回一个错误的结果,可至少结果对于相同的参数 而言是绝对不会变化的。

它绝对不会抛出任何种类的Exceptio 口。它可能抛出错误,例如 OOM 或是 SOE,但是这些错误表示代码有bug ,并不是作为程序员的你或是你API 的用户应该处理的(除了让应用程序崩溃井最终修复 BUG)。

  • 任何时候它都不会导致其他代码意外失败。

例如,它不会改变参数或是外界的数据,从而导致调用者发现自己的数据过期或者并发访问异常。

ps: 这里其实就对函数式编程有一个比较大的限制,那就是对于外界的依赖实际上都无法函数式编程。(或许需要个人更深一步的理解)

  • 不受外部设备影响

它不会由于外部设备(数据库、文件系统或网络)不可用、太慢或坏掉而崩溃。

函数式编程的优势

从我刚刚说的那些,你应该可以猜到函数式编程的诸多优势:

优点

  • 确定性

函数式程序更加易于推断,因为它们是确定性( deterministic )的。

对于一个特定的输入总会给出相同的输出。

在许多情况下, 你都可以证明程序是正确的,而不是在大量的测试后仍然不确定程序是否会在意外的情况下出错。

  • 易于测试

函数式程序更加易于测试。

因为没有副作用,所以你不需要那些经常用于在测试里隔离程序及外界的mock。

  • 函数式程序更加模块化,因为它们是由只有输入和输出的函数构建的。

我们不必处理副作用,不必捕获异常,不必处理上下文变化, 不必共享变化的状态,也没有并发的修改。

  • 函数式编程让复合和重新复合更加简单。

为了编写函数式程序,你需要开始编写各种必要的基础函数,并把它们复合为更高级别的函数,重复这个过程直到你拥有了一个与你打算构建的程序一致的函数。

因为所有的函数都是引用透明的,它们无须修改便可以为其他程序所重用。

函数式的程序天生就是线程安全的,因为它们防止了共享状态的变化。

ps: 我觉得这一点是一个非常大的优点,这让并发时代码更加安全。

再重复一遍,这并不意味着所有数据都需要不可变,只有共享的数据才需要。

但是函数式程序员很快就会认识到不可变的数据总是更安全的,即使在外界观测不到这种变化。

用代换模型来推断程序

请记住一个函数什么事情都不做。

它只是有一个值,依赖于参数而己。

因此,永远都可以用其值来替换一个函数调用或是任何引用透明的表达式,就像图 1.3 展示的那样。

3*2 + 4*5 = 26
6 + 4*5 = 26
6 + 20 = 26

当应用函数时,代换模型允许你将任何函数调用替换为它的返回值。

例子

思考如下代码:

public static void main(String[] args) {
   int x = add(multi(2,3), multi(4, 5));
}

public static int add(int a, int b) {
   int result = a+b;
   log(a + " + " + b + " = " + result);
   return result;
}

public static int multi(int a, int b) {
   return a * b;
}

public static void log(final String m) {
   System.out.println(m);
}

对于 multi 的替换,直接改成一个数值是没有影响的。

但是对于 add 直接替换为结果值可能就是有影响的,因为日志产生了影响。

函数式原则应用的例子

购买甜甜圈

public static Donut buyDonut(final CreditCard creditCard) {
    Donut donut = new Donut();
    creditCard.charge(donut.price());
    return donut;
}

这个例子比较简单,但是存在一些问题。

在这段代码中, 信用卡支付是一个副作用。

信用卡支付多半由调用银行、检查信用卡是否可用并己授权、注册交易等组成。函数返回甜甜圈。

这种代码的问题在于难以测试。

测试程序可能需要联系银行并用某个mock 账户来注册交易。要不就得创建一张 mock 信用卡来代替真实的 charge 方法, 并在测试之后验证mock 的状态。

如果你想在无须接触银行或是使用 mock 的情况下测试代码,那就应该移除副作用。

由于你仍然想要用信用卡支付,唯一的解决方案就是往返回值里加个什么东西来表示这个操作。

你的 buyDonut 方法将会返回甜甜圈和表示支付的这个东西。

改进

你可以使用一个 Payment 类来表示支付,如清单1.2 所示。

public class Payment {

    private final CreditCard creditCard;

    private double price;


    public Payment(CreditCard creditCard, double price) {
        this.creditCard = creditCard;
        this.price = price;
    }
}

这里包含了必要的属性。

我们再创建一个标识支付的类,来标识购买甜甜圈这个操作。

public class Purchace {

    private Donut donut;

    private Payment payment;

    public Purchace(Donut donut, Payment payment) {
        this.donut = donut;
        this.payment = payment;
    }
}

你经常会需要一个类来容纳两个(或以上的)值,因为函数式编程替换副作用的方式就是将其返回。

你可以使用被称为Tuple (元组)的通用类,而不必创建一个特定的 Purchase 类。

这个类将会由两种类型的参数组成(Donut 和Payment ) 。清单1.3 展示了它的实现,

以及它在 DonutShop 类里的使用方法。

public static Pair<Donut, Payment> buyDonut2(final CreditCard creditCard) {
    Donut donut = new Donut();
    Payment payment = new Payment(creditCard, donut.price());

    return new Pair<>(donut, payment);
}

请注意,你不必顾虑(在这个时候〉如何真正地用信用卡支付,这样可以给你构建程序带来一些自由。

你仍然可以接着立即支付, 也可以将它保存起来以便后续支付,甚至还可以将一张信用卡里保存的多份待支付记录合并起来,在一个操作里处理完成。

将多份购买记录合并

这样便可以通过减少调用信用卡服务的次数来节省你的开销。

清单 1.4 里的 combine 方法允许你合并支付。

请注意,如果信用卡不一致,将会抛出异常。

这与我说的函数式编程不抛出异常并不矛盾。

在这里,试图合并两张不同信用卡的两份待支付记录,被视为一个bug ,所以必须使应用程序崩溃。

(这样做并不明智。你要到第 7 章之后才能学到如何用不抛出异常的方式来处理这种状况。)

public Payment combine(final Payment other) {
    // 只有同一张卡才进行合并
    if(other.creditCard.equals(this.creditCard)) {
        this.price += other.price;
        return this;
    }
    throw new UnsupportedOperationException("只支持相同信用卡的合并!");
}

一次购买多份

当然,用 combine 方法一次购买多份甜甜圈的效率并不高。

在这种情况下,你可以使用如下代码提升购买效率:

public static Pair<List<Donut>, Payment> buyDonuts(final CreditCard creditCard,
                                                   final int num) {
    return Pair.of(CollectionUtil.fill(num, new Donut()),
            new Payment(creditCard, num*Donut.DEFAULT_PRICE));
}

现在,你的程序不需要 mock 就可以测试了。

让你的程序成为函数式的另外一个好处是它更容易被复合。

如果同一个人用你的初始代码购买多份,你只能一次次地调用银行(并付账)。

通过新的函数式版本,你可以选择是每次购买立即支付还是为同一张信用卡合并支付。

分组

为了把支付分组,你将需要函数式List 类的附加方法(现在并不需要知道这些方法是怎么工作的,你将在第5 章和第8 章中详细地学习它们):

public static List<Payment> groupCreditCard(final List<Payment> paymentList) {
    return paymentList.stream()
            .collect(Collectors.groupingBy((Function<Payment, Object>) Payment::creditCard))
            .values()
            .stream()
            .map(paymentList1 -> paymentList1.stream()
                    .reduce(Payment::combine).get())
            .collect(Collectors.toList());
}

(1)分组

List<Payment> 变成了 Map<creditCard, List<Payment>>

(2)组合

对每一个组的 List 进行 combine 操作,最后的结果返回一个新的 list

抽象到极致

如你所见,函数式编程包含了通过复合没有副作用的纯函数来编写代码。

这些函数可能表示为方法,也可能是一等公民(first-class)的函数,如上例中的 groupBy/map/reduce 方法的参数。

一等公民的函数与方法不同,可以由程序来操作。

在大多数情况下,它们被用作其他函数或方法的参数。你会在第2 章中学到这是如何实现的。

然而这里最重要的概念是抽象

ps: 函数式编程和 OO 的核心其实都是抽象。

看看reduce 方法。它接收一个操作作为参数,并用其将列表化简为单值。这里的操作有两个相同类型的操作对象。除此之外,它 可以是任何操作。

思考一个整型列表。你可以编写一个sum 方法来对成员求和:

可以编写一个 product 方法来对成员求积;还可以编写一个 min 或 max 方法来计算列表的最小值或最大值。

而你可以对所有的这些计算重用 reduce 方法。这就是抽象。

在 reduce 方法中, 你将所有操作的通用部分抽象出来,并传递变量(操作)作为参数。

其实你还可以更进一步。相对生成不同于列表元素类型的更加通用的方法而言,

reduce 方法是一个特例。

例如,可以通过一个字符列表创建出一个 String。

为此你需要从一个给定值(很可能是一个空字符串〉开始。

在第3 章和第5 章中,你将会学到如何开发这个方法(称为 fold)。

还要注意的是, reduce 方法不能在一个空列表中正常工作。

考虑一个整型数组一一如果想要求和,那就需要一个初始元素。

如果列表是空的,你应该返回什么?

当然,你知道结果应该是0 ,但它只适用于求和,并不适用于求积。

同样考虑一下groupByCard 方法。

它看起来像是一个只能用于通过信用卡把支付分组的业务方法。

但是并非如此! 你可以用这个方法来通过任意属性对任意集合里的元素分组, 所以这个方法应该被抽象出来井放到List 类里以使其更容易被重用。

函数式编程的一个非常重要的部分就是将抽象推到极致

抽象!抽象!为了抽象不择手段!

在本书的剩余部分,你将会学到如何抽象许多东西,以至于再也不需要定义它们了。

例如,你将学到如何抽象循环,以至于你再也不需要编写循环了。

你还会学到如何抽象并行化,以至于从顺序执行切换到并行执行仅仅是选择List 类里的一个方法。

小结

本篇内容介绍了函数式编程是什么,为什么需要,优点是什么。

并通过几个例子让我们初步感受到了函数式编程的魅力。

相信后续的内容更加精彩,期待与你一期探索。

参考资料

《java 函数式编程》