如何設計一門語言(十)——正規表示式與領域特定語言(DSL)

陳梓瀚(vczh)發表於2013-09-16

幾個月前就一直有博友關心DSL的問題,於是我想一想,我在gac.codeplex.com裡面也建立了一些DSL,於是今天就來說一說這個事情。

建立DSL恐怕是很多人第一次設計一門語言的經歷,很少有人一開始上來就設計通用語言的。我自己第一次做這種事情是在高中寫這個傻逼ARPG的時候了。當時做了一個超簡單的指令碼語言,長的就跟彙編差不多,雖然每一個指令都寫成了呼叫函式的形態。雖然這個遊戲需要指令碼在劇情裡面控制一些人物的走動什麼的,但是所幸並不複雜,於是還是完成了任務。一眨眼10年過去了,現在在寫GacUI,為了開發的方便,我自己做了一些DSL,或者實現了別人的DSL,漸漸地也明白了一些設計DSL的手法。不過在講這些東西之前,我們先來看一個令我們又愛(對所有人)又恨(反正我不會)的DSL——正規表示式!

一、正規表示式

正規表示式可讀性之差我們人人都知道,而且正規表示式之難寫好都值得O’reilly出一本兩釐米厚的書了。根據我的經驗,只要先學好編譯原理,然後按照.net的規格自己擼一個自己的正規表示式,基本上這本書就不用看了。因為正規表示式之所以要用奇怪的方法去寫,只是因為你手上的引擎是那麼實現的,所以你需要順著他去寫而已,沒什麼特別的原因。而且我自己的正規表示式擁有DFA和NFA兩套解析器,我的正規表示式引擎會通過檢查你的正規表示式來檢查是否可以用DFA,從而可以優先使用DFA來執行,省去了很多其實不是那麼重要的麻煩(譬如說a**會傻逼什麼的)。這個東西我自己用的特別開心,程式碼也放在gac.codeplex.com上面。

正規表示式作為一門DSL是當之無愧的——因為它用了一種緊湊的語法來讓我們可以定義一個字串的集合,並且取出裡面的特徵。大體上語法我還是很喜歡的,我唯一不喜歡的是正規表示式的括號的功能。括號作為一種指定優先順序的方法,幾乎是無法避免使用的。但是很多流行的正規表示式的括號竟然還帶有捕獲的功能,實在是令我大跌眼鏡——因為大部分時候我是不需要捕獲的,這個時候只會浪費時間和空間去做一些多餘的事情而已。所以在我自己的正規表示式引擎裡面,括號是不捕獲的。如果要捕獲,就得用特殊的語法,譬如說(<name>pattern)把pattern捕獲到一個叫做name的組裡面去。

那我們可以從正規表示式的語法裡面學到什麼DSL的設計原則呢?我認為,DSL的原則其實很簡單,只有以下三個:

  1. 短的語法要分配給常用的功能
  2. 語法要麼可讀性特別好(從而比直接用C#寫直接),要麼很緊湊(從而比直接用C#寫短很多)
  3. API要容易定義(從而用C#呼叫非常方便,還可以確保DSL的目標是明確又簡單的)

很多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都反著來,然後做成了屎。

二、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用來實現迴圈的那個技巧實在是我操太他媽難受了。

於是,我們就可以把需求整理成這樣:

  1. Fpmacro的程式碼由函式組成,每一個函式的唯一目的都是生成C++程式碼的片段。
  2. 函式和函式之間的空白可以用來寫程式碼。把這些程式碼收集起來就可以組成“main函式”了,從而構成Fpmacro程式碼的主體。
  3. 函式可以有內部函式,在程式碼複雜的時候可以充當一些namespace的功能,而且內部函式都是私有的。
  4. Fpmacro程式碼可以include另一份Fpmacro程式碼,可以實現全域性配置的功能。
  5. Fpmacro必須支援分支和迴圈,而且他們的語法和函式呼叫應該一致。
  6. 用來代表C++程式碼的部分需要的轉義應該降到最低。
  7. 即使是非功能程式碼部分,括號也必須配對。這是為了定義出一個清晰的簡單的語法,而且因為C++本身也是括號配對的,所以這個規則並沒有傷害。
  8. C++本身對空格是有很高的容忍度的,因此Fpmacro作為一個以換行作為分隔符的語言,並不需要具備特別精確的控制空格的功能。

為什麼要強調轉義呢?因為如果用Fpmacro隨便寫點什麼程式碼都要到處轉義的話,那還怎麼寫得下去呀!

這個時候我們開始從結構入手。Fpmacro的結構是簡單的,只有下面幾種:

  1. 普通C++程式碼
  2. 巨集名字引用
  3. 巨集呼叫
  4. 連線
  5. 括號
  6. 表達陣列字面量(最後這被證明是沒有任何意義的功能)

根據上面提到的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的編輯器就可以了,啊哈哈哈哈。

三、尾聲

本來我是想舉很多個例子的,還有語法檔案啊,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,用起來別提多爽了。

相關文章