18.1 实现和维护系统
假设你被要求对一个大型的遗留软件系统进行升级,而且之前对这个系统并不是非常了解。你是否应该接受维护这种软件系统的工作呢?稍有理智的外包Java程序员只会依赖如下这种言不由衷的格言做决定,“搜索一下代码中有没有使用synchronized关键字,如果有就直接拒绝(由此我们可以了解修复并发导致的缺陷有多困难),否则进一步看看系统结构的复杂程度”。我们会在下面内容中提供更多的细节,但是你发现了吗,正如前面几章所讨论的,如果你喜欢无状态的行为(即你处理Stream的流水线中的函数不会由于需要等待从另一个方法中读取变量,或者由于需要写入的变量同时有另一个方法正在写而发生中断),那么Java 8中新增的Stream提供了强大的技术支撑,让我们无须担心锁引起的各种问题,充分发掘系统的并发能力。
为了让程序易于使用,你还希望它具备哪些特性呢?你会希望它具有良好的结构,最好类的结构应该反映出系统的结构,这样能便于大家理解;甚至软件工程中还提供了指标,对结构的合理性进行评估,比如耦合性(软件系统中各组件之间是否相互独立)以及内聚性(系统的各相关部分之间如何协作)。
不过,对大多数程序员而言,最关心的日常要务是代码维护时的调试:代码遭遇一些无法预期的值就有可能发生崩溃。为什么会发生这种情况?它是如何进入到这种状态的?想想看你有多少代码维护的顾虑都能归咎到这一类!1 很明显,函数式编程提出的无副作用以及不变性对于解决这一难题是大有裨益的。让我们就此展开进一步的探讨。
1推荐你阅读Michael Feathers的Working Effectively with Legacy Code详细了解这个话题。
18.1.1 共享的可变数据
最终,刚才讨论的无法预知的变量修改问题,都源于共享的数据结构被你所维护的代码中的多个方法读取和更新。假设几个类同时都保存了指向某个列表的引用,那么到底谁对这个列表拥有所属权呢?如果一个类对它进行了修改,会发生什么情况?其他的类预期会发生这种变化吗?其他的类又如何得知列表发生了修改呢?需要将这一变化通知给使用该列表的所有类吗?抑或是不是每个类都应该为自己准备一份防御式的数据备份以备不时之需呢?
换句话说,由于使用了可变的共享数据结构,我们很难追踪你程序的各个组成部分所发生的变化。图18-1解释了这一问题。

图 18-1 多个类同时共享的一个可变对象。我们很难说到底哪个类真正拥有该对象
假设有这样一个系统,它不修改任何数据。维护这样的系统将是一个无以伦比的美梦,因为你不再会收到任何由于某些对象在某些地方修改了某个数据结构而导致的意外报告。如果一个方法既不修改它内嵌类的状态,也不修改其他对象的状态,使用return返回所有的计算结果,那么我们称其为纯粹的或者无副作用的。
更确切地讲,到底哪些因素会造成副作用呢?简而言之,副作用就是函数的效果已经超出了函数自身的范畴。下面是一些例子。
- 除了构造器内的初始化操作,对类中数据结构的任何修改,包括字段的赋值操作(一个典型的例子是
setter方法)。 - 抛出一个异常。
- 进行输入/输出操作,比如向一个文件写数据。
从另一个角度来看无副作用的话,就应该考虑不可变对象。不可变对象是这样一种对象,它们一旦完成初始化就不会被任何方法修改状态。这意味着一旦一个不可变对象初始化完毕,它永远不会进入到一个无法预期的状态。你可以放心地共享它,无须保留任何副本,并且由于它们不会被修改,所以还是线程安全的。
无副作用这个想法的限制看起来很严苛,你甚至可能会质疑是否有真正的生产系统能够以这种方式构建。希望结束本章的学习之后,你能够确信这一点。一个好消息是,如果构成系统的各个组件都能遵守这一原则,该系统就能在完全无锁的情况下,使用多核的并发机制,因为任何一个方法都不会对其他的方法造成干扰。此外,这还是一个让你了解你的程序中哪些部分是相互独立的非常棒的机会。
这些思想都源于函数式编程,下一节会进行介绍。但是在开始之前,先来看看函数式编程的基石声明式编程吧。
18.1.2 声明式编程
一般通过编程实现一个系统有两种思考方式。一种专注于如何实现,比如:“首先做这个,紧接着更新那个,然后……”。举个例子,如果你希望通过计算找出列表中最昂贵的事务,那么通常需要执行一系列的命令:从列表中取出一个事务,将其与临时最昂贵事务进行比较;如果该事务开销更大,就将临时最昂贵的事务设置为该事务;接着从列表中取出下一个事务,并重复上述操作。
这种“如何做”风格的编程非常适合经典的面向对象编程,有些时候也称之为命令式编程,因为它的特点是它的指令和计算机底层的词汇非常相近,比如赋值、条件分支以及循环,就像下面这段代码:
Transaction mostExpensive = transactions.get(0);if(mostExpensive == null)throw new IllegalArgumentException("Empty list of transactions");for(Transaction t: transactions.subList(1, transactions.size())){if(t.getValue() > mostExpensive.getValue()){mostExpensive = t;}}
另一种方式则更加关注要做什么。你在第4章和第5章中已经看到,使用Stream API可以指定下面这样的查询:
Optional<Transaction> mostExpensive =transactions.stream().max(comparing(Transaction::getValue));
这个查询把最终如何实现的细节留给了函数库。我们把这种思想称之为内部迭代。它的巨大优势在于你的查询语句现在读起来就像是问题陈述,由于采用了这种方式,我们马上就能理解它的功能,比理解一系列的命令要简洁得多。
采用这种“要做什么”风格的编程通常被称为声明式编程。你制定规则,给出了希望实现的目标,让系统来决定如何实现这个目标。它带来的好处非常明显,因为用这种方式编写的代码更加接近问题陈述了。
18.1.3 为什么要采用函数式编程
函数式编程具体实践了前面介绍的声明式编程(“你只需要使用不相互影响的表达式,描述想要做什么,由系统来选择如何实现”)和无副作用计算。正如前面所讨论的,这两个思想能帮助你更容易地构建和维护系统。
同时也请注意,我们在第3章中使用Lambda表达式介绍的内容,即一些语言的特性,比如构造操作和传递行为对于以自然的方式实现声明式编程是必要的,它们能让我们的程序更便于阅读,易于编写。你可以使用Stream将几个操作串接在一起,表达一个复杂的查询。这些都是函数式编程语言的特性。我们在19.5节中介绍结合器时会更加深入地介绍这些内容。
为了让你有更直观的感受,我们会结合Java 8介绍这些语言的新特性,现在我们会具体给出函数式编程的定义,以及它在Java语言中的表述。我们希望表达的是,使用函数式编程,你可以实现更加健壮的程序,还不会有任何的副作用。
