幾個月前就一直有博友關心DSL的問題,因而我想想,我在gac.codeplex.com裏面也建立了一些DSL,因而今天就來講一說這個事情。php
建立DSL恐怕是不少人第一次設計一門語言的經歷,不多有人一開始上來就設計通用語言的。我本身第一次作這種事情是在高中寫這個傻逼ARPG的時候了。當時作了一個超簡單的腳本語言,長的就跟彙編差很少,雖然每個指令都寫成了調用函數的形態。雖然這個遊戲須要腳本在劇情裏面控制一些人物的走動什麼的,可是所幸並不複雜,因而仍是完成了任務。一眨眼10年過去了,如今在寫GacUI,爲了開發的方便,我本身作了一些DSL,或者實現了別人的DSL,漸漸地也明白了一些設計DSL的手法。不過在講這些東西以前,咱們先來看一個令咱們又愛(對全部人)又恨(反正我不會)的DSL——正則表達式!html
1、正則表達式正則表達式
正則表達式可讀性之差咱們人人都知道,並且正則表達式之難寫好都值得O’reilly出一本兩釐米厚的書了。根據個人經驗,只要先學好編譯原理,而後按照.net的規格本身擼一個本身的正則表達式,基本上這本書就不用看了。由於正則表達式之因此要用奇怪的方法去寫,只是由於你手上的引擎是那麼實現的,因此你須要順着他去寫而已,沒什麼特別的緣由。並且我本身的正則表達式擁有DFA和NFA兩套解析器,個人正則表達式引擎會經過檢查你的正則表達式來檢查是否能夠用DFA,從而能夠優先使用DFA來運行,省去了不少其實不是那麼重要的麻煩(譬如說a**會傻逼什麼的)。這個東西我本身用的特別開心,代碼也放在gac.codeplex.com上面。算法
正則表達式做爲一門DSL是當之無愧的——由於它用了一種緊湊的語法來讓咱們能夠定義一個字符串的集合,而且取出裏面的特徵。大致上語法我仍是很喜歡的,我惟一不喜歡的是正則表達式的括號的功能。括號做爲一種指定優先級的方法,幾乎是沒法避免使用的。可是不少流行的正則表達式的括號居然還帶有捕獲的功能,實在是令我大跌眼鏡——由於大部分時候我是不須要捕獲的,這個時候只會浪費時間和空間去作一些多餘的事情而已。因此在我本身的正則表達式引擎裏面,括號是不捕獲的。若是要捕獲,就得用特殊的語法,譬如說(<name>pattern)把pattern捕獲到一個叫作name的組裏面去。數據庫
那咱們能夠從正則表達式的語法裏面學到什麼DSL的設計原則呢?我認爲,DSL的原則其實很簡單,只有如下三個:api
不少DSL其實都知足這個定義。SQL就屬於API簡單並且可讀性好的那一部分(想一想ADO.NET),而正則表達式就屬於API簡單並且語法緊湊的那一部分。爲何正則表達式能夠設計的那麼緊湊呢?如今讓咱們來一一揭開它神祕的面紗。數組
正則表達式的基本元素是不多的,只有鏈接、分支和循環,還有一些簡單的語法糖。鏈接不須要字符,分支須要一個字符「|」,循環也只須要一個字符「+」或者「*」,還有表明任意字符的「.」,還有表明屢次循環的{5,},還有表明字符集合的[a-zA-Z0-9_]。對於單個字符的集合來說,咱們甚至不須要[],直接寫就行了。除此以外由於咱們用了一些特殊字符因此還得有轉義(escaping)的過程。那讓咱們數數咱們定義了多少字符:「|+*[]-\{},.()」。用的也很少,對吧。數據結構
儘管看起來很亂,可是正則表達式自己也有一個嚴謹的語法結構。關於個人正則表達式的語法樹定義能夠看這裏:https://gac.codeplex.com/SourceControl/latest#Common/Source/Regex/RegexExpression.h。在這裏咱們能夠整理出一個語法: 閉包
DIGIT ::= [0-9] LITERAL ::= [^|+*\[\]\-\\{}\^,.()] ANY_CHAR ::= LITERAL | "^" | "|" | "+" | "*" | "[" | "]" | "-" | "\" | "{" | "}" | "," | "." | "(" | ")" CHAR ::= LITERAL ::= "\" ANY_CHAR CHARSET_COMPONENT ::= CHAR ::= CHAR "-" CHAR CHARSET ::= CHAR ::= "[" ["^"] { CHARSET_COMPONENT } "]" REGEX_0 ::= CHARSET ::= REGEX_0 "+" ::= REGEX_0 "*" ::= REGEX_0 "{" { DIGIT } ["," [ { DIGIT } ]] "}" ::= "(" REGEX_2 ")" REGEX_1 ::= REGEX_0 ::= REGEX_1 REGEX_0 REGEX_2 ::= REGEX_1 ::= REGEX_2 "|" REGEX_1 REGULAR_EXPRESSION ::= REGEX_2
這只是隨手寫出來的語法,儘管可能不是那麼嚴謹,可是表明了正則表達式的全部結構。爲何咱們要熟練掌握EBNF的閱讀和編寫?由於當咱們用EBNF來看待咱們的語言的時候,咱們就不會被愈發的表面所困擾,咱們會投過語法的外衣,看到語言自己的結構。脫別人衣服老是很爽的。編輯器
因而咱們也要透過EBNF來看到正則表達式自己的結構。其實這是一件很簡單的事情,只要把EBNF裏面那些「fuck」這樣的字符字面量去掉,而後規則就會分爲兩種:
1:規則僅由終結符構成——這是基本概念,譬如說上面的CHAR什麼的。
2:規則的構成包含非終結符——這就是一個結構了。
咱們甚至能夠利用這種方法迅速從EBNF肯定出咱們須要的語法樹長什麼樣子。具體的方法我就不說了,你們本身聯繫一下就會悟到這個簡單粗暴的方法了。可是,咱們在設計DSL的時候,是要反過來作的。首先肯定語言的結構,翻譯成語法樹,再翻譯成不帶「fuck」的「骨架EBNF」,再設計具體的細節寫成完整的EBNF。
看到這裏你們會以爲,其實正則表達式的結構跟四則運算式子是沒有區別的。正則表達式的*是後綴操做符,|是中綴操做符,鏈接也是中最操做符——並且操做符是隱藏的!我猜perl系正則表達式的做者當初在作這個東西的時候,確定糾結過「隱藏的中綴操做符」應該給誰的問題。不過其實咱們能夠經過收集一些素材,用不一樣的方案寫出正則表達式,最後通過統計發現——隱藏的中綴操做符給鏈接操做是最靠譜的。
爲何呢?咱們來舉個例子,若是咱們把鏈接和分支的語法互換的話,那麼本來「fuck|you」就要寫成「(f|u|c|k)(y|o|u)」了。寫多幾個你會發現,的確鏈接是比分支更經常使用的,因此短的那個要給鏈接,因此鏈接就被分配了一個隱藏的中綴操做符了。
上面說了這麼多廢話,只是爲了說明白一個道理——要先從結構入手而後才設計語法,而且要把最短的語法分配給最經常使用的功能。由於不少人設計DSL都反着來,而後作成了屎。
2、Fpmacro
第二個要講的是Fpmacro。簡單來講,Fpmacro和C++的宏是相似的,可是C++的宏是從外向內展開的,這意味着dynamic scoping和call by name。Fpmacro是從內向外展開的,這意味着lexical scoping和call by value。這些概念我在第七篇文章已經講了,你們也知道C++的宏是一件多麼不靠譜的事情。可是爲何我要設計Fpmacro呢?由於有一天我終於須要相似於Boost::Preprocessor那樣子的東西了,由於我要生成相似這樣的代碼。可是C++的宏實在是太他媽噁心了,噁心到連我都不能駕馭它。最終我就作出了Fpmacro,因而我能夠用這樣的宏來生成上面提到的文件了。
我來舉個例子,若是我要生成下面的代碼:
int a1 = 1; int a2 = 2; int a3 = 3; int a4 = 4; cout<<a1<<a2<<a3<<a4<<endl;
就要寫下面的Fpmacro代碼:
$$define $COUNT 4 /*定義數量:4*/ $$define $USE_VAR($index) a$index /*定義變量名字,這樣$USE_VAR(10)就會生成「a10」*/ $$define $DEFINE_VAR($index) $$begin /*定義變量聲明,這樣$DEFINE_VAR(10)就會生成「int a10 = 10;」*/ int $USE_VAR($index) = $index; $( ) /*用來換行——會多出一個多餘的空格不過不要緊*/ $$end $loop($COUNT,1,$DEFINE_VAR) /*首先,循環生成變量聲明*/ cout<<$loopsep($COUNT,1,$USE_VAR,<<)<<endl; /*其次,循環使用這些變量*/
順便,Fpmacro的語法在這裏,FpmacroParser.h/cpp是由這個語法生成的,剩下的幾個文件就是C++的源代碼了。不過由於今天講的是如何設計DSL,那我就來說一下,我當初爲何要把Fpmacro設計成這個樣子。
在設計以前,首先咱們須要知道Fpmacro的目標——設計一個沒有坑的宏,並且這個宏還要支持分支和循環。那如何避免坑呢?最簡單的方法就是把宏當作函數,真正的函數。當咱們把一個宏的名字當成參數傳遞給另外一個宏的時候,這個名字就成爲了函數指針。這一點C++的宏是不可能徹底的作到的,這裏的坑實在是太多了。並且Boost::Preprocessor用來實現循環的那個技巧實在是我操太他媽難受了。
因而,咱們就能夠把需求整理成這樣:
爲何要強調轉義呢?由於若是用Fpmacro隨便寫點什麼代碼都要處處轉義的話,那還怎麼寫得下去呀!
這個時候咱們開始從結構入手。Fpmacro的結構是簡單的,只有下面幾種:
根據上面提到的DSL三大原則,咱們要給最經常使用的功能配置最短的語法。那最短的功能是什麼呢?跟正則表達式同樣,是鏈接。因此要給他一個隱藏的中綴運算符。其次就要考慮到轉義了。若是Fpmacro大量運用的字符與C++用到的字符同樣,那麼咱們在C++裏面用這個字符的時候,就得轉義了。這個是絕對不能接受的。咱們來看看鍵盤,C++沒用到的也就只有@和$了。這裏我由於我的喜愛,選擇了$,它的功能大概跟C++的宏裏面的#差很少。
那咱們如何知道咱們的代碼片斷是訪問一個C++的名字,仍是訪問一個Fpmacro的名字呢?爲了不轉義,並且也順即可以突出Fpmacro的結構自己,我讓全部的Fpmacro名字都要用$開頭,不管是函數名仍是參數都同樣。因而定義函數就用$$define開始,並且多行的函數還要用$$begin和$$end來提示(見上面的例子)。函數調用就能夠這麼作:$名字(一些參數)。由於無論是參數名仍是函數名都是$開頭的,因此函數調用確定也是$開頭的。那寫出來的代碼真的須要轉義怎麼辦呢?直接用$(字符)就好了。這個時候咱們能夠來檢查一下這樣作是否是會定義出歧義的語法,答案固然是不會。
咱們定義了$做爲Fpmacro的名字前綴以後,是否是一個普通的C++代碼(所以沒有$),直接貼上去就至關於一個Fpmacro代碼呢?結論固然是成立的。仔細選擇這些語法可讓咱們在只想寫C++的時候能夠專心寫C++而不會被各類轉義干擾到(想一想在C++裏面寫正則表達式的那一堆斜槓臥槽)。
到了這裏,就到了最關鍵的一步了。那咱們把一個Fpmacro的名字傳遞給參數的時候,到底是什麼意思呢?一個Fpmacro的名字,要麼就是一個字符串,要麼就是一個Fpmacro函數,不會有別的東西了(其實還多是數組,可是最後證實沒用)。這個純潔性要一直保持下去。就跟咱們在C語言裏面傳遞一個函數指針同樣,無論傳遞到了哪裏,咱們均可以隨時調用它。
那Fpmacro的函數到底有沒有包括上下文呢?由於Fpmacro和pascal同樣有「內部函數」,因此固然是要有上下文的。可是Fpmacro的名字都是隻讀的,因此只用shared_ptr來記錄就能夠了,不須要出動GC這樣的東西。關於爲何帶變量的閉包就必須用GC,這個你們能夠去想想。這是Fpmacro的函數像函數式語言而不是C語言的一個地方,這也是爲何我把名字寫成了Fpmacro的緣由了。
不過Fpmacro是不帶lambda表達式的,由於這樣只會把語法搞得更糟糕。再加上Fpmacro容許定義內部函數和Fpmacro名字是隻讀的這兩條規則,全部的lambda表達式均可以簡單的寫成一個內部函數而後賦予它一個名字。所以這一點沒有傷害。那何時須要傳遞一個Fpmacro函數呢進另外一個函數呢?固然就只有循環了。Fpmacro的內置函數有分支循環還有簡單的數值計算和比較功能。
咱們來作一個小實驗,生成下面的代碼:
void Print(int a1) { cout<<"1st"<<a1<<endl; } void Print(int a1, int a2) { cout<<"1st"<<a1<<", "<<"2nd"<<a2<<endl; } .... void Print(int a1, int a2, ... int a10) { cout<<...<<"10th"<<a10<<endl; } ....
咱們須要兩重循環,第一重是生成Print,第二重是裏面的cout。cout裏面還要根據數字來產生st啊、nd啊、rd啊、這些前綴。因而咱們能夠開始寫了。Fpmacro的寫法是這樣的,由於沒有lambda表達式,因此循環體都是一些獨立的函數。因而咱們來定義一些函數來生成變量名、參數定義和cout的片斷:
$$define $VAR_NAME($index) a$index /*$VAR_NAME(3) -> a3*/ $$define $VAR_DEF($index) int $VAR_NAME($index) /*$VAR_DEF(3) -> int a3*/ $$define $ORDER($index) $$begin /*$ORDER(3) -> 3rd*/ $$define $LAST_DIGIT $mod($index,10) $index$if($eq($LAST_DIGIT,1),st,$if($eq($LAST_DIGIT,2),nd,$if($eq($LAST_DIGIT,3),rd,th))) $$end $$define $OUTPUT($index) $(")$ORDER($index)$(")<<$VAR_NAME($index) /*$OUTPUT(3) -> "3rd"<<a3*/
接下來就是實現Print函數的宏:
$$define $PRINT_FUNCTION($count) $$begin void Print($loopsep($count,1,$VAR_DEF,$(,))) { cout<<$loopsep($count,1,$OUTPUT,<<)<<endl; }
$( ) $$end
最後就是生成整片代碼了:
$define $COUNT 10 /*就算是20,那上面的代碼的11也會生成11st,特別方便*/ $loop($COUNT,1,$PRINT_FUNCTION)
注意:註釋實際上是不能加的,由於若是你加了註釋,這些註釋最後也會被生成成C++,因此上面那個$COUNT就會變成10+空格+註釋,他就不能放進$loop函數裏面了。Fpmacro並無添加「Fpmacro註釋」的代碼,由於我以爲不必
爲何咱們不須要C++的宏的#和##操做呢?由於在這裏,A(x)##B(x)被咱們處理成了$A(x)$B(x),而L#A(x)被咱們處理成了L$(「)$A(x)$(「)。雖然就這麼看起來好像Fpmacro長了一點點,可是實際上用起來是特別方便的。$這個前綴剛好幫咱們解決了A(x)##B(x)的##的問題,寫的時候只須要直接寫下去就能夠了,譬如說$ORDER裏面的$index$if…。
那麼這樣作到底行不行呢?看在Fpmacro能夠用這個宏來生成這麼複雜的代碼的份上,我認爲「簡單緊湊」和「C++代碼幾乎不須要轉義」和「沒有坑」這三個目標算是達到了。DSL之因此爲DSL就是由於咱們是用它來完成特殊的目的的,不是general purpose的,所以不須要太複雜。所以設計DSL要有一個習慣,就是時刻審視一下,咱們是否是設計了多餘的東西。如今我回過頭來看,Fpmacro支持數組就是多餘的,並且實踐證實,根本沒用上。
你們可能會說,代碼遍地都是$看起來也很亂啊?不要緊,最近我剛剛搞定了一個基於語法文件驅動的自動着色和智能提示的算法,只須要簡單地寫一個Fpmacro的編輯器就能夠了,啊哈哈哈哈。
3、尾聲
原本我是想舉不少個例子的,還有語法文件啊,GUI配置啊,甚至是SQL什麼的。不過其實設計一個DSL首先要求你對領域自己有着足夠的理解,在長期的開發中已經在這個領域裏面感覺到了極大的痛苦,這樣你才能真的設計出一個專門根除痛點的DSL來。
像正則表達式,咱們都知道手寫字符串處理程序常常要人肉作錯誤處理和回溯等工做,正則表達式幫咱們自動完成了這個功能。
C++的宏生成複雜代碼的時候,動不動就會由於dynamic scoping和call by name掉坑裏並且尚未靠譜的工具來告訴咱們究竟要怎麼作,Fpmacro就解決了這個問題。
開發DSL須要語法分析器,並且帶Visitor模式的語法樹可擴展性好可是定義起來特別的麻煩,因此我定義了一個語法文件的格式,寫了一個ParserGen.exe(代碼在這裏)來替我生成代碼。Fpmacro的語法分析器就是這麼生成出來的。
GUI的構造代碼寫起來太他媽煩了,因此還得有一個配置的文件。
查詢數據特別麻煩,並且就算是隻有十幾個T的小型數據庫也很難本身設計一個靠譜的容器,因此咱們須要SQLServer。這個DSL作起來不簡單,可是用起來簡單。這也是一個成功的DSL。
相似的,Visual Studio爲了生成代碼還提供了T4這種模板文件。這個東西其實超好用的——除了用來生成C++代碼,因此我還得本身擼一個Fpmacro……
用MVC的方法來寫HTML,須要從數據結構裏面拼HTML。用過php的人都知道這種東西很容易就寫成了屎,因此Visual Studio裏面又在ASP.NET MVC裏面提供了razor模板。並且他的IDE支持特別號,razor模板裏面能夠混着HTML+CSS+Javascript+C#的代碼,智能提示從不出錯!
還有各類數不清的配置文件。咱們都知道,一個強大的配置文件最後都會進化成爲lisp,哦不,DSL的。
這些都是DSL,用來解決咱們的痛點的東西,並且他自己又不足以複雜到用來完成程序全部的功能(除了連http service都能寫的SQLServer咱們就不說了=_=)。設計DSL的時候,首先要找到痛點,其次要理清楚DSL的結構,而後再給他設計一個要麼緊湊要麼可讀性特別高的語法,而後再給一個簡單的API,用起來別提多爽了。