試著將「Functional Programming」接上Python、Ruby、JavaScript或任何你想要的語言,都可能找到文件討論如何實現,而藉由盤點語言中的函數式或任何典範味道,除了能發現相同概念在各語言中的不同實現,進一步掌握這些元素的使用時機,更能重新塑造你的思考習慣。

盤點一級函式實現方式

先前〈往數學領域抽象化的函數程式設計〉我介紹過函數式語言的特徵,其中一級函式是不少主流語言中普遍支援的概念,也就是將函式本身視為值,可作為引數或回傳值傳遞,一級函式概念在語言中多半被實現為兩個部份:具名函式與匿名函式。

例如Haskell中,doubleMe x = x + x是個具名函式,而\x -> x + x是個匿名函式,你可以傳遞函式,像是double = doubleMe或double = \x -> x + x;在Python或JavaScript中的實現亦是類似,以Python為例,def doubleMe(x): return  x + x是具名函式,而lambda x: x + x是匿名函式,你可以傳遞函式,像是double = doubleMe或double = lambda x: x + x。

有些語言具有一級函式的實現,不過,並非具名與匿名函式都可以直接當作值傳遞。例如,在Ruby中定義的方法(外觀上看像是個函式)就不行,而Ruby的區塊(Block)語法可視為接近匿名函式的實現,不過區塊本身並不是物件,必須配合方法一起使用,而且區塊中的程式碼較像個流程片段,區塊中若有return,接受區塊的方法執行至該行,就會結束方法呼叫,這與多數語言中實現一級函式時的呼叫方式,並不相同,為了彌補這方面的不足,Ruby提供了lambda方法,lambda {|x| x + x}或語法蜜糖->(x) {x + x}的用法,看來會較像是匿名函式。

一級函式概念的實現,不是動態定型語言的專利,靜態定型語言如Scala甚至Java,也有實現方式,對於具名函式能否直接當作值傳遞的問題,Scala與Java的答案都是「否」,至於匿名函式本身是否以物件存在的問題,Scala的答案為「是」而Java為「否」。

在最初定義語法時,就將函式當作一等公民(First-class citizen)看待的語言,具名與匿名函式多半都能作為值傳遞;後續在既有典範與語法包袱下,納入一等函式概念的語言,多半無法達到這點。有趣的就在於,瞭解既有典範與語法包袱下為何無法實現,這往往會重新加深你對該語言的認識。

例如,JDK8的Lambda,就因為以物件導向為主要典範,或有著泛型語法等的包袱,具名函式不能直接當作值傳遞,而匿名函式本身也不是以物件直接存在,須配合既有interface語法來定義Lambda目標型態(Target type),這避免了創造新型態系統是其好處,缺點就是目標型態的命名容易令人困惑。Ruby創建者在《松本行弘的程式設計世界》中解釋了區塊為何會有目前設計方式:減少物件的數量,程式碼外觀上像是擴充控制結構,方法只接受一個區塊的簡單設計,也符合了OCaml高階函式程式庫中,九成多的函式只接受一個函式的調查結果。

盤點高階函式(方法)

基本上,高階函式是指可接受一級函式作為引數的函式,這類函式多半在內部實現了極為通用的樣版流程,而在函式名稱提供更高一層的流程意義。

高階函式中最常見的,是先前〈List處理模式〉談過的map、filter、fold等,或在〈開發者應認識的資料型態及效用函式〉中看到的slice、zip等,甚至是〈神秘的Monad不神秘〉中的flatMap,使用這類高階函式,通常表示你對流程要能分而治之(Divide and conquer),有著更高一層的思考,而不再只是使用if、for、while等語法,思考低階處理流程。

使用高階處理函式的另一個考量是,程式碼本身是否能展現意圖。舉例來說,JDK8中numbers.filter(number -> number > 10)慣例上表示將大於10的數字留下來,不過,Ruby認為filter會被誤解為過濾掉大於10的數字,改用numbers.select {|number| number > 10}會清楚許多,類似的理由下,Ruby中用了collect來取代map,還有著detect、reject等高階方法;Python不愛用map、filter、reduce這類函式,認為List comprehension如[number for number in numbers if number > 10]會清楚許多,不過松本行弘認為這樣不易閱讀,因為與Ruby由左而右的求值順序不一致,目前並不考慮在Ruby中加入這類語法。

