簡潔Java之道

longmanma發表於2021-09-09

計算機專家在問題求解時非常重視表示式簡潔性的價值。Unix的先驅者Ken Thompson曾經說過非常著名的一句話:“丟棄1000行程式碼的那一天是我最有成效的一天之一。”這對於任何一個需要持續支援和維護的軟體專案來說,都是一個當之無愧的目標。早期的Lisp貢獻者Paul Graham甚至將語言的簡潔性等同為語言的能力。這種對能力的認識讓可以編寫緊湊、簡介的程式碼成為許多現代軟體專案選擇語言的首要標準。

任何程式都可以通過重構,去除多餘的程式碼或無用的佔位符,如空格,變得更加簡短,不過某些語言天生就善於表達,也就特別適合於簡短程式的編寫。認識到這一點之後,Perl程式設計師普及了程式碼高爾夫競賽;其目標是用盡可能短的程式碼量解決某一特定的問題或者實現某個指定的演算法。APL語言的設計理念是利用特殊的圖形符號讓程式設計師用很少量的程式碼就可以編寫功能強大的程式。這類程式如果實現得當,可以很好地對映成標準的數學表示式。簡潔的語言在快速建立小指令碼時非常高效,特別是在目的不會被簡潔所掩蓋的簡潔明確的問題域中。

相比於其他程式設計語言,Java語言的冗長已經名聲在外。其主要原因是由於程式開發社群中所形成的慣例,在完成任務時,很多情況下,要更大程度地考慮描述性和控制。例如,長期來看,長變數名會讓大型程式碼庫的可讀性和可維護性更強。描述性的類名通常會對映為檔名,在向已有系統中增加新功能時,會顯得很清晰。如果能夠一直堅持下去,描述性名稱可以極大簡化用於表明應用中某一特定的功能的文字搜尋。這些實踐讓Java在大型複雜程式碼庫的大規模實現中取得了極大的成功。

對於小型專案來說,簡潔性則更受青睞,某些語言非常適於短指令碼編寫或者在命令提示符下的互動式探索程式設計。Java作為通用性語言,則更適用於編寫跨平臺的工具。在這種情況下,“冗長Java”的使用並不一定能夠帶來額外的價值。雖然在變數命名等方面,程式碼風格可以改變,不過從歷史情況來看,在一些基本的層面上,與其他語言相比,完成同樣的任務,Java語言仍需更多的字元。為了應對這些限制,Java語言一直在不斷地更新,以包含一些通常稱為“語法糖”的功能。用這些習語可以實現更少的字元表示相同功能的目標。與其對應的更加冗長的配對物相比,這些習語更受程式開發社群的歡迎,通常會被社群作為通用用法快速地採用。

本文將著重介紹編寫簡潔Java程式碼的最佳實踐,特別是關於JDK8中新增的功能。簡而言之,Java 8中Lambda表示式的引入讓更加優雅的程式碼成為可能。這在用新的Java Streaming API處理集合時尤其明顯。

冗長的Java

Java程式碼冗長之所以名聲在外,一部分原因是由於其物件導向的實現風格。在許多語言中,只需要一行包含不超過20個字元的程式碼就可以實現經典的“Hello World”程式示例。而在Java中,除了需要類定義中所包含的main方法之外,在main方法中還需要包含一個方法呼叫,通過System.out.println()將字串列印到終端。即使在使用最少的方法限定詞、括號和分號,並且將所有空格全都刪除的極限情況下,“Hello World”程式最少也需要86個字元。為了提高可讀性,再加上空格和縮排,毋庸置疑,Java版的“Hello World”程式給人的第一印象就是冗長。

Java程式碼冗長一部分原因還應歸咎於Java社群將描述性而非簡潔性作為其標準。就這一點而言,選擇與程式碼格式美學相關的不同標準是無關緊要的。此外,樣板程式碼的方法和區段可以包含在整合到API中的方法中。無需犧牲準確性或清晰度,著眼於簡潔性的程式程式碼重構可以大大簡化冗餘Java程式碼。

