

21CTO導讀:指令式程式設計:一種軟體開發範例,其中解決問題所需的每個步驟中的函式都隱式編碼。宣告式程式設計:一種抽象軟體執行操作所需邏輯的控制流的方法。相反,它涉及說明任務或期望結果是什麼。
宣告式程式設計是一種特殊的魔法:你只需描述自己想要的邏輯,它就會發生。你無需擔心執行的細節,編譯器會解決它。
你的程式碼現在會變得簡單而優雅。它可以輕鬆被其他程式設計師閱讀、審查或貢獻。只要確保你正確地指定了最小邏輯,剩下的就很簡單了。這絕對是切片面包以來最好的東西,對不對?
嗯……也不完全是,或者至少——不總是。當你只解釋要做什麼時,“如何”仍然是一個謎,程式設計師應該害怕有其它謎團。
流(Stream)
讓我們舉個例子——Java流。
流是在十多年前的Java 8中釋出的,它們提供了一種抽象,用於迭代集合,而無需將它們儲存在內部資料結構中。
此外,流本質上是函式式的,並允許惰性求值。它們為對映、過濾、分組和縮減等操作提供了豐富的功能——但很難理解其背後發生了什麼。
以並行為例——一個返回等效並行流的函式。文件沒有提到這種並行性的實現方式。雖然想象出一種用於流式傳輸陣列或列表的實現非常容易,但並行流的實現並不那麼簡單,並且不能保證所有 Java 提供方都以相同的方式實現它。
即使在抽象的流世界中,你仍然需要明確要求流是並行的,因為它們預設是連續的。你怎麼知道何時使用並行流?嗯,顯然你有兩個考慮併發性的常規因素:我是否有足夠的資料(流的大小),對每個元素執行的操作是否足夠密集?如果答案是否定的,效能甚至可能會下降,因為並行執行的好處不會抵消執行緒的開銷。對於流,在考慮開銷時還有第三個因素:將流拆分為子流的成本。
根據Oracle 的說法:“諸如 ArrayList、HashMap 或簡單陣列之類的集合可以有效地拆分,而 LinkedList 或基於 I/O 的資料來源在這方面效率最低。”
需要做出的另一個決定是如何宣告您的邏輯。流的語法允許以幾種等效方式執行相同的操作,例如,要對 Long 與流求和,你可以執行以下任一操作:
stream.mapToLong(Long::longValue).sum()
stream.reduce(0L, Long::sum)0L, Long::sum)
stream.collect(Collectors.summingLong(Long::longValue))
final LongAdder longAdder = new LongAdder();
stream.forEach(longAdder::add);
很重要的是,需要對程式碼進行基準測試。但是,沒有樣板程式碼的問題在於你無法對其進行最佳化。
如果效能不夠好,你可以嘗試不同的流操作或從另一個角度解決問題——但你無法改變迭代的執行方式。
來玩一個小遊戲。我將對以下情況進行基準測試,看看你是否能猜出哪一個更有效率。結果在頁面底部。
private static final long MIN_VALUE = 0L;
private static final long MAX_VALUE = 10_000L;
...
// With or without "parallel" (simple calculation)?
final long streamNoParallelResult = LongStream.range(MIN_VALUE, MAX_VALUE).sum();
final long streamParallelResult = LongStream.range(MIN_VALUE, MAX_VALUE).parallel().sum();
// Assume both list have the exact same elements as the range above
// ArrayList or LinkedList? map or reduce?
final long arrayListResult = arrayList.stream().mapToLong(Long::longValue).sum();
final long linkedListResult = linkedList.stream().mapToLong(Long::longValue).sum();
final long linkedListReduceResult = linkedList.stream().reduce(0L, Long::sum);
// With or without "parallel" (complicated calculation)?
final long arrayListParallelHeavyResult = arrayList
.parallelStream()
.map(this::heavyOperation)
.mapToLong(Long::longValue)
.sum();
final long linkedListParallelHeavyResult = linkedList=
.parallelStream()
.map(this::heavyOperation)
.mapToLong(Long::longValue)
.sum();
final long arrayListHeavyResult = arrayList
.stream()
.map(this::heavyOperation)
.mapToLong(Long::longValue)
.sum();
劇透:使用stream.range是非常高效的;即使沒有並行性,ArrayList 也比 LinkedList 更好(這並不奇怪,因為 ArrayList 使用連續記憶體,這對於讀取和快取非常有用);除非你的任務繁重,否則不應使用 parallelStream,並且 Stream<Long> 的 Reduce 與將其對映到 LongStream 並應用 sum 方法之間沒有顯著差異。
同樣值得一提的是,此基準測試是按順序執行的,因此在實際應用中,其他執行緒可能不太可用,從而使並行性的好處不那麼明顯。
在基準測試中,我們發現即使是非常小的變化,比如單個單詞並行,效能也會發生巨大變化。程式設計師和審閱者都很容易忽略它(它的存在或缺失),這使得流有點效能風險。
但是,程式碼的某些部分對單元測試等效能問題不太敏感。沒有人關心它們的執行方式,只要它們最終是綠色的(並且不會將 CI/CD 延遲太久)。認真地問一下我的審閱者,他們可以毫無理由地流式傳輸 TreeList ,或者使用可能在將來使用Bogosort實現的排序方法的神秘呼叫。只要它清晰並正確涵蓋所有情況,那就沒有問題。
註解
單元測試中常用的另一種宣告性手段是註解。我真的不關心 Junit 如何執行用@Test註解的方法,也不關心 Mockito 如何初始化@mock欄位,只要它始終有效就行。有時簡單的程式碼比高效的程式碼更重要。
但是,宣告性註釋也用於程式碼中更關鍵的部分,例如 Spring 的@Controller(用於微服務)和@RequestMapping(用於控制器的節點)。
在這種情況下,實現的效率很重要,忽略潛在的瓶頸是有風險的。在選擇框架之前,你應該嘗試對其進行基準測試,測試其他替代方案,或者編寫自己的實現,例如 Outbrain 的內部ob1k。一旦我決定使用框架,我別無選擇,只能按預期使用它。在這種情況下,一旦選擇了框架,擁有一個簡單的註釋而沒有樣板程式碼正是我想要的。
值得一提的是,註釋也有其自身的缺點:它們在執行時進行解釋,增加了出現執行時錯誤的風險,而這些錯誤可能在編譯期間就被檢測到,例如錯誤的型別或錯誤的值。它們很難測試:雖然 Java 程式碼可以在單元測試中輕鬆呼叫和測試,但註釋可能會悄無聲息地失敗,並且驗證是否存在所有正確的註釋(並且只有它們)很棘手,而且通常需要使用處理它們的程式碼,這可能會複雜得多。最後,很難在功能上擴充套件註釋或將它們與其他註釋組合在一起。
結論
半個世紀前,瑪格麗特·漢密爾頓和她的團隊為阿波羅飛行系統編寫了 145,000 行彙編程式碼。