儘管可讀性如何實現有些爭議,以更高層次思考對待流程,是運用高階函式或語法時該有的態度,只是既然不去處理低階處理流程,有時得留意高階函式在惰性求值(Lazy evaluation),與平行處理的可能性,才能掌握高階函式或語法的使用時機。

Python中[x * 10 for x in xrange(100)]是立即求值(Eager evaluation),將[]改為(),就是惰性求值;Ruby的陣列操作是立即求值,Ruby 2.0之後,可以使用Enumerable的lazy方法,對後續操作做惰性求值;JDK8中Collection得明確使用stream方法,以進行後續管線(Pipeline)操作,在可能情況下,亦可進行惰性求值,如果要進一步取得平行處理的可能性,得明確使用parallelStream方法。

在《Learn You a Haskell for Great Good!》中提到,「不熟悉鞣製(Curring)與不全套用(Partial application)的人們,往往會寫出很多lambda,而實際上大部分都是沒必要的」,其中舉例,與其寫下map (\x -> x + 3) [1, 6, 3, 2],不如寫下map (+3) [1, 6, 3, 2]來得清爽。有些語言亦支援這兩種概念,例如Scala,與其寫下List(1, 2, 3).foreach(x => println(x)),不如寫下List(1, 2, 3).foreach(println)來得簡潔好讀。

實際上,不一定要支援鞣製與不全套用這兩種概念,lambda就是匿名函式概念,一個臨時定義的小運算式,可傳遞給高階函式回呼使用,如果你已經有事先定義好的函式,當然也可以傳遞給高階函式,這是重用現有元件及考量可讀性的概念。

例如在JavaScript中,與其寫下[1, 2, 3].forEach(function(ele, idx, arr) { console.log(ele, idx, arr); }),不如改用[1, 2, 3].forEach(console.log)清楚。

對於具有一級函式及高階函式概念,但具名函式無法直接作為值傳遞的語言來說,也有其因應之道,例如Ruby可以透過method方法取得Method、透過to_proc方法取得Proc物件、透過&將Proc物件當作引數傳給高階方法,因而你有各種重用既有方法的機會,而不是直接寫下區塊定義。例如,與其寫下[1, 2, 3, 4, 5].reduce { |sum, element| sum + element },不如寫下[1, 2, 3, 4, 5].reduce(:+)來得明確。

有了匿名函式,不見得你一定用它來解決一切,透過現有或自定義適當名稱的函式,必要時直接參考函式,重用與可讀性都會高很多。JDK8方法參考(Method reference),也是這類概念的實作。與其用IntStream.of(1, 2, 3).forEach(x -> out.println(x)),不如用IntStream.of(1, 2, 3).forEach(out::println)清楚。

透過盤點特性重新認識語言

有人對JDK8中Collection要進行filter等操作前,要先呼叫stream方法有些不解,覺得太過麻煩,其實Collection的stream相當於Scala的view,知道嗎?Scala中(1 to 1000000000).view.filter(_ % 2 == 0).take(10).toArray,沒有那個view方法,將會有OutOfMemoryError錯誤,實際上JDK8中對應的程式是rangeClosed(1, 1000000000).filter(it -> it % 2 == 0).limit(10).toArray(),寫stream或view是否太麻煩不是重點,重點是你有沒有惰性求值的概念。

不少程式語言看似相互競爭,實際是相互學習,只是換了實作樣貌,有時概念是在實作中很明確,有時隱含在實作中,盤點語言中的函數味只是幌子,實際上你可以盤點物件導向、meta-programming等概念在不同語言中的實現,而且,過程中你會發現語言中未曾注意過的考量,對程式的思考也會改變,從而更善用語言中適當的元素。

專欄作者

熱門新聞

Advertisement