有些情況下,Java程式碼冗長之所以名聲在外是由於大量的老舊程式碼示例所帶來的錯覺。許多關於Java的書籍寫於多年之前。由於在整個全球資訊網最初興起時,Java便已經存在,許多Java的線上資源所提供的程式碼片段都源自於Java語言最早的版本。隨著時間的推移,一些可見的問題和不足不斷得到完善,Java語言也日趨成熟,這也就導致即使十分準確並實施的當的案例,可能也未能有效利用後來的語言習語和API。

Java的設計目標包括物件導向、易於上手(在當時,這意味著使用C++格式的語法),健壯、安全、可移植、多執行緒以及高效能。簡潔並非其中之一。相比於用物件導向語法實現的任務,函式式語言所提供的替代方案要簡潔的多。Java 8中新增的Lambda表示式改變了Java的表現形式,減少了執行許多通用任務所需的程式碼數量,為Java開啟了函數語言程式設計習語的大門。

函數語言程式設計

函數語言程式設計將函式作為程式開發人員的核心結構。開發人員可以以一種非常靈活的方式使用函式,例如將其作為引數傳遞。利用Lambda表示式的這種能力,Java可以將函式作為方法的引數,或者將程式碼作為資料。Lambda表示式可以看作是一個與任何特定的類都無關的匿名方法。這些理念有著非常豐富多彩並且引人入勝的數學基礎。

函數語言程式設計和Lambda表示式仍然是比較抽象、深奧的概念。對於開發人員來說,主要關注如何解決實際生產中的任務,對於跟蹤最新的計算趨勢可能並不感興趣。隨著Lambda表示式在Java中的引入,對於開發人員來說對這些新特性的瞭解至少需要能夠達到可以讀懂其他開發人員所編寫程式碼的程度。這些新特性還能帶來實際的好處——可以影響併發系統的設計,使其擁有更優的效能。而本文所關心的是如何利用這些機制編寫簡潔而又清晰的程式碼。

之所以能夠用Lambda表示式生成簡潔的程式碼,有如下幾個原因。區域性變數的使用量減少,因此宣告和賦值的程式碼也隨之減少。迴圈被方法呼叫所替代,從而將三行以上的程式碼縮減為一行。本來在巢狀迴圈和條件語句中的程式碼現在可以放置於一個單獨的方法中。實現連貫介面,可以將方法以類似於Unix管道的方式連結在一起。以函式式的風格編寫程式碼的淨效應並不只限於可讀性。此類程式碼可以避免狀態維護並且不會產生副作用。這種程式碼還能夠產生易於並行化,提高處理效率的額外收益。

Lambda表示式

與Lambda表示式相關的語法比較簡單直白,不過又有別於Java之前版本的習語。一個Lambda表示式由三部分組成,引數列表、箭頭和主體。引數列表可以包含也可以不包含括號。此外還新增了由雙冒號組成的相關操作符,可以進一步縮減某些特定的Lambda表示式所需的程式碼量。這又稱為方法引用。

執行緒建立

在這個示例中,將會建立並執行一個執行緒。Lambda表示式出現在賦值操作符的右側,指定了一個空的引數列表,以及當執行緒執行時寫到標準輸出的簡單的訊息輸出。

Runnable r1 = () -> System.out.print("Hi!");

r1.run()

引數列表

箭頭

主體

()

->

System.out.print(“Hi!”);

處理集合

Lambda表示式的出現會被開發人員注意到的首要位置之一就是與集合API相關。假設我們需要將一個字串列表根據長度排序。

java.util.List<String> l;
l= java.util.Arrays.asList(new String[]{"aaa", "b", "cccc", "DD"});

可以建立一個Lambda表示式實現此功能。

java.util.Collections.sort(l, (s1, s2) ->
       new Integer(s1.length()).
           compareTo(s2.length())

這個示例中包含兩個傳遞給Lambda表示式體的引數,以比較這兩個引數的長度。

引數列表

箭頭

主體

(s1, s2) -> new Integer(s1.length()).compareTo(s2.length()));

除此之外還有許多替代方案,在無需使用標準的“for”或“while”迴圈的前提下,就可以操作列表中的各個元素。通過向集合的“forEach”方法傳入Lambda表示式也可以完成用於比較的語義。這種情況下,只有一個引數傳入,也就無需使用括號。

l.forEach(e -> System.out.println(e));

Argument List

Arrow

Body

e -> System.out.println(e)

