程式語言演化的考量


iThome 網站首載:程式語言演化的考量

語言總會演化,過程中開發者總希望增添自己喜愛的特性,他們經常嚷嚷「為何不加入X?」,在鵠首企昐的特性加入語言後,開發者又叫囂著「這什麼鬼東西?」從使用者角度來看,語言特性越多越好,特性彼此間像是互不侵擾;從語言本身來看,要加入任何新特性幾乎都沒問題,然而新舊特性間的侵擾會造成複雜度的提昇。有時認識語言演化過程是必要的,以瞭解語言為何有目前樣貌,善用新特性的良好部份並避開新的複雜性。

維持現有語法及API相容性
   
近幾年來一級函式的應用受到重視,連不算年輕的Java也打算加入此一特性,長久以來Java不存在一級函式,觀察Lambda規格JSR335的演化過程,可作為認識語言演化考量的實際案例。在動態語言如JavaScript、Python等之中,一級函式是重要且經常應用的特性,因為無論是建立一級函式,或是宣告能接受一級函式的變數,都不用指定型態,使用一級函式對程式碼表述力有所助益;對靜態語言來說,一級函式主要問題在於型態為何?舉例來說,Scala中視參數個數而定,一級函式會是FunctionFunction1Function2等函數型態(Function type)實例,有專屬型態系統。如果方法(或函式)可接受函式,那麼參數就必須宣告對應的型態。

在Lambda最初草案中,Java原本預計採用類似Scala的作法,為一級函式制訂專屬函式型態,在不與現有關鍵字衝突的考量下,使用了#int(int, int) sum = #(int x, int y)(x + y)的語法,新語法問題之一是不一致,#int(int, int)傳回值宣告在左邊,而Lambda語法函式本體是在右邊,這也造成另一問題,從函式中傳回函式是一級函式常見應用,如果有函式接受一個int然後傳回新函式,新函式接受一個int並傳回int,那麼型態宣告就會是##int(int)(int),這讓人彷彿看到泛型語法造成Enum<E extends Enum<E>>之類的遞迴性問題。

另一問題在於Java長久累積的龐大程式庫,如果為了Lambda建立專屬型態,那麼現有程式庫就無法在新語法上得到任何助益,你得建立一套新API,在接受函式的方法上宣告函式型態。即使能找到一個途徑,讓函式型態可像泛型語法得到向後相容性,昇級現有API也會是個龐大工程,想想泛型語法出現後多久才獲得採用就可得知,因為這是在修改公開介面,而且勢必在已經夠複雜的方法宣告上加入更複雜語法。

Java最後採取了函式介面(Functional interface),這類僅具單一抽象方法的介面遍佈現有程式庫之中;Lambda運算式語法獨立於型態之外,同一Lambda運算式會因目標函式介面不同而為不同實例,避免了為Lambda運算式制訂專屬型態問題。為了能重用現有API,使用了方法參考(Method reference),只要現有方法的簽署符合函式介面的方法簽署(名稱除外),就可以直接包裹為目標實例,不用撰寫新的Lambda運算式。
   
統一風格與避免複雜度

引入語言新特性時,除了避免引入新複雜度外,讓新特性看來像是原有語言的一部份,而不是突兀的方言也是個目標。除了讓新語法相容於現有API,如果想增加一套與新特性搭配的API,也得有相同考量。以Java來說,既然有了Lambda語法,自然就會想到伴隨著Lambda語法而存在的函數式風格程式庫。問題重點在於,這些新API要定義在哪?函數式語言特別擅長處理資料,因而Java考量是否建立新的Collection2來取代現有Collection框架,不過Collection的應用貫穿了現有應用程式與程式庫是個考量,單為了Lambda建立新的Collection2工作太過龐大,無法成為下個版本JDK8的目標。那麼直接新增在Collections上成為靜態方法呢?像是guava-libraries在IterablesIterators上提供的那些靜態方法?結果很明顯,這會讓你覺得使用guava-libraries風格撰寫程式,也就是這種作法會讓新API看來不像是Java原有風格。

Java以物件導向為主流典範,Lambda應用概念則來自於函數式語言,對於適切地融合函數式與物件導向,Scala是個成功案例,借鏡而來的作法是在Collection介面上增加方法,這是個大問題,因為在介面增加方法,意謂著所有實作類別都得實現,以Collection為基礎的程式太多,不可採取這種破壞式的作法。有許多防禦式(Defensive)的API演化方式,可以讓你在演化介面時,避免影響介面的使用者,像是提供預設實作以避免使用者直接實作介面,不過這些方式,得在一開始就作好設計。Java的作法是解除介面限制,讓它可擁有防禦者方法(Defender method),而不僅僅只能定義抽象方法。防禦者方法就是允許在介面中有條件實作的預設方法(Default method),這看來有點像是作弊,因為只有語言制定者才能作這種防禦式介面演化,不過這確實也為日後的防禦式介面演化提供更大彈性。

相對於防禦式介面演化的就是侵犯式(Offensive)介面演化,後者是不管使用者直接演化介面,使用者在昇級前必須修改程式,因而有遭到侵犯的感覺。侵犯式演化若過於劇烈,使用者就不會願意或緩慢採納,Python3是個案例,由於語法變異過大,許多現有程式庫多半觀望而不願昇級。Java解開介面限制不是沒有代價,最直接的衝擊就是使得函式介面的判斷變得複雜,這後來提出了@FunctionalInterface並結合編譯器協助判斷,另一問題在於實作介面就是多重繼承,以往為了限制多重繼承複雜度,因而不允許介面擁有實作方法,一旦解開此限制,在可繼承實作情況下,必然帶入多重繼承的複雜問題。
   
作法明確而不是隱含

有別於使用iterator方法傳回外部迭代(External iteration)用的Iterator實例,JDK8在Collection上增加內部迭代(Internal iteration)的方法,像是forEachmapfilterreduce等,內部迭代令程式庫可以掌控迭代流程,使得延遲性(Laziness)、平行化(Parallelism)等非循序性實作細節得以隱藏,然而使用者如何知道何時操作有延遲性?何時以平行化在進行操作?

Java語言演化原則之一是程式碼閱讀比撰寫重要。在觀察許多Collection迴圈操作之後,發現許多中介運算都可有延遲性,最後取得結果時才需要立即的(Eager)操作。JDK8定義了Stream介面來負責中介(Intermediate)操作,Collection介面上新增了stream方法傳回Stream實例,Streammapfilter等只要傳回Stream實例的方法,都是中介操作,其他如forEachreduce不傳回Stream實例的都是結束(Terminal)操作,這也使得Collection不會因塞入mapfilter等方法而肩負太多職責,然而這會產生shapes.stream(..).filter(..).collect(..)這類操作,不少人覺得stream方法降低了簡潔性,簡潔是個優點,然而語言不能隱藏發生的事情,明確而非隱含是此一設計的目的。

瞭解新特性與複雜度間的平衡點

找出新特性與複雜度間的平衡點很困難,這是制訂語言者的責任,不過絕非多數使用者的天真想法:「喔!增加個X很簡單,不會太複雜!」Joshua Bloch後來面對泛型也只能說,面對複雜性與功能時必須想得更透徹些,如果語言已經逼近使用者理解能力,你不可能在不搞爛它的情況下加入任何複雜性。

Joshua Bloch亦以C++為例,複雜並沒有使C++消失,只是令使用者取用功能子集。因而身為使用者得瞭解新特性與複雜度間的平衡點,在使用新特性時若覺得太複雜時,才能適時地考慮採用新特性的子集,就像使用泛型確實有其優點,然而如果情況變得複雜,也得有所取捨或作其他設計考量。