摘要特定於領域的語言已經成為一個熱門話題很多函數性語言之所以受歡迎主要是因為它們可以用於構建特定於領域的語言鑒於此在 面向 Java? 開發人員的 Scala 指南 系列的第 篇文章中Ted Neward 著手構建一個簡單的計算器 DSL以此來展示函數性語言的構建 外部 DSL 的強大功能他研究了 Scala 的一個新的特性case 類並重新審視一個功能強大的特性模式匹配
上個月的文章發表後我又收到了一些抱怨/評論說我迄今為止在本系列中所用的示例都沒涉及到什麼實質性的問題當然在學習一個新語言的初期使用一些小例子是很合理的而讀者想要看到一些更 現實的 示例從而了解語言的深層領域和強大功能以及其優勢這也是理所當然的因此在這個月的文章中我們來分兩部分練習構建特定於領域的語言(DSL)— 本文以一個小的計算器語言為例
關於本系列
Ted Neward 將和您一起深入探討 Scala 編程語言在這個新的 developerWorks 系列 中您將深入了解 Sacla並在實踐中看到 Scala 的語言功能進行比較時Scala 代碼和 Java 代碼將放在一起展示但(您將發現)Scala 中的許多內容與您在 Java 編程中發現的任何內容都沒有直接關聯而這正是 Scala 的魅力所在!如果用 Java 代碼就能夠實現的話又何必再學習 Scala 呢?
特定於領域的語言
可能您無法(或沒有時間)承受來自於您的項目經理給您的壓力那麼讓我直接了當地說吧特定於領域的語言無非就是嘗試(再一次)將一個應用程序的功能放在它該屬於的地方 — 用戶的手中
通過定義一個新的用戶可以理解並直接使用的文本語言程序員成功擺脫了不停地處理 UI 請求和功能增強的麻煩而且這樣還可以使用戶能夠自己創建腳本以及其他的工具用來給他們所構建的應用程序創建新的行為雖然這個例子可能有點冒險(或許會惹來幾封抱怨的電子郵件)但我還是要說DSL 的最成功的例子就是 Microsoft® Office Excel 語言用於表達電子表格單元格的各種計算和內容甚至有些人認為 SQL 本身就是 DSL但這次是一個旨在與關系數據庫相交互的語言(想象一下如果程序員要通過傳統 API read()
/write()
調用來從 Oracle 中獲取數據的話那將會是什麼樣子)
這裡構建的 DSL 是一個簡單的計算器語言用於獲取並計算數學表達式其實這裡的目標是要創建一個小型語言這個語言能夠允許用戶來輸入相對簡單的代數表達式然後這個代碼來為它求值並產生結果為了盡量簡單明了該語言不會支持很多功能完善的計算器所支持的特性但我不也不想把它的用途限定在教學上 — 該語言一定要具備足夠的可擴展性以使讀者無需徹底改變該語言就能夠將它用作一個功能更強大的語言的核心這意味著該語言一定要可以被輕易地擴展並要盡量保持封裝性用起來不會有任何的阻礙
關於 DSL 的更多信息
DSL 這個主題的涉及面很廣它的豐富性和廣泛性不是本文的一個段落可以描述得了的想要了解更多 DSL 信息的讀者可以查閱本文末尾列出的 Martin Fowler 的 正在進展中的圖書特別要注意關於 內部 和 外部 DSL 之間的討論Scala 以其靈活的語法和強大的功能而成為最強有力的構建內部和外部 DSL 的語言
換句話說(最終的)目標是要允許客戶機編寫代碼以達到如下的目的
清單 計算器 DSL目標
// This is Java using the Calculator
String s = (( * ) + );
double result = comtednewardcalcdslCalculatorevaluate(s);
Systemoutprintln(We got + result); // Should be
我們不會在一篇文章完成所有的論述但是我們在本篇文章中可以學習到一部分內容在下一篇文章完成全部內容
從實現和設計的角度看可以從構建一個基於字符串的解析器來著手構建某種可以 挑選每個字符並動態計算 的解析器這的確極具誘惑力但是這只適用於較簡單的語言而且其擴展性不是很好如果語言的目標是實現簡單的擴展性那麼在深入研究實現之前讓我們先花點時間想一想如何設計語言
根據那些基本的編譯理論中最精華的部分您可以得知一個語言處理器(包括解釋器和編譯器)的基本運算至少由兩個階段組成
● 解析器用於獲取輸入的文本並將其轉換成 Abstract Syntax Tree(AST)
● 代碼生成器(在編譯器的情況下)用於獲取 AST 並從中生成所需字節碼或是求值器(在解釋器的情況下)用於獲取 AST 並計算它在 AST 裡面所發現的內容
擁有 AST 就能夠在某種程度上優化結果樹如果意識到這一點的話那麼上述區別的原因就變得更加顯而易見了對於計算器我們可能要仔細檢查表達式找出可以截去表達式的整個片段的位置諸如在乘法表達式中運算數為 的位置(它表明無論其他運算數是多少運算結果都會是 )
您要做的第一件事是為計算器語言定義該 AST幸運的是Scala 有 case 類一種提供了豐富數據使用了非常薄的封裝的類它們所具有的一些特性使它們很適合構建 AST
case 類
在深入到 AST 定義之前讓我先簡要概述一下什麼是 case 類case 類是使 scala 程序員得以使用某些假設的默認值來創建一個類的一種便捷機制例如當編寫如下內容時
清單 對 person 使用 case 類
case class Person(first:String last:String age:Int)
{
}
Scala 編譯器不僅僅可以按照我們對它的期望生成預期的構造函數 — Scala 編譯器還可以生成常規意義上的 equals()toString() 和 hashCode() 實現事實上這種 case 類很普通(即它沒有其他的成員)因此 case 類聲明後面的大括號的內容是可選的
清單 世界上最短的類清單
case class Person(first:String last:String age:Int)
這一點通過我們的老朋友 javap 很容易得以驗證
清單 神聖的代碼生成器Batman!
C:\Projects\Exploration\Scala>javap Person
Compiled from casescala
public class Person extends javalangObject implements scalaScalaObjectscala
ProductjavaioSerializable{
public Person(javalangString javalangString int);
public javalangObject productElement(int);
public int productArity();
public javalangString productPrefix();
public boolean equals(javalangObject);
public javalangString toString();
public int hashCode();
public int $tag();
public int age();
public javalangString last();
public javalangString first();
}
如您所見伴隨 case 類發生了很多傳統類通常不會引發的事情這是因為 case 類是要與 Scala 的模式匹配(在 集合類型 中曾簡短分析過)結合使用的
使用 case 類與使用傳統類有些不同這是因為通常它們都不是通過傳統的 new 語法構造而成的事實上它們通常是通過一種名稱與類相同的工廠方法來創建的
清單 沒有使用 new 語法?
object App
{
def main(args : Array[String]) : Unit =
{
val ted = Person(Ted Neward )
}
}
case 類本身可能並不比傳統類有趣或者有多麼的與眾不同但是在使用它們時會有一個很重要的差別與引用等式相比case 類生成的代碼更喜歡按位(bitwise)等式因此下面的代碼對 Java 程序員來說有些有趣的驚喜
清單 這不是以前的類
object App
{
def main(args : Array[String]) : Unit =
{
val ted = Person(Ted Neward )
val ted = Person(Ted Neward )
val amanda = Person(Amanda Laucher )
Systemoutprintln(ted == amanda: +
(if (ted == amanda) Yes else No))
Systemoutprintln(ted == ted: +
(if (ted == ted) Yes else No))
Systemoutprintln(ted == ted: +
(if (ted == ted) Yes else No))
}
}
/*
C:\Projects\Exploration\Scala>scala App
ted == amanda: No
ted == ted: Yes
ted == ted: Yes
*/
case 類的真正價值體現在模式匹配中本系列的讀者可以回顧一下模式匹配(參見 本系列的第二篇文章關於 Scala 中的各種控制構造)模式匹配類似 Java 的 switch/case只不過它的本領和功能更加強大模式匹配不僅能夠檢查匹配構造的值從而執行值匹配還可以針對局部通配符(類似局部 默認值 的東西)匹配值case 還可以包括對測試匹配的保護來自匹配標准的值還可以綁定於局部變量甚至符合匹配標准的類型本身也可以進行匹配
有了 case 類模式匹配具備了更強大的功能如清單 所示
清單 這也不是以前的 switch
case class Person(first:String last:String age:Int);
object App
{
def main(args : Array[String]) : Unit =
{
val ted = Person(Ted Neward )
val amanda = Person(Amanda Laucher )
Systemoutprintln(process(ted))
Systemoutprintln(process(amanda))
}
def process(p : Person) =
{
Processing + p + reveals that +
(p match
{
case Person(_ _ a) if a > =>
theyre certainly old
case Person(_ Neward _) =>
they come from good genes
case Person(first last ageInYears) if ageInYears > =>
first + + last + is + ageInYears + years old
case _ =>
I have no idea what to do with this person
})
}
}
/*
C:\Projects\Exploration\Scala>scala App
Processing Person(TedNeward) reveals that theyre certainly old
Processing Person(AmandaLaucher) reveals that Amanda Laucher is years old
*/
清單 中發生了很多操作下面就讓我們先慢慢了解發生了什麼然後回到計算器看看如何應用它們
首先整個 match 表達式被包裹在圓括號中這並非模式匹配語法的要求但之所以會這樣是因為我把模式匹配表達式的結果根據其前面的前綴串聯了起來(切記函數性語言裡面的任何東西都是一個表達式)
其次第一個 case 表達式裡面有兩個通配符(帶下劃線的字符就是通配符)這意味著該匹配將會為符合匹配的 Person 中那兩個字段獲取任何值但是它引入了一個局部變量 apage 中的值會綁定在這個局部變量上這個 case 只有在同時提供的起保護作用的表達式(跟在它後邊的 if 表達式)成功時才會成功但只有第一個 Person 會這樣第二個就不會了第二個 case 表達式在 Person 的 firstName 部分使用了一個通配符但在 lastName 部分使用常量字符串 Neward 來匹配在 age 部分使用通配符來匹配
由於第一個 Person 已經通過前面的 case 匹配了而且第二個 Person 沒有姓 Neward所以該匹配不會為任何一個 Person 而被觸發(但是Person(Michael Neward ) 會由於第一個 case 中的 guard 子句失敗而轉到第二個 case)
第三個示例展示了模式匹配的一個常見用途有時稱之為提取在這個提取過程中匹配對象 p 中的值為了能夠在 case 塊內使用而被提取到局部變量中(第一個最後一個和 ageInYears)最後的 case 表達式是普通 case 的默認值它只有在其他 case 表達式均未成功的情況下才會被觸發
簡要了解了 case 類和模式匹配之後接下來讓我們回到創建計算器 AST 的任務上
計算器 AST
首先計算器的 AST 一定要有一個公用基類型因為數學表達式通常都由子表達式組成通過 + ( * ) 就可以很容易地看到這一點在這個例子中子表達式 ( * ) 將會是 + 運算的右側運算數
事實上這個表達式提供了三種 AST 類型
● 基表達式
● 承載常量值的 Number 類型
● 承載運算和兩個運算數的 BinaryOperator
想一下算數中還允許將一元運算符用作求負運算符(減號)將值從正數轉換為負數因此我們可以引入下列基本 AST
清單 計算器 AST(src/calcscala)
package comtednewardcalcdsl
{
private[calcdsl] abstract class Expr
private[calcdsl] case class Number(value : Double) extends Expr
private[calcdsl] case class UnaryOp(operator : String arg : Expr) extends Expr
private[calcdsl] case class BinaryOp(operator : String left : Expr right : Expr)
extends Expr
}
注意包聲明將所有這些內容放在一個包(comtednewardcalcdsl)中以及每一個類前面的訪問修飾符聲明表明該包可以由該包中的其他成員或子包訪問之所以要注意這個是因為需要擁有一系列可以測試這個代碼的 JUnit 測試計算器的實際客戶機並不一定非要看到 AST因此要將單元測試編寫成 comtednewardcalcdsl 的一個子包
清單 計算器測試(testsrc/calctestscala)
package comtednewardcalcdsltest
{
class CalcTest
{
import orgjunit_ Assert_
@Test def ASTTest =
{
val n = Number()
assertEquals( nvalue)
}
@Test def equalityTest =
{
val binop = BinaryOp(+ Number() Number())
assertEquals(Number() binopleft)
assertEquals(Number() binopright)
assertEquals(+ binopoperator)
}
}
}
到目前為止還不錯我們已經有了 AST
再想一想我們用了四行 Scala 代碼構建了一個類型分層結構表示一個具有任意深度的數學表達式集合(當然這些數學表達式很簡單但仍然很有用)與 Scala 能夠使對象編程更簡單更具表達力相比這不算什麼(不用擔心真正強大的功能還在後面)
接下來我們需要一個求值函數它將會獲取 AST並求出它的數字值有了模式匹配的強大功能編寫這樣的函數簡直輕而易舉
清單 計算器(src/calcscala)
package comtednewardcalcdsl
{
//
object Calc
{
def evaluate(e : Expr) : Double =
{
e match {
case Number(x) => x
case UnaryOp( x) => (evaluate(x))
case BinaryOp(+ x x) => (evaluate(x) + evaluate(x))
case BinaryOp( x x) => (evaluate(x) evaluate(x))
case BinaryOp(* x x) => (evaluate(x) * evaluate(x))
case BinaryOp(/ x x) => (evaluate(x) / evaluate(x))
}
}
}
}
注意 evaluate() 返回了一個 Double它意味著模式匹配中的每一個 case 都必須被求值成一個 Double 值這個並不難數字僅僅返回它們的包含的值但對於剩余的 case(有兩種運算符)我們還必須在執行必要運算(求負加法減法等)前計算運算數正如常在函數性語言中所看到的會使用到遞歸所以我們只需要在執行整體運算前對每一個運算數調用 evaluate() 就可以了
大多數忠實於面向對象的編程人員會認為在各種運算符本身以外 執行運算的想法根本就是錯誤的 — 這個想法顯然大大違背了封裝和多態性的原則坦白說這個甚至不值得討論這很顯然違背 了封裝原則至少在傳統意義上是這樣的
在這裡我們需要考慮的一個更大的問題是我們到底從哪裡封裝代碼?要記住 AST 類在包外是不可見的還有就是客戶機(最終)只會傳入它們想求值的表達式的一個字符串表示只有單元測試在直接與 AST case 類合作
但這並不是說所有的封裝都沒有用了或過時了事實上恰好相反它試圖說服我們在對象領域所熟悉的方法之外還有很多其他的設計方法也很奏效不要忘了 Scala 兼具對象和函數性有時候 Expr 需要在自身及其子類上附加其他行為(例如實現良好輸出的 toString 方法)在這種情況下可以很輕松地將這些方法添加到 Expr函數性和面向對象的結合提供了另一種選擇無論是函數性編程人員還是對象編程人員都不會忽略到另一半的設計方法並且會考慮如何結合兩者來達到一些有趣的效果
從設計的角度看有些其他的選擇是有問題的例如使用字符串來承載運算符就有可能出現小的輸入錯誤最終會導致結果不正確在生產代碼中可能會使用(也許必須使用)枚舉而非字符串使用字符串的話就意味著我們可能潛在地 開放 了運算符允許調用出更復雜的函數(諸如 abssincostan 等)乃至用戶定義的函數這些函數是基於枚舉的方法很難支持的
對所有設計和實現的來說都不存在一個適當的決策方法只能承擔後果後果自負
但是這裡可以使用一個有趣的小技巧某些數學表達式可以簡化因而(潛在地)優化了表達式的求值(因此展示了 AST 的有用性)
● 任何加上 的運算數都可以被簡化成非零運算數
● 任何乘以 的運算數都可以被簡化成非零運算數
● 任何乘以 的運算數都可以被簡化成零
不止這些因此我們引入了一個在求值前執行的步驟叫做 simplify()使用它執行這些具體的簡化工作
清單 計算器(src/calcscala)
def simplify(e : Expr) : Expr =
{
e match {
// Double negation returns the original value
case UnaryOp( UnaryOp( x)) => x
// Positive returns the original value
case UnaryOp(+ x) => x
// Multiplying x by returns the original value
case BinaryOp(* x Number()) => x
// Multiplying by x returns the original value
case BinaryOp(* Number() x) => x
// Multiplying x by returns zero
case BinaryOp(* x Number()) => Number()
// Multiplying by x returns zero
case BinaryOp(* Number() x) => Number()
// Dividing x by returns the original value
case BinaryOp(/ x Number()) => x
// Adding x to returns the original value
case BinaryOp(+ x Number()) => x
// Adding to x returns the original value
case BinaryOp(+ Number() x) => x
// Anything else cannot (yet) be simplified
case _ => e
}
}
還是要注意如何使用模式匹配的常量匹配和變量綁定特性從而使得編寫這些表達式可以易如反掌對 evaluate() 惟一一個更改的地方就是包含了在求值前先簡化的調用
清單 計算器(src/calcscala)
def evaluate(e : Expr) : Double =
{
simplify(e) match {
case Number(x) => x
case UnaryOp( x) => (evaluate(x))
case BinaryOp(+ x x) => (evaluate(x) + evaluate(x))
case BinaryOp( x x) => (evaluate(x) evaluate(x))
case BinaryOp(* x x) => (evaluate(x) * evaluate(x))
case BinaryOp(/ x x) => (evaluate(x) / evaluate(x))
}
}
還可以再進一步簡化注意一下它是如何實現只簡化樹的最底層的?如果我們有一個包含 BinaryOp(* Number() Number()) 和 Number() 的 BinaryOp 的話那麼內部的 BinaryOp 就可以被簡化成 Number()但外部的 BinaryOp 也會如此這是因為此時外部 BinaryOp 的其中一個運算數是零
我突然犯了作家的職業病了所以我想將它留予讀者來定義其實是想增加點趣味性罷了如果讀者願意將他們的實現發給我的話我將會把它放在下一篇文章的代碼分析中將會有兩個測試單元來測試這種情況並會立刻失敗您的任務(如果您選擇接受它的話)是使這些測試 — 以及其他任何測試只要該測試采取了任意程度的 BinaryOp 和 UnaryOp 嵌套 — 通過
結束語
顯然我還沒有說完還有分析的工作要做但是計算器 AST 已經成形我們無需作出大的變動就可以添加其他的運算運行 AST 也無需大量的代碼(按照 Gang of Four 的 Visitor 模式)而且我們已經有了一些執行計算本身的工作代碼(如果客戶機願意為我們構建用於求值的代碼的話)
更重要的是您已經看到了 case 類是如何與模式匹配合作使得創建 AST 並對其求值變得輕而易舉這是 Scala 代碼(以及大多數函數性語言)很常用的設計而且如果您准備認真地研究這個環境的話這是您應當掌握的內容之一
From:http://tw.wingwit.com/Article/program/Java/hx/201311/25735.html