這個特殊的示例還可以通過使用方法引用將包含類和靜態方法分開的方式進一步減少程式碼量。每個元素都會按順序傳入println方法。

l.forEach(System.out::println)

java.util.stream是在Java 8中新引入的包,以函式式程式開發人員所熟悉的語法處理集合。在包的摘要中對包中的內容解釋如下:“為流元素的函式式操作提供支援的類,如對集合的map-reduce轉換。”

下方的類圖提供了對該包的一個概覽,著重介紹了接下來的示例中將要用到的功能。包結構中列示了大量的Builder類。這些類與連貫介面一樣,可以將方法連結成為管道式的操作集。

字串解析和集合處理雖然簡單,在真實世界中仍有許多實際應用場景。在進行自然語言處理(NLP)時,需要將句子分割為單獨的詞。生物資訊學將DNA和RNA表示為有字母組成的鹼基,如C,G,A,T或U。在每個問題領域中,字串物件會被分解,然後針對其各個組成部分進行操作、過濾、計數以及排序等操作。因此,儘管示例中所包含的用例十分簡單,其理念仍適用於各類有實際意義的任務。

下方的示例程式碼解析了一個包含一個句子的字串物件,並統計單詞的數量和感興趣的字母。包括空白行在內,整個程式碼清單的行數不超過70行。

1. import java.util.*;
2.
3. import static java.util.Arrays.asList;
4. import static java.util.function.Function.identity;
5. import static java.util.stream.Collectors.*;
6. 
7. public class Main {
8. 
9.    public static void p(String s) {
10.        System.out.println(s.replaceAll("[\\]\\[]", ""));
11.   }
12.
13.   private static List uniq(List letters) {
14.        return new ArrayList(new HashSet(letters));
15.   }
16.
17.    private static List sort(List letters) {
18.        return letters.stream().sorted().collect(toList());
19.    }
20.
21.    private static  Map uniqueCount(List letters) {
22.        return letters.stream().
23.                collect(groupingBy(identity(), counting()));
24.    }
25.
26.    private static String getWordsLongerThan(int length, List words) {
27.        return String.join(" | ", words
28.                        .stream().filter(w -> w.length() > length)
29.                        .collect(toList())
30.        );
31.    }
32.
33.    private static String getWordLengthsLongerThan(int length, List words)
34.    {
35.        return String.join(" | ", words
36.                .stream().filter(w -> w.length() > length)
37.                .mapToInt(String::length)
38.                .mapToObj(n -> String.format("%" + n + "s", n))
39.                .collect(toList()));
40.    }
41.
42.    public static void main(String[] args) {
43.
44.        String s = "The quick brown fox jumped over the lazy dog.";
45.        String sentence = s.toLowerCase().replaceAll("[^a-z ]", "");
46.
47.        List words = asList(sentence.split(" "));
48.        List letters = asList(sentence.split(""));
49.
50.        p("Sentence : " + sentence);
51.        p("Words    : " + words.size());
52.        p("Letters  : " + letters.size());
53.
54.        p("\nLetters  : " + letters);
55.        p("Sorted   : " + sort(letters));
56.        p("Unique   : " + uniq(letters));
57.
58.        Map m = uniqueCount(letters);
59.        p("\nCounts");
60.
61.        p("letters");
62.        p(m.keySet().toString().replace(",", ""));
63.        p(m.values().toString().replace(",", ""));
64.
65.        p("\nwords");
66.        p(getWordsLongerThan(3, words));
67.        p(getWordLengthsLongerThan(3, words));
68.     }
69. }

示例程式執行輸出:

Sentence : the quick brown fox jumped over the lazy dog
Words    : 9
Letters  : 44

Letters  : t, h, e,  , q, u, i, c, k,  , b, r, o, w, n,  , f, o, x,  , j, u, m, p, e, d,  , o, v, e, r,  , t, h, e,  , l, a, z, y,  , d, o, g
Sorted   :  ,  ,  ,  ,  ,  ,  ,  , a, b, c, d, d, e, e, e, e, f, g, h, h, i, j, k, l, m, n, o, o, o, o, p, q, r, r, t, t, u, u, v, w, x, y, z
Unique   :  , a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, t, u, v, w, x, y, z

Counts
letters
  a b c d e f g h i j k l m n o p q r t u v w x y z
8 1 1 1 2 4 1 1 2 1 1 1 1 1 1 4 1 1 2 2 2 1 1 1 1 1

words
quick | brown | jumped | over | lazy
    5 |     5 |      6 |    4 |    4

上述程式碼已經經過了多重精簡。其中一些方式並非在各個版本的Java中都可行,而且有些方式可能並不符合公認的編碼風格指南。思考一下在較早版本的Java中如何才能夠獲得相同的輸出?首先,需要建立許多區域性變數用於臨時儲存資料或作為索引。其次,需要通過許多條件語句和迴圈告知Java如何處理資料。新的函數語言程式設計方式更加專注於需要什麼資料,而並不關心與其相關的臨時變數、巢狀迴圈、索引管理或條件語句的處理。

在某些情況下,採用早期版本中的標準Java語法以減少程式碼量是以犧牲清晰度為代價的。例如,示例程式碼第一行的標準import語句中的Java包引用了java.util下的所有類,而不是根據類名分別引用。對System.out.println的呼叫被替換為對一個名為p的方法的呼叫,這樣在每次方法呼叫時都可以使用短名稱(行9-11)。由於可能違反某些Java的編碼規範,這些改變富有爭議,不過有著其他背景的程式開發人員檢視這些程式碼時可能並不會有何問題。

另外一些情況下,則利用了從JDK8預覽版才新增的功能特性。靜態引用(行3-5)可以減少內聯所需引用的類的數量。而正規表示式(行10,45)則可以用與函數語言程式設計本身無關的方式,有效隱藏迴圈和條件語句。這些習語,特別是正規表示式的使用,經常會因為難以閱讀和說明而受到質疑。如果運用得當,這些習語可以減少噪音的數量,並且能夠限制開發人員需要閱讀和說明的程式碼數量。

最後,示例程式碼利用了JDK 8中新增的Streaming API。使用了Streaming API中大量的方法對列表進行過濾、分組和處理(行17-40)。儘管在IDE中它們與內附類的關聯關係很清晰,不過除非你已經很熟悉這些API,否則這種關係並不是那麼顯而易見。下表展示了示例程式碼中所出現的每一次方法呼叫的來源。

方法

完整的方法名稱引用

stream() java.util.Collection.stream()
sorted() java.util.stream.Stream.sorted()
collect() java.util.stream.Stream.collect()
toList() java.util.stream.Collectors.toList()
groupingBy() java.util.stream.Collectors.groupingBy()
identity() java.util.function.Function.identity()
counting() java.util.stream.Collectors.counting()
filter() java.util.stream.Stream.filter()
mapToInt() java.util.stream.Stream.mapToInt()
mapToObject() java.util.stream.Stream.mapToObject()

uniq()(行13)和sort()(行17)方法體現了同名的Unix實用工具的功能。sort引入了對流的第一次呼叫,首先對流進行排序,然後再將排序後的結果收集到列表中。UniqueCount()(行21)與uniq -c類似,返回一個map物件,其中每個鍵是一個字元,每個值則是這個字元出現次數的統計。兩個“getWords”方法(行26和行33)用於過濾出比給定長度短的單詞。getWordLengthsLongerThan()方法呼叫了一些額外的方法,將結果格式化並轉換成不可修改的String物件。

整段程式碼並未引入任何與Lambda表示式相關的新概念。之前所介紹的語法只適用於Java Stream API特定的使用場景。

總結

用更少的程式碼實現同樣任務的理念與愛因斯坦的理念一致:“必須儘可能地簡潔明瞭,但又不能簡單地被簡單。”Lambda表示式和新的Stream API因其能夠實現擴充套件性良好的簡潔程式碼而備受關注。它們讓程式開發人員可以恰當地將程式碼簡化成最好的表現形式。

函數語言程式設計習語的設計理念就是簡短,而且仔細思考一下就會發現許多可以讓Java程式碼更加精簡的場景。新的語法雖然有點陌生但並非十分複雜。這些新的功能特性清晰地表明,作為一種語言,Java已經遠遠超越其最初的目標。它正在用開放的態度接受其他程式設計語言中最出色的一些功能,並將它們整合到Java之中。

相關文章