從Java和JavaScript來學習Haskell和Groovy(DSL)
這是《從Java和JavaScript來學習Haskell和Groovy》系列的第四篇。
首先來理解DSL。
DSL( Domain Specific Language )指的是一定應用領域內的計算機語言,它可以不強大,它可以只能在一定的領域內生效(和GPL相比,GPL是General Purpose Language),表達僅限于該領域,但是它對于特定領域簡潔、清晰,包含針對特定領域的優化。
當我們面對各種各樣的特定需求的時候,一個通用的語言往往不能高效地提供解決問題的路徑,相應的DSL并不是并非要解決所有的問題,但是它只關注于某一個小領域,以便解決那一個小領域的問題就好了。比如HTML,只用于網頁渲染,出了這個圈子它什么都不做,但是用來表達網頁的內容卻很擅長,有很多內置的標簽來表達有預定義含義的內容;再比如SQL,只能寫數據庫相關的操作語句,但是很適合用來描述要查詢什么樣的一個數據集合,要對數據集合中的元素做什么樣的操作。
先來看Java。用Java寫DSL是可能的,但是寫高效和簡潔的DSL是困難的。原因在于它的語法限制,必須嚴謹的括號組合,不支持腳本方式執行代碼等等。
首先講講鏈式調用。這也不是Java特有的東西,只不過Java的限制太多,能幫助DSL的特性很少,第一個能想到的就是它而已。比如這樣的代碼,組織起html文本來顯得有層次、有條理:
document
.html()
.body()
.p()
.text("context 1")
.end()
.p()
.text("context 2")
.end()
.end()
.end()
.creat(); 鏈式調用還有一個令人愉快的特性是泛型傳遞,我在這篇文章中介紹過,可以約束寫DSL的人使用正確的類型。
其次是嵌套函數,這也不是Java特有的東西,它和鏈式調用組成了DSL最基本的實現形式:
new Map(
city("Beijing", x1, y1),
city("Shanghai", x2, y2),
city("Guangzhou", x3, y3)
); 值得一提的是Java的閉包,可以說閉包是融合了管道操作和集合操作美感的,談DSL不能不談閉包。但是,直到014年4月 JSR-335 才正式final release,不能不說這個來得有點晚。有了閉包,有了Lambda表達式(其實本質就是匿名函數),也就有了使用函數式編程方式在Java中思考的可能。
考慮一下排序的經典例子,可以自定義Comparator<T>接口的實現,從而對特定對象列表進行排序。對于這樣的類T:
public class T {
public Integer val;
} 可以使用匿名的Comparable實現類來簡化代碼:
Collections.sort(list, new Comparator<T>() {
@Override
public int compare(T o1, T o2) {
return o1.val.compareTo(o2.val);
}
}); 但是如果使用JDK8的Lambda表達式,代碼就簡化為:
Collections.sort(list, (x, y) -> y - x);
更加直觀,簡潔。
那么為什么 (x,y) -> y-x 這樣的Lambda表達式可以被識別為實現了 Comparator接口 呢?
原來這個接口的定義利用了這樣的語法糖:
<pre>@FunctionalInterface public interface Comparator<T> { ... }</pre>這個 @FunctionalInterface的注解 ,是可以用來修飾“函數接口”的,函數接口要求整個接口中只有一個非java.lang.Object中定義過的抽象的方法(就是沒有具體實現的方法,且方法簽名沒有在java.lang.Object類中出現過,因為所有類都會實現自java.lang.Object的,那么該類中已定義的方法可以認為已經有默認實現,接口中再出現就不是抽象方法了)。
好,有了這一點知識以后還是回頭看這個 Comparator接口 的定義,有這樣兩個抽象方法:
int compare(T o1, T o2); boolean equals(Object obj);
那么按照剛才的說法,其中的equals方法是在java.lang.Object中出現過的,不算,在考察函數接口的合法性時,其實只有一個compare這一個抽象方法。
順便加一句吐槽。該接口還有幾個方法的default實現,“接口的默認方法”,為了在增加行為的情況下,考慮向下兼容,總不能把Comparator把接口改成抽象類吧,于是搞了這樣一個語法糖,但是它是如此地毀曾經建立的三觀,接口已經可以不再是純粹的抽象了。
接著來看JavaScript的DSL。其實就DSL的實現而言,Java和JavaScript來實現并沒有非常多的區別,最大的區別可能是,JavaScript中,function可以成為一等公民,因此能夠寫更加靈活的形式:
new Wrapper([1, 2, 5, 3, 4]) .filter(filterFunc) .map(mapFunc) .sort() .zipWith([7, 8, 9, 10, 11]);
再給一個高階函數(Curry化)的例子:
var logic = new Logic() .whenTrue(exp1) .whenFalse(exp2);console.log(logic.test(3>2));</pre>
動態語言和豐富語法糖的關系,Groovy是非常適合用來寫DSL的。一方面是因為語法糖的關系,萬物皆對象,省掉不少累贅的括號,代碼看起來比較自然,接近自然語言;另一方面是有不少語言特性,比如MethodMissing,幫助寫出簡潔的DSL。下面分別說明,例子大多來自這個 官網頁面 。
// equivalent to: take(2.pills).of(chloroquinine).after(6.hours) take 2.pills of chloroquinine after 6.hours看到上面這個,因為簡簡單單的語法糖,就使得代碼如此接近自然語言,是否有很心曠神怡的感覺?
這個是個更復雜一些的例子:
show = { println it } square_root = { Math.sqrt(it) }def please(action) { [the: { what -> [of: { n -> action(what(n)) }] }] }
// equivalent to: please(show).the(square_root).of(100) please show the square_root of 100 // ==> 10.0</pre>
上面定義了show和square_root的閉包,然后在please方法中,調用返回了一個對象,可以繼續調用the方法,其結果可以繼續調用of方法。action是please方法的閉包參數,square_root是the方法的閉包參數。挺有趣的,好好品味一下。
再有這個我曾經舉過的例子,生成HTML樹,利用的就是MethodMissing(執行某一個方法的時候,如果該方法不存在,就可以跳到特定的統一的某個方法上面去),這樣避免了寫一大堆無聊方法的問題:
def page = new MarkupBuilder() page.html { head { title 'Hello' } body { a ( href:'http://...' ) { 'this is a link' } } }當然了,Groovy已經內置了 一大堆常用的builder ,比如這個JsonBuilder:
JsonBuilder builder = new JsonBuilder() builder.records { car { name 'HSV Maloo' make 'Holden' year 2006 country 'Australia' record { type 'speed' description 'production pickup truck with speed of 271kph' } } } String json = JsonOutput.prettyPrint(builder.toString())利用元編程的一些特性,也可以讓一些本來沒有的方法和功能,出現在特定的對象上面,從而支持DSL。比如Categories,這個,我在前面一篇《元編程》中已經介紹過了。
最后來說Haskell。
作為語言特性的一部分,利用(1)模式匹配的守護語句和(2)List Comprehension帶來的條件分類,免去了if-else的累贅,對于邏輯的表達,可以極其簡約。
關于上面(1)模式匹配的部分,《元編程》中已經有過介紹,下面給一個(2)List Comprehension的經典例子,快排:
quicksort :: (Ord a) => [a] -> [a] quicksort [] = [] quicksort (x:xs) = let smallerSorted = quicksort [a | a <- xs, a <= x] biggerSorted = quicksort [a | a <- xs, a > x] in smallerSorted ++ [x] ++ biggerSorted上面這個快排算法,清晰,而且簡潔。相比以前用Java寫的快排,用Haskell寫真是太酷了。
前文已經介紹過了高階函數的使用,但是在Haskell中,所有的函數都可以理解為,每次調用最多都只接受一個參數,如果有多個參數怎么辦?把它化簡為多次調用的嵌套,而非最后一次調用,都可視為高階函數(返回函數的函數)。比如:
Prelude> :t max max :: Ord a => a -> a -> a上面描述的調用本質決定了為什么它的結構是a->a->a:接受一個類型a的參數,再接受一個類型a的參數,最終返回的類型和入參相同。
也就是說,這兩者是等價的:
max 1 2 (max 1) 2繼續談論和DSL相關的語言特性,尾遞歸和惰性求值。
對于尾遞歸不了解的朋友可以先參考 維基百科上的解釋 。如果遞歸函數的遞歸調用自己只發生在最后一步,并且程序可以把這一步的入棧操作給優化掉,也就是最終可以使用常量棧空間的,那么就可以說這個程序/語言是支持尾遞歸的。
它有什么好處?因為可以使用常量棧空間了,這就意味著再也沒有遞歸深度的限制了。
不過話說回來,Haskell是必須支持尾遞歸的。因為對于常規語言,如果面臨遞歸工作棧過深的問題,可以優化為循環解決問題;但是在Haskell中,是沒有循環語法的,這就意味著必須用尾遞歸來解決這個本來得用循環才能解決的問題。
給一個例子,利用尾遞歸,我們自己來實現list求長度的函數:
len :: (Num b) => [a] -> b len [] = 0 len (_:xs) = 1 + len xs然后是惰性求值,直到最后一步,非要求值不可前,不做求值的操作。聽起來簡單,但是只有Haskell是真正支持惰性求值的,其他的語言最多是在很局限的范圍內,基于優化語言運行性能的目的,運行時部分采用惰性求值而已。
有了惰性求值,可以寫出一些和無限集合之間的互操作,比如:
sum (takeWhile (<10) (filter odd (map (^2) [1..])))這是對于正整數序列(無限集合)中的每個元素,平方以后再判斷奇偶性,只取奇數的結果,最后再判斷是否小于10,最后再把滿足條件的這些結果全部加起來。
當然,利用語法糖,可以把上面討厭的嵌套給拉平,從而去掉那些惱人的括號:
sum . takeWhile (<10) . filter odd . map (^2) $ [1..]兩者功能上沒有任何區別。
下一篇,也預計是最后一篇,我想著重介紹一下整體的角度來看時,編程范型的部分。
原文 http://www.raychase.net/3110