DSL的核心在於受限的表達能力,容易理解的語義以及清晰的語義模型,最後一點雖然並不是是必要的,但在絕大多數時候每每是成立的。其中受限的表達能力是我最想強調的一點,咱們碼農在寫代碼的時候每每對於程序的可擴展性和API支持的泛化能力有一種執着的追求,但對於以表達而非功能的DSL,卻並不是是一件好事。擴展和泛化的職責應該交由語義模型完成,而非DSL自身,在大多數狀況下采用DSL級聯比實現一個快要接近於通用語言的DSL要好,你能夠把它和程序設計中的單一職責原則聯繫起來,雖然不那麼恰當,我想說的就是讓你的DSL保持小巧精悍的樣子。··前端
DSL自己只是一層爲了表達的能力而作的淺淺封裝,在你考慮DSL的時候你應該將絕大部分的精力花在構建語義模型上,但反過來講DSL的設計從某種程度上來講幫你理清了語義模型的需求,這讓咱們看起來開始像一條產品狗了(笑),接下來咱們大體過一下DSL設計和實現主要考慮的問題吧。java
首要的就是如何設計DSL表達形式和你的語義模型,這是領域相關的技術node
如何解析DSL文件並根據狀況生成IR(中間數據)express
如何在解析過程當中或者在解析以後經過DSL 來構建語義模型編程
有時候僅僅獲得語義模型是不夠的,咱們須要考察一些代碼生成之類的輸出相關的問題。ruby
接下來咱們將分別詳細介紹這些主題。網絡
這裏的解析DSL顯然指的是外部DSL,那麼咱們接下來討論的你可能在編譯原理前端都已經聽到過了,但我仍是須要在這裏再簡要地講述一遍,請原諒個人囉嗦。閉包
解析的過程主要能夠分爲詞法解析、語法解析,視你的DSL的複雜程度你可能還須要在中間生成符號表,使用語境變量和維護做用域上下文。詞法解析過程將DSL文本解析成一個Token Stream,token包含詞的原始內容和位置,同時這個過程將會丟棄一些無用的字符好比空格符等。語法解析從Token Stream中生成語法解析樹,在生成的過程當中你能夠採起一系列的動做來組裝你的語義模型,這一點咱們到下一節再講。ide
接下來讓咱們考慮一下最簡單的情形,好比讀入一個CSV文件,咱們並不須要定義語法,經過使用','分隔每一行就足夠進行解析了,這就是所謂的分隔符指導翻譯,因爲語法過於簡單,你只是經過分隔符作了一下詞法解析。函數
咱們能夠考慮一個稍微複雜的場景,你須要從一個日誌文件中的日誌記錄快速讀取若干信息,好比錯誤類型、相關用戶,這時候你能夠考慮使用正則詞法分析,你快速得到了你的詞條,而且並無什麼語法解析的過程就能夠組裝出你的語義模型(多是一個鍵值對或者一行記錄)。
上面所列舉的例子都沒有涉及到語法分析,對於簡單的情形,你能夠根據本身的邏輯編寫LL和LR解析器進行解析而不須要一個顯式的文法定義。但每每咱們首先須要定義咱們的文法並經過文法來指導咱們作語義解析。關於BNF文法和EBNF文法的內容這裏就再也不說起了,你能夠自行查閱。當你不採用語法樹來幫助你進行解析的狀況下,上面方法幾乎都是在組合解析器,欲匹配A規則,A由B和C組成,則先匹配B再匹配C,整個解析過程基於這種嵌套遞歸,有人將這種解析過程爲解析器組合子。這裏面有一些有趣的情形,當兩條文法規則擁有相同的Token前綴的時候,咱們怎麼解決?能夠經過向前看K步直到發現不一致的地方爲止,擴展到極端狀況下就是回溯法。咱們還能夠再作一點優化,在回溯的時候記錄下已經匹配上的子規則和其在標記流中的位置,切換到其餘候選規則時就能夠沒必要重複匹配相同前綴了,有些人將這種方法稱爲是記憶解析器。須要咱們注意的是否使用IR如語法樹對於語法解析的過程並沒有本質區別,不一樣點在於使用IR能夠屢次遍歷語法結構更方便構建語義模型,同時AST等能夠經過許多已有工具快速生成。
對於上下文相關文法,對於C++好比T(x)這種,既多是函數調用也多是類型轉換,你必須結合上下文來判斷,看有沒有T的函數定義。這時候你可能須要構建符號表和做用域上下文來幫助你作謂詞解析(語義預測)。
生成語義模型幾乎是咱們今天最重要的問題了,我我的認爲這也幾乎是DSL開發的核心部分,咱們從內部DSL和外部DSL分別介紹這個問題的解決方案。
從外部DSL生成語義模型,咱們能夠根據是否採用IR分開來考察,當不使用IR的時候,咱們是當匹配上某一規則執行對應的操做,好比向模型裏填充數據。可是當你有回溯動做的時候,一旦某個規則匹配失敗,你可能不得不清除其子規則作出的更改。因此好的作法並非直接作出對最終模型修改的動做,而是生成中間對象或者藉助生成器,若是想象爲樹的構建,那麼動做必定是在從下至上出子樹時執行。
當藉助IR進行構建時,一方面能夠在構建AST時執行相應操做,另一方面能夠在構建AST完畢後再次遍歷構建語義模型,大多數狀況下這兩種方法能夠結合起來使用。下面咱們以ANTLR爲例介紹藉助於AST的種種構建方法。
嵌入式語法翻譯和嵌入助手:嵌入式語法翻譯支持你在進入子樹根節點和退出子樹根節點時執行相應操做,它支持你在進入節點時定義須要使用的變量來幫助你更好地編寫處理邏輯。若是將絕大部分的action邏輯都放置到外部類中,而後在文法文件中只須要調用外部類的方法,這樣能夠避免在文法中混入太多的通用代碼以致於破壞了可讀性和表達的清晰程度,這就是嵌入助手。
grammar Rows; @parser::members { // add members to generated RowsParser int col; public RowsParser(TokenStream input, int col) { // custom constructor this(input); this.col = col; } } file: (row NL)+ ; row locals [int i=0] : ( STUFF { $i++; if ( $i == col ) System.out.println($STUFF.text); } )+ ; TAB : '\t' -> skip ; // match but don't pass to the parser NL : '\r'? '\n' ; // match and pass to the parser STUFF: ~[\t\r\n]+ ; // match any chars except tab, newline
內嵌解釋器:在樹的構建過程當中指明每一個規則節點返回的類型,以及在文法文件中嵌入代碼生成返回對象,這種只適合於很是簡單的狀況。
a returns [string expr] : b {$expr = $b.expr;} | c {$expr = $c.expr;} ;
外加代碼:和內嵌解釋器不一樣,代碼並不是加在文法定義文件中而是實際的DSL腳步中,與其說它是一種構建手段,稱它爲DSL表達能力的一種擴展可能更爲合適。你可使用通用語言在DSL中表達複雜的邏輯,在構建過程當中能夠將代碼塊當作閉包傳入。
scott handles floor_wax in MA when {/^Baker/.test(lead.name)};
經過Listener和Visitor從新遍歷語法樹,Listener在每次進入(top-down)和退出(bottom-up)節點的過程當中容許你自定義動做,你能夠在此組裝你的語義模型。Visitor則讓你在每次訪問一個子樹後生成一個新的對象。比較這兩種模式能夠看出Listener是嵌入助手的一個變體而Visitor是內嵌解釋器的增強。對於ANTLR4已經再也不支持自定義樹的構建過程了,咱們能夠經過實現Visitor遍歷已有的語法樹來構建知足咱們本身需求的新樹。
grammar VecMath; statList: stat+; stat: VAR '=' expression # assignStat | 'print' expression # printStat ; primary: VAR | INT | list; expression: multiExpr ( op=('+' | '-') multiExpr )*; parExpr: '(' expression ')' ; unaryExpr: '-'? factorExpr; multiExpr: unaryExpr (op=('*' | '.') unaryExpr)*; factorExpr: parExpr | primary; list: '[' expression (',' expression)* ']' ; VAR: ('a'..'z' | 'A'..'Z')+; INT: ('0'..'9')+; WS: ('\r' | '\n' | '\r' | ' ')+ {skip();};
public class ParseTreeVisitor extends VecMathBaseVisitor<Node> { @Override public Node visitStatList(VecMathParser.StatListContext ctx) { List<Node> children = ctx.stat().stream().map(c->visit(c)).collect(Collectors.toList()); RuleNode node = new RuleNode("statList"); node.setChildren(children); return node; } @Override public Node visitAssignStat(VecMathParser.AssignStatContext ctx) { RuleNode node = new RuleNode("stat"); node.setChildren(listChildren(ctx)); return node; } @Override public Node visitPrintStat(VecMathParser.PrintStatContext ctx) { RuleNode node = new RuleNode("stat"); node.setChildren(listChildren(ctx)); return node; } @Override public Node visitList(VecMathParser.ListContext ctx) { RuleNode node = new RuleNode("list"); node.setChildren(listChildren(ctx)); return node; } private List<Node> listChildren(ParserRuleContext ctx){ return ctx.children.stream().map(c->{ if(c instanceof TerminalNode) return new TokenNode(((TerminalNode) c).getSymbol()); else if(c instanceof ParserRuleContext) return visit(c); else return null; }).filter(c->c!=null).collect(Collectors.toList()); } }
重寫輸入流:有時候咱們須要對子樹進行模式匹配並對它作一些形式變換來更好地執行構建邏輯,咱們能夠採用重寫輸入流的方式來達到這一目的。
public class RewriteListener extends IDataBaseListener { TokenStreamRewriter rewriter; public RewriteListener(TokenStream tokens) { rewriter = new TokenStreamRewriter(tokens); } @Override public void enterGroup(IDataParser.GroupContext ctx) { rewriter.insertAfter(ctx.stop, '\n'); } }
對於內部DSL主題,可能更多的是一些代碼技巧和語言特性的使用,做爲一名程序猿你可能自己已經對此很熟悉了,但我仍是要在此獻醜一番了。
對於通用語言,生成模式的代碼形式主要是鏈式調用(方法級聯)、方法序列、嵌套方法;這些名詞對你來講可能有些陌生,讓我換一些更通俗的說法吧,上面分別對應於建造者模式、大量的連續指令也就是咱們最經常使用的編程形式、將一個函數的返回值做爲另外一個函數的參數使用。
接下來我要提一些在使用這些形式時須要注意的問題和可能會用到的技巧。當你使用方法級聯時你可能最須要注意的就是做用範圍了。一個生成器(Builder)是最簡單的狀況,咱們不如考慮這樣一種情形,A的生成須要B、C、D,B又依賴於E和F,而後他們每個都有大量的構造參數,這種時候你但願經過使用一次鏈式調用來完成A的建立,恐怕就須要限制級聯方法的範圍了,你能夠在生成器中記錄它的父生成器,父生成器的方法能夠調用子生成器的同名方法,子生成器方法結尾再次返回父生成器。這是一種解決思路,但並不夠優美,固然你也能夠選擇方法序列和鏈式調用結合。
讓咱們再考慮一種情形,若是A依賴於兩個B對象進行構建,那鏈式調用多是這樣的ABuilder.B().E(XX).F(XX).B().E(XX).F(XX),在實現過程當中你必須可以分清楚兩次不一樣的E方法分別做用於哪一個B,因此你可能須要一個應用來表示當前正在進行的階段和生成的對象,這就是語境變量。
當你使用嵌套函數的時候,最大的麻煩在於在你調用上層方法時,其嵌套方法都已經執行完畢了,數據也準備好了,你無法作一些更靈活的擴展。解決的方法一方面你能夠傳入生成器而非方法;另一方面你可使用閉包傳入代碼塊。閉包是一種極其優秀的語言特性,也是實現修飾模式的極佳選擇。使用閉包你能夠將對象的new操做的時機掌控在本身手中,並經過執行閉包中的代碼塊來實際初始化對象。更有意思的是閉包不只僅能夠用來直接執行,你還能夠將它當作是輸入的DSL(不過這裏DSL的語法剛好是通用語言)進行解析來生成語義模型,好比當閉包中是一個bool表達式組合,你就能夠將這個表達式解析成語法樹並最終構建出一個組合模式的語義模型(詳見DSL 41章,這一部分頗有意思)。
接下來咱們所提到的可能更多的和語言特性相關,下面一一列舉作個簡要介紹:
動態接收:ruby等動態語言的特性,當類中的某些方法未被定義可是卻被調用的時候容許你在類中自定義方法來處理這種狀況。Ruby的Getter/Setter能夠經過這種方法實現,一樣,咱們還能夠聯想到DAO中的findByXXX之類的方法,也能夠採用相似的技術來實現。動態接收雖然java不支持但咱們能夠借用裏面的思路,好比提供通用的API可是對於方法找不到的異常進行攔截並處理,最終調用通用API去完成其功能。固然實際上的Spring-Data-JPA應該不是這麼作的,我猜他們是使用了動態代理技術並反射填充了method。動態接收能夠幫助咱們擁有許多更富有語義的方法卻不用一一實現它而只用實現一個泛化的API和一個接收方法。
漸進式接口:爲了顯示規範鏈式調用中的操做順序,你可讓鏈中的方法返回不一樣的接口,每一個接口只包含後續操做的方法,但實際的生成器實現了全部的接口,這樣當你不進行顯式的類型轉換時就沒法按非法的順序執行調用鏈了,這種方法適合構造動做有強順序關係的場景。
字面量擴展:這個就是典型的語言特性了,好比對於已有的類Integer,咱們在特定的命名空間裏能夠爲它動態地擴展新方法,很惋惜Java並不支持這種形式。字面量擴展對於內部DSl的表達能力是一個提高,可是我奉勸你謹慎的使用它,由於絕大多數時候不須要使用它你能夠編寫出出色的代碼。
Literal Map:有些語言好比Python它們支持關鍵字參數和默認參數,這樣看起來語義更明晰並且在調用的時候更難以犯錯。咱們能夠經過爲方法傳入列表或Map來實現相似的功能,只是須要你在方法實現中首先解析這些。但我一樣不推薦這種作法,現代IDE的智能提示程度已經很大程度上爲咱們規避了調用時候出錯的可能,編寫同名方法也並不是多麼勞累的事情。
註解:註解的核心在於把定義和處理分離,其定義應當是聲明式的,不該該和任何處理的邏輯流程相關。咱們能夠這樣理解,註解是一種攜帶信息的索引,方便你在任什麼時候刻任何階段對被標註的實體進行處理。每個Java程序猿對註解都不可能不熟悉,註解常常被用於取代複雜的配置文件並容許用戶經過不多的代碼進行配置(Spring Boot),同時註解也能夠用來作Validation,關於註解的多種使用方式這裏就再也不贅述了。
類符號表:爲了使用IDE的提示功能,咱們能夠在生成器裏先聲明全部須要的對象,但不進行初始化操做,在生成器額初始化過程當中採用反射爲它們賦予標識符並開闢內存空間,最終在實際的初始化代碼中咱們就能夠直接使用這些對象進行操做了,這時你就可使用IDE爲它們的方法進行提示了。
在這裏我主要想提三個比較常見和有表明性的語義模型:
依賴網絡本質上是一個有向無環圖,包含了節點和它們之間的依賴關係,根據節點的類型咱們大體能夠分爲以任務爲核心的依賴網絡好比ANT和以輸出爲核心的依賴網絡好比Makefile,這裏面有些微妙的區別;前者可能更關心任務不會被重複執行也就是構建合理的任務執行序列,後者可能更關心當某個中間輸出結果變更時須要從新生成部分的輸出而並不是全盤從新輸出。
構建依賴網絡的主要問題是:
快速從任意節點出發遍歷網絡找出依賴關係。
檢測環的問題。
出現事件時(中間輸出變化或者依賴不知足)可以快速生成解決方案。
產生式規則系統的核心是一條一條的規則,當規則知足的時候會觸發相應的動做,因此核心在於如何快速找出候選的規則集,畢竟每次遍歷全部規則既不現實也不高效。這裏須要重視的是規則和規則之間的關係,當某個規則知足並觸發動做時可能會形成有一系列新的規則知足,這被稱爲規則的前向式交互。但咱們依然須要謹慎地防止環的出現,一種辦法是在加入規則時進行檢測,另外一種辦法則是在檢查規則的時候維護一個激活集,一個規則只能被激活一次。我認爲提早構建好規則之間的關係是個不錯的方法,這樣有助於咱們在實際判斷規則條件是否知足的時候可以快速檢索到那些被上一條規則激活的規則。
狀態機的使用十分普遍,相信在座的程序猿沒有幾個木有用過狀態機的,因此在這裏就很少言了,可是它又是如此的重要以致於我必須把它做爲一個醒目的標題列出來,忽視它是不可饒恕的。
在這一節裏咱們主要探討代碼翻譯和代碼生成的技術,根據是否須要顯式藉助語義模型、是否須要藉助於外部信息等能夠把它們分爲三大類:
語法制導的翻譯:不須要藉助語義模型,不須要輸出模型,以直接輸出外部語句爲主,在語法解析的同時就插入動做進行翻譯,對當前的翻譯不須要輸入流後面的信息。
基於規則的翻譯系統:雖然不須要語義模型,可是須要顯式的外部規則的指導,通常規則是由輸入語句的文法和執行的轉換組成。對於基於規則的翻譯系統,你既能夠在解析語法結構的同時匹配規則;也能夠先生成IR好比AST再遍歷語法樹經過樹文法(在ANTLR4中已經廢棄,你能夠經過Visitor生成符合你遍歷規則的新樹來完成這件事)作子模式匹配來匹配規則,固然通常狀況
基於語義模型的翻譯:這種翻譯和語法解析過程就幾乎沒有關係了,你的目標轉變爲一個個語義對象生成對應的輸出。你可能須要定製特定目標的生成類,好比對於生成SQL語句,你能夠爲Table類單獨編寫一個生成輸出的類,固然你也能夠不借助於輸出生成器,從Table組裝它的每一列最終直接輸出。但我認爲輸出的任務最好不要和語義模型混雜在一塊兒,這違反了單一職責原則。
上面講的都是代碼生成的思路,對於如何操縱輸出內容所提甚少,在實際開發中,輸出模板和模板引擎每每是必不可少的。能夠爲每個語義對象建立輸出模板,而後將實際的語義對象傳入模板引擎,在模板中填入動態變化的數據。這種方式對於AJAX時代以前的WEB程序猿簡直是得心應手,大量的模板被用來生成HTML中的動態內容,本質上並沒有任何不一樣。只不過當咱們藉助於語義模型而且有大量的嵌套操做時,咱們可能得作些模板的嵌套和拼接了。
當不借助於語義模型時咱們也能夠在樹的構建中使用模板以生成輸出,不過在ANTLR4中你可能得手動編寫Listener來調用模板了。說到這裏我就不得不吐槽一下ANTLR4了,雖然我認可將文法和各類邏輯操做解耦是一個正確的方向,可是忽然感受一切沒有那麼靈活了,儘管去掉的這些功能幾乎均可以很快地經過Listener和Visitor實現,依然有一種蛋疼的感受。最後放一個StringTemplate的模板定義文件吧。
group Cymbol; // START: file file(defs) ::= << <defs; separator="\n"> >> // END: file // START: class class(name, sup, members) ::= << class <name> <if(sup)>: <sup><endif> { <members> }; >> // END: class // START: method method(name, retType, args, block) ::= << <retType> <name>(<args; separator=", ">) <block> >> // END: method // START: block block(stats) ::= << { <stats; separator="\n"> } >> // END: block // START: if if(cond, stat1, stat2) ::= << if ( <cond> ) <stat1> <if(stat2)>else <stat2><endif> >> // END: if // START: assign assign(a,b) ::= "<a> = <b>;" // END: assign return(v) ::= "return <v>;" // START: callstat callstat(name, args) ::= "<call(...)>;" // call() inherits name,args // END: callstat // START: decl decl(name, type, init, ptr) ::= "<type> <if(ptr)>*<endif><name><if(init)> = <init><endif>" var(name, type, init, ptr) ::= "<decl(...)>;" arg(name, type, init, ptr) ::= "<decl(...)>" // END: decl // START: ops operation(op, x, y) ::= "(<x> <op> <y>)" // END: ops // START: operator operator(o) ::= "<o>" // END: operator unary_minus(v) ::= "-(<v>)" unary_not(v) ::= "!(<v>)" addr(v) ::= "&(<v>)" deref(v) ::= "*(<v>)" index(array, i) ::= "<array>[<i>]" member(obj, name) ::= "(<obj>).<name>" // START: call call(name, args) ::= << <name>(<args; separator=", ">) >> // END: call
這篇文章的主要目的是爲了整理和歸納一下DSL的主要相關知識,可能內容也有些雜亂,有些地方也沒有說的很清楚,或者是說到一半就戛然而止了,但願你們多多包涵,也但願你們有什麼想法能夠和我討論。