我並不指望你們中的任何人會因為低階語言的必要性而遷移你們基於 Java 的現代微服務或基於 Python 的機器學習庫,但在很多情況下,你們至少應該嘗試瞭解底層工作原理。
一層又一層的過甜的語法糖會讓你的味蕾和程式設計技巧變得遲鈍。不考慮後果就輕率地使用比康定斯基更抽象的概念是危險的。
宣告式程式設計非常適合清晰地傳達想法,或者對於你不關心如何執行的程式碼部分,無論是因為效能不是問題,還是執行邏輯由外部庫等另一方管理,都是一個不錯的選擇。
對於你希望完全控制的程式碼部分,指令式程式設計是更好的選擇。如果您希望能夠修改或最佳化完成事情的方式 – 你應該自己動手。即使宣告式方式的當前行為看起來不錯,你也永遠不知道下一次程式碼更改會導致什麼,更不用說 – 一旦負責“如何”的工具更新,即使是未更改的程式碼的效能也可能會發生變化。
宣告式程式設計並不完美。還有其他方法可以表達相同的想法,這可能會影響效能。如果你最終努力最佳化宣告,例如許多人對 SQL 查詢所做的那樣,我們的技術可能並不像您想象的那樣具有宣告性。也許下一代宣告式程式設計將包含一個 NLP 模型,該模型可以理解我們的最終目標並根據其對內部實現的熟悉程度為其選擇最佳解決方案。那真的會很神奇。
作者:場長
相關閱讀: