假設你要在一個交易(事務)處理系統中編寫一個新組件。這個系統的簡化視圖如圖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 OK
1R6GV OK
1R6GW OK
main_ref: 1R6H2 not present in DB
main_ref: 1R6H3 not present in DB
1R6H6 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上可用的語言類型開始(有時候我們也把這稱為語言生態學)。