假设你要在一个交易(事务)处理系统中编写一个新组件。这个系统的简化视图如图7-1所示。
图7-1 交易处理系统的例子
在图中可以看到,系统有两个数据源:上游的收单系统(可以通过Web服务查询)和下游的派发数据库。
这是一个很现实的系统,是Java开发人员经常构建的系统。我们在这一节里准备引入一小段代码把两个数据源整合起来。你会看到Java解决这个问题有点笨拙。之后我们会介绍函数式编程的一个核心概念,并展示一下怎么用映射(map)和过滤器(filter)等函数式特性简化很多常见的编程任务。你会看到Java由于缺乏对这些特性的直接支持,编程会困难不少。
7.1.1 整合系统
我们需要一个整合系统来检查数据确实到了数据库。这个系统的核心是reconcile
方法,它有两个参数:sourceData
(来自于Web服务的数据,归结到一个Map
中)和dbIds
。
你需要从sourceData
中取出main_ref
键值,用它跟数据库记录的主键比较。代码清单7-1是进行比较的代码。
代码清单7-1 整合两个数据源
public void reconcile(List<Map<String, String>> sourceData,Set<String> dbIds) { Set<String> seen = new HashSet <String>; MAIN: for (Map<String, String> row : sourceData) { String pTradeRef = row.get(/"main_ref/"); //假定pTradeRef永远不会为null if (dbIds.contains(pTradeRef)) { System.out.println(pTradeRef +/" OK/"); seen.add(pTradeRef); } else { System.out.println(/"main_ref: /"+ pTradeRef +/" not present in DB/"); } } for (String tid : dbIds) { //特殊情况 if (!seen.contains(tid)) { System.out.println(/"main_ref: /"+ tid +/" seen in DB but not Source/"); } }}
这里主要是检查收单系统中的所有订单是否都出现在派发数据库里。这项检查由打上了MAIN
标签的for
循环来做。
还有另外一种可能。比如有个实习生通过管理界面做了些测试订单(他没意识到这些订单用的是生产系统)。这样订单数据会出现在派发数据库里,但不会出现在收单系统中。
为了处理这种特殊情况,还需要一个循环。这个循环要检查所见到的集合(同时出现在两个系统中的交易)是否包含了数据库中的全部记录。它还会确认那些遗漏项。下面是这个样例的一部分输出:
7172329 OK1R6GV OK1R6GW OKmain_ref: 1R6H2 not present in DBmain_ref: 1R6H3 not present in DB1R6H6 OK
哪儿出错了?原来是上游系统不区分大小写而下游系统区分,在派发数据库里表示为1R6H12
的记录实际上是1r6h2
。
如果你检查一下代码清单7-1,就会发现问题出在contains
方法上。contains
方法会检查其参数是否出现在目标集合中,只有完全匹配时才会返回true
。
也就是说其实你应该用containsCaseInsensitive
方法,可这是一个根本就不存在的方法!所以你必须把下面这段代码
if (dbIds.contains(pTradeRef)) { System.out.println(pTradeRef +/" OK/"); seen.add(pTradeRef);} else { System.out.println(/"main_ref: /"+ pTradeRef +/" not present in DB/");}
换成这样的循环:
for (String id : dbIds) { if (id.equalsIgnoreCase(pTradeRef)) { System.out.println(pTradeRef +/" OK/"); seen.add(pTradeRef); continue MAIN; }}System.out.println(/"main_ref: /"+ pTradeRef +/" not present in DB/");
这看起来比较笨重。只能在集合上执行循环操作,不能把它当成一个整体来处理。代码既不简洁,又似乎很脆弱。
随着应用程序逐渐变大,简洁会变得越来越重要——为了节约脑力,你需要简洁的代码。
7.1.2 函数式编程的基本原理
希望上面的例子中的两个观点引起了你的注意。
- 将集合作为一个整体处理要比循环遍历集合中的内容更简洁,通常也会更好。
- 如果能在对象的现有方法上加一点点逻辑来调整它的行为是不是很棒呢?
如果你遇到过那种基本就是你需要,但又稍微差点儿意思的集合处理方法,你就明白不得不再写一个方法是多么沮丧了,而函数式编程(FP)恰好搔到了这个痒处。
换种说法,简洁(并且安全)的面向对象代码的主要限制就是,不能在现有方法上添加额外的逻辑。这将我们引向了FP的大思路:假定确实有办法向方法中添加自己的代码来调整它的功能。
这意味着什么?要在已经固定的代码中添加自己的处理逻辑,就需要把代码块作为参数传到方法中。下面这种代码才是我们真正想要的(为了突出,我们把这个特殊的contains
方法加粗了):
if (dbIds.contains(pTradeRef, matchFunction)) { System.out.println(pTradeRef +/" OK/"); seen.add(pTradeRef);} else { System.out.println(/"main_ref: /"+ pTradeRef +/" not present in DB/");}
如果能这样写,contains
方法就能做任何检查,比如匹配区分大小写。这需要能把匹配函数表示成值,即能把一段代码写成“函数字面值”并赋值给一个变量。
函数式编程要把逻辑(一般是方法)表示成值。这是FP的核心思想,我们还会再次讨论,先看一个带点儿FP思想的Java例子。
7.1.3 映射与过滤器
我们把例子稍微展开一些,并放在调用reconcile
的上下文中:
reconcile(sourceData, new HashSet<String>(extractPrimaryKeys(dbInfos)));private List<String> extractPrimaryKeys(List<DBInfo> dbInfos) { List<String> out = new ArrayList<>; for (DBInfo tinfo : dbInfos) { out.add(tinfo.primary_key); } return out;}
extractPrimaryKeys
方法返回从数据库对象中取出的主键值(字符串)列表。FP粉管这叫map
表达式:extractPrimaryKeys
方法按顺序处理List
中的每个元素,然后再返回一个List
。上面的代码构建并返回了一个新列表。
注意,返回的List
中元素的类型(String
)可能跟输入的List
中元素的类型(DBInfo
)不同,并且原始列表不会受到任何影响。
这就是“函数式编程”名称的由来,函数的行为跟数学函数一样。函数f(x)=x*x
不会改变输入值2,只会返回一个不同的值4。
便宜的优化技巧
调用
reconcile
时,有个实用但小有难度的技巧:把extractPrimaryKeys
返回的List
传入HashSet
构造方法中,变成Set
。这样可以去掉List
中的重复元素,reconcile
方法调用的contains
可以少做一些工作。
map
是经典的FP惯用语。它经常和另一个知名模式成对出现:filter
形态,请看代码清单7-2。
代码清单7-2 过滤器形态
List<Map<String, String>> filterCancels(List<Map<String, String>> in) { List<Map<String, String>> out = new ArrayList<>; //防御性复制 for (Map<String, String> msg : in) { if (!msg.get(/"status/").equalsIgnoreCase(/"CANCELLED/")) { out.add(msg); } } return out;}
注意其中的防御性复制,它的意思是我们返回了一个新的List
实例。这段代码没有修改原有的List
(filter
的行为跟数学函数一样)。它用一个函数测试每个元素,根据函数返回的boolean
值构建新的List
。如果测试结果为true
,就把这个元素添加到输出List
中。
为了使用过滤器,还需要一个函数来判断是否应该把某个元素包括在内。你可以把它想象成一个向每个元素提问问题的函数:“我应该允许你通过过滤器吗?”
这种函数叫做谓词函数(predicate function)。这里有一个用伪代码(几乎就是Scala)编写的方法:
(msg) -> { !msg.get(/"status/").equalsIgnoreCase(/"CANCELLED/") }
这个函数接受一个参数(msg
)并返回boolean
值。如果msg
被取消了,它会返回false
,否则返回true
。用在过滤器中时,它会过滤掉所有被取消的消息。
这就是你想要的。在调用整合代码之前,你需要移除所有被取消的订单,因为被取消的订单不会出现在派发数据库中。
事实上, Java 8准备采用这种写法(受到了Scala和C#语法的强烈影响)。我们在第14章还会讨论这个主题,但在那之前我们会遇到几次函数字面值(也称为lambda表达式)。
我们接着往下看,讨论一下其他情况,从JVM上可用的语言类型开始(有时候我们也把这称为语言生态学)。