程式中可被指定給變數的東西稱為值,如果值可以傳給函式的參數,或從函式中傳回,稱為一級值(First-class value)。有些物件導向程式語言中,不僅基本型態及物件,函式亦具有一等公民(First-class citizen)的地位,它們可以匿名地定義、指定給變數、傳入函式或從函式中傳回,現代開發者最熟悉具一級函式的語言,就是JavaScript,可使用function關鍵字來定義匿名函式。

函式作為一級值,主要受到lambda演算的影響,想要瞭解或善用一級函式,就得瞭解基本的lambda演算概念。在lambda演算中,每個表達式(Expression)代表具單一參數的函數,參數本身亦可接受具有單一參數的函式。

例如,函數f(x) = x * 2可匿名地表達為x -> x * 2(或表達為λ x. x * 2,以下採前者表示方式),如果要套用x為2,可表示為(x -> x * 2)(2)。

如果有函數g(y) = y - 1,想表達g(f(x)),可以匿名地寫為(y -> y - 1)(x -> x * 2),套用後成為x -> x * 2 - 1,函數傳入另一函數,相當於組合出新函數。

多參數的函數可使用單獨參數的函數套用而成,例如(x, y) -> x * x + y * y可表示為x -> (y -> x * x + y * y),如果x為2而y為3,則套用過程為(x -> (y -> x * x + y * y)(2))(3) = (y -> 2 * 2 + y * y)(3) = 4 + 3 * 3 = 4 + 9 = 13,原先的x -> (y -> x * x + y * y稱為鞣製函式(Curried function),函數套用x後傳回新函數y -> 1 + y * y,稱為部份套用函式(Partially applied function),之後再以新函數套用y的值。

類似地,透過一套規則定義,lambda表達式可用來表現任何可計算函數,連if等控制結構,也可使用函數來表示。

不同程式語言,會提供不同程度的lambda表達式,例如(x -> x * 2)(2)以JavaScript來表達則為function(x) {return x * 2;}(2),JavaScript函式可接受函式作為參數,亦可將函式作為傳回值,但不支援函式部份套用(Partially application),必須自行實作,才能達到鞣製函式效果。Java至JDK7為止都沒有支援一級函式,JDK8將導入lambda語法及相關支援,探討Java何以要導入lambda語法,有助於瞭解一級函式在物件導向語言中的角色。

Java匿名內部類別與lambda語法

Java一直存在是否導入lambda語法的爭議,反對者所持理由之一是,Java中存在著lambda語法的類似品,也就是匿名內部類別(Anonymous inner class)。

若要使用匿名內部類別模擬一級函式,可定義單一抽象方法(Single abstract method)介面,例如interface Func { R apply(P p); },其中P代表參數,R代表傳回值,也就以apply方法簽署的參數與傳回值,來代表一級函式的參數與傳回值宣告。如果要使用匿名內部類別來表示x -> x * 2,則必須寫為new Func() { public Integer apply(Integer x) { return x * 2; } }。

實際上,開發者只關心x -> x * 2,也就是函數的參數與執行內容,匿名內部類別語法顯而易見地,迫使開發者得額外留意介面名稱、方法名稱、參數與傳回值型態,以及相關類別建構語法。

在更進階應用場合中,例如想達成任意g(f(x))的函數組合,可定義Func compose(final Func f, final Func g)方法,方法傳回值可實作為new Func() { public C apply(A x) { return g.apply(f.apply(x)); },匿名內部類別語法,會使得語法冗長到難以理解,令開發者無法專心以函數角度來思考問題。

若採用JDK8即將導入的lambda語法,情況就得以改善。先前例子只要使用(Integer x) -> x * 2來表達,而compose方法的傳回值實作,只要撰寫為x -> g.apply(f.apply(x)),想將f(x) = x + 2與g(y) = y * 3組合為g(f(x)),可使用compose((Integer x) -> x + 2, (Integer y) -> y * 3)。

相較於使用匿名內部類別語法,使用lambda語法,確實易於鼓勵開發者以函數角度來思考問題。

不過(Integer x) -> x * 2有點問題,由於Java是靜態語言,變數帶有型態資訊,這使得JDK8的lambda語法,基本上必須指定參數型態,不過可透過編譯器的類型推斷(Type inference)來改善,問題是類型推斷的來源為何?

先前JDK草案曾打算採用「#傳回值型態(參數型態,...)」的語法來宣告,但這會在現有程式庫創造出lambda語法專用API,還會建立起如##int(int)(int)的複雜語法,這彷彿看到JDK5為了語法簡潔性而引入泛型(Generics),反造成了Enum>之類的複雜語法。

JDK8後來採用單一抽象方法的函式介面(Functional interface),以介面的方法簽署作為類型推斷來源,因此若有個doSome方法參數為Func,就可以使用doSome(x -> x * 2)來呼叫,因為編譯器可從Integer apply(Integer x)推斷型態資訊,省略了lambda語法的參數型態宣告。搭配lambda語法的程式庫

無論程式語言是一開始就支援,或是日後才導入lambda語法,不同程式語言對lambda語法提供不同程度的支援,因而支援lambda語法時必須搭配另一重要主角:可搭配lambda語法的程式庫。

因為無法從lambda語法得到的支援,往往可透過程式庫實作來盡可能補足,即使是以lambda演算為基礎的函數式語言,以各種函式為基礎組裝而成的程式庫,也是搭配一級函式時必要的元件。

例如鞣製(Curry)目的之一,是不用事先宣告,從即有的函式中產生新函式。如果語法上直接支援,使用鞣製特性的開發者,就不用親自實作動態產生函式定義的演算法;對於語法上沒有直接支援鞣製的語言(像JavaScript),就必須有開發者實作動態產生新函式的API,使用API的人才可享有鞣製特性的好處,複雜演算則交由API開發者負責。

在物件導向為主軸的Java中,物件絕對是抽象化的重點,然而許多問題其實都是資料處理問題,例如面對關聯式資料庫中的資料,多數處理無非就是將資料過濾、映射為另一筆資料,然後再作某種型式的處理。

在沒有lambda語法的過去,多數開發者習慣以物件觀點來思考,ORM(Object-Relational Mapping)框架曾盛行一時,但對於資料庫查詢而得的群集(Collection)資料,也許只需以函式方式處理。

JDK8搭配lambda語法的重要程式庫之一,就是改進後的群集框架,其提供了filter、map、reduce等方法,許多群集資料的處理方式,都可由這些方法組合而成,搭配lambda語法使用,更可提高程式的表述能力。比方說blocks若為List型態,取得所有紅色積木的重量總合可寫為:

int sum = blocks.filter(b -> b.getColor() == RED)
.map(b -> b.getWeight()).sum();

解決特定問題、增加表述性、隱藏低階細節
物件導向語言本身就可用來解決問題,納入lambda語法的目的之一,就是為原本語言提供小型通用語言,讓使用物件導向解決時會導致複雜語法或設計的問題,可以使用lambda語法優雅地解決,可預見地,除了JDK本身程式庫將搭配lambda語法而演化,相關開放原始碼或許也將呈現不同風格。

由於程式庫的封裝,平行化、函數式等方向的可能性,也將更簡單且更具表述性,以群集框架新增的filter、map、reduce等方法為例,其內部也許會採遞迴、迭代,或基於效能採用延遲或平行化等更複雜的演算法。

然而由於這一切都被封裝在程式庫中,使得Java開發者無需面對複雜演算,因而在解題思路可有所不同,而少了複雜語法與演算的雜訊後,開發者就可進一步思索map、filter、reduce等解決問題的基本形式。

 

作者簡介


Advertisement

更多 iThome相關內容