前言
過去幾年蠻熱門的話題(?),原本還想來學院派式的追本溯源一下,不過凡事起頭難,手邊資料不多,很多論文又不能下載,就作罷了,當閒聊了,又不是要寫論文 XD
我想是因為自己很晚才接觸資訊這個領域,加上上個世紀,網路、社群並非如此盛行,總有種錯覺:這幾年許多新語言的出現不再宣稱自己能解決所有的問題、可以被應用在所有領域,而是將焦點專注於特定問題、領域(domain)的,當然,這不是個新概念,但 Domain-specific language 卻是這幾年才流行起來的。
Generative Programming 一書是這麼描述 DSL 的:
A domain-specific language (DSL) is a specialized, problem-oriented language. …(略) ... Domain-specific languages can be textural (e.g. SQL) or graphical (e.g. the graphical specification of a GUI in a GUI builder).
哈,基本上這是個很寬鬆的描述,會被 reviewer 打槍的那種,不過不難想像到 DSL 概念的成品已經充斥著我們生活周遭,像是:
- lex & yacc:大學 compiler 課用會到!
- SQL
- 曾經 SLIM 想用的 XUL ,到後來自己設計的 component script
- SMP 的 SREAM script
- ChucK (那有沒有 SCREAM 呢!)
- 微軟 .Net 新一代的 GUI 平台用到的 XAML(很多 open source GUI framework 也有類似的概念)
- UML 中的 OCL
這幾年 VM 這種結合 compiler, interpreter 特性的平台語言很是盛行,透過重複利用 VM 的中間碼可以讓原本設計一個 programming langauge 需要的繁雜後端工作得到部份減輕。所以許多研究、實驗性質的 DSL 會考慮建構在 VM 之上。舉例來說,就有許多語言建構在 JVM 之上,參考這邊。
優點
那到底 DSL 的好處是什麼呢?wiki 和 Domain-Specific Language: An Annotated Bibliography 做了很好的條列和解釋,不過我還是想再說一遍:( -_-||唉,我想我個性不太好)
- Expressive:我想這是最重要一點,人類的思考會受到語言、符號、圖像的限制。因此 DSL 在描述問題、解決問題時都會比 general purpose language (暫用 GPL 代表)更能貼近核心。最顯而易見的例子就是 GUI 的設計,用 WYSIWYG 的方式刻繪介面會比純文字好、BNF form 比一堆 if, else, loop 湊出來的 FSM 更能表達語言結構、SQL 語法比用 procedural 清晰。此外,好的表達力可以讓維護程式碼時,一目了然,不需太多註解、文件相輔,一行 DSL 程式碼表達的運算可能需要數行 GPL 才能完成,這種精簡效果是很可觀的!
- 語法支持:這個部份我最想挑出來說的是 domain-specific error report/checking 和 constraint enforcement。好的語言不僅給予程式設計師自由的空間去發會,還要能適時給予程式設計師提醒與限制。 enforcement 是我進公司後才體驗到的一個 term ,這裡或許反映出一個個人喜好,我不喜歡把錯誤推遲到執行時期發現。不同的應用中常會帶來不同的限制或者說是規範,最糟糕的情況是無法透過 GPL 既有元素去表達的,或是僅能在執行時期偵測、或是以間接、隱晦的方式去表現。
- 其他,如可以做到更好的最佳化、程式碼內嵌 domain knowledge、易於測試等。(哈,我用其他來帶過,其實就代表我個人認為前兩項是最重要的好處)
為了避免空口說白話,這邊以 C++ 實做一個矩陣為例子來說明 GPL 可能做不好的地方:
class MyMatrix {
public:
MyMatrix();
MyMatrix( int row, int col );
int operator ()( int row, int col );
vector<int>& operator []( int row );
template <typename T>
MyMatrix& operator =( const T& );
private:
int row_;
int col_;
vector< vector<int> > data_;
};
使用時很簡單,就像下面一樣,精練、不失矩陣的意含(我想是這樣沒錯):
MyMatrix m1( 10, 10 );
第一步看起來不錯,但是很明顯的當我們想初始化矩陣時,無論我們怎麼努力,卻很難只靠 C++ 本身(先不討論 Preprocessor)做到像下面的效果:
MyMatrix m2 = { { 0, 0 }, { 0, 0 } };
最多我們只能靠著 operator overloading 做到:
for ( int i = 0; i < 10; ++i ) {
for ( int j = 0; j < 10; ++j ) {
m1[ i ][ j ] = i + j;
}
}
或是提供 MyMatrixRow, MyMatrixCol 之類的 Proxy class 去一次設定多個元素;但無論如何,當元素間沒有規則時,一個一個指定矩陣元素的步驟就跑不了,此時若是有個 Matrix 相關的 DSL 輔助,我們可以寫出更精練、具可讀性的程式。下一個我們會面臨的問題可能發生在矩陣運算上,以加法為例,兩個相加的矩陣必須具有同樣的維度,但這個限制(或說規範)在 MyMatrix 是無法直接表達的(請先不要考慮到 C++ template,首先是因為這不是每個語言都有的 feature,其次是之後會有個段論詳細解釋這部份),所以我們可能得在
MyMatrix operator +( const MyMatrix& lhs, const MyMatrix& rhs );
的實做時,去檢查矩陣的維度,當兩者不符合時丟出一個 excption。當然不是說 exception 不好,而是若是能越早發現錯誤會更好,尤其是這種維度的指定與推導是可以靜態完成的,實在沒有必要留待動態時期去偵測。此時若是有個 Matrix 相關的 DSL 可以在撰寫時給予這方面的限制,可以省去不少麻煩、也能提高程式效能。
缺點
說完 DSL 的優點,聽起來是如此好用,讓我也一度著迷,想做些 DSL 出來用,不過現實世界當然不會如此美好!DSL 最大的問題在於:
- 草創期
實做一個 DSL 需要許多人力投入在設計、實做上,先期的 domain/problem 分析、研究更是花費人工。這階段最惱人的應該是穩定度吧?!
- 初期
當語言實做完成後,下一個面臨的問題就是配套與使用者心理。即使語言再簡單,都還是有自己的中心哲學,對於使用者來說:他們是不是快速的學習、語言的 Learning curve 是不是適當呢?甚至使用者可能排斥再學一個新語言?接著實際開發時,是不是相關的 tool chain 都能搭配上,是不是能有好用的 editor、IDE 來輔助開發、吸引開發者目光。而其中最為麻煩的,我想是錯誤資訊(像是 compiler error、failed to code gen …)與 debug 的環境,該如何讓使用者快速 trouble shooting 是很重要的。即使今天我們提供的 DSL 只是做 transformation,把 MyMatrix DSL 轉成 C++ 而已,錯誤資訊的轉換是恨重要的,有用過 lex/yacc 的人可以回憶一下,在寫錯 lex/yacc 的程式時,lex/yacc 的 codegen 程式有時不會知道,它們只能根據預先的條件去轉換成 C 語言,當送給 C compiler 時才會 compile errors,此時使用者該如何從這樣的錯誤對應回 lex/yacc 的錯誤呢?當然最好的方式是,使用者不必以 codegen 後的角度去找尋錯誤,而是直接面對他們寫的 DSL 程式去找錯(像是 lex, yacc 程式碼、MyMatrix),同樣地,debug 也是面臨同樣問題,lex/yacc 雖然在code generation 時會把對應的行號、檔名寫到產生出來的 .c 檔中,但用 C debugger 回頭看到的卻是 FSM 和 LALR 的程式,個人認為使用者是很容易迷路的!
- 中期
維護,像是文件、社群資源等都是需要長時間投入的。
- 長期
想必該是面對改版相容性問題時候了!
DSL 的形式
從 DSL 的實做方式以及它怎麼跟其他語言合作,我們可以大致上分成下面幾種,並且從設計、實做與使用上來討論一下:
- Fixed, separate DSLs
這是最常見的一種形式,DSL 擁有自行一套的 tool chain 甚至執行環境,例如 SQL 。這種形式的 DSL 最大的問題是很難跟其他語言進行良好的整合,可能是語言特性(static vs. dynamic)、執行方式(compiler vs. interpreter)、執行環境的差異、tool chain 等因素造成。即使是可以透過一定的手法把 separate DSLs 轉為 embedded DSLs,但仍不是很方便。而 DSL 實做上呢?從頭到尾打造一個 DSL 是很曠日費時,而且成本極高的一件事,更不要談說相關的開發環境。簡單的說, DSL 和他要合作的語言間太鬆綁了(loose coupling),loose coupling 有時不是件好事。
- Embedded DSLs
另一種方式是我們可以在 GPL 中定義相關的類別、函式去包裝 DSL ,讓開發過程中,DSL 與 GPL 是緊緊地包在一起的,這聽起來比 separate DSLs 好,但是根據實做出的合作方式,卻可能比 separate DSLs 好不到哪裡去,舉個例子來說,很多 database 都會有不同語言的 interface library 供開發者使用,PostgreSQL 有一個 C++ 介面的函式,叫做 libpgxx ,它與 PostgreSQL 連接的方式是:
int playWithDb() {
connection Conn( "dbname=test" );
{
work Xaction( Conn, "DemoTransaction" );
result r = Xaction.exec( "DELETE FROM " + Table + " WHERE ID=" + ID );
for ( result::size_type i = 0; i != R.size(); ++i ) {
Process(R[i]["lastname"]);
}
}
}
或是看個 C++ 與 TCL 的整合:
void hello()
{
cout << "Hello C++/Tcl!" << endl;
}
int main()
{
interpreter i;
i.def("hello", hello);
string script = "for {set i 0} {$i != 4} {incr i} { hello }";
i.eval(script);
}
不難發現這種倚賴參數、字串來執行 DSL 的方式讓我們失去了機會去做一些事情:
- express domain-specific optimization
- domain-specific error report: syntax error 和 debug 是我認為尤其重要的,像是 compile time 的 error/constraint enforcement !
- domain-specific syntax
不過幸運的是,有另外一種方式可以讓我們從 GPL 中衍生 DSL ,那就是 metaprogramming ,雖然不是每種語言都有這種特性,也不是擁有這種特性後就能一切完美。以 C++ 來說,我們可以透過 template metaprogramming 做到 code generation 和 code optimization ,甚至透過 C++ 型別去做部份的 error report/checking。但大部分時候仍是補強而已,很多 domain-specific 的事還是得推延到 run time。這邊想示範一個簡單的 C++ metaprogramming 利用型別實做 constraint enforcement 的概念,那就再次請出 Matrix 來當例子,這次我想針對維度做出 constraint enforcement ,那我們可以把原本的實做改成像下面這樣:
template <int RowT, int ColT>
class MyMatrix {
public:
friend MyMatrix operator +( const MyMatrix& lhs, const MyMatrix& rhs );
MyMatrix();
int operator ()( int row, int col );
vector<int>& operator []( int row );
template <typename T>
MyMatrix& operator =( const T& );
static const int RowDimension = RowT;
static const int ColDimension = ColT;
private:
vector< vector<int> > data_;
};
注意到沒有,我們把維度的概念帶入型別之中,那麼下面的程式碼,
MyMatrix<10, 10> m1;
MyMatrix<2, 2> m2;
cout << m1 + m2 << endl;
便可以在 compile time 丟出錯誤,下面是 VC 2005 的錯誤訊息:
error C2679: binary '+' : no operator found which takes a right-hand operand of type 'MyMatrix' (or there is no acceptable conversion)
但是細心的你注意到了嗎?即使我們把錯誤拉前了,但這樣的錯誤訊息並不是跟矩陣有太大關係,如果能丟出
incompatible dimensions
是不是更好呢?在 GPL 中,我們有時是很難做到完整的 domain-specific error report/checking 的 。再者,並不是每種 constraint 都適合以型別的方式表達,一來可能造成 template parameter 數量爆增,這可能增加 compile 時間,二來也暴露太多細節在 client code 中,這會讓程式碼難以擴充維護。
雖然用了 C++ template 當例子,不過值得提一下, template haskell 似乎也很有趣,許多 functional language 都可以一些語言擴充!
- Modularly composable DSLs
腦子很小的我,只想出前面兩種,這第三種形式是 Generative Programming 一書提出的,它強調的是在語言的基礎建設(infrastructure)完善皆備的情況下,去開發語言的 plug-in ,說來神奇,其實不會, C/C++ 的 preprocessor 或是更古老的 Smalltalk 和 CLOS 都是這種概念的雛型。只是它們未能在 domain-specific 的需求上達到更好的精細度。或許看到這裡,你會有跟我一樣的一個疑問:那 Modularly composable DSLs 與 Embedded DSLs 有什麼不同呢?聽起來都是建構在既有語言之上,其實這中間有個隱微的差異,可以細細體驗一下:
Embedded DSLs 的根基是現有語言的 syntax,而 Modularly composable DSLs 則是做 syntax extensions。
這句話代表的是,Embedded DSLs 的使用是類似於使用 GPL 的 library 或是 framework 的,其 syntax/semantic 跟原本 GPL 是一樣的。但 Modularly composable DSLs 是構造在 GPL infrastructure 之上,除了既有的 syntax/semantic 外,我們還可以增加 domain-specific 的 syntax/semantic ,甚至是刪去、捨棄既有的。若是想更細部條列兩者的差異,或許可以參考 Generative Programming 所條列的,Modularly composable DSLs 可以比 Embedded DSLs 做到:
- Syntax extensions
- Semantic extensions or modifications of language constructs
- Domain-specific optimizations
- Domain-specific type systems
- Domain-specific error checking and reporting
再回到 language infrastructure 上, 我們有什麼辦法可以操作 language infrastructure 呢?有幾種可能性:
- Preprocessor
- Metaprogramming
- Modularly Extendible Compilers and Modularly Extendible Programming Environments
前兩者在許多語言上已經可以做到,正如前面提到的 C/C++, Smalltalk, Haskell 和 CLOS 都行,只是做的不夠好。因為這不只是語言本身支援的問題,還牽扯到 tool chain 與環境。至於第三種,則可以參考 Charles Simonyi (是的,就是微軟的那位大師,匈牙利命名法、WYSIWYG document 發明者、 Word 之父)的研究 – Intentional Programming 。大師在 Microsoft Research 以及後來開設的 Intentional Software 公司都可以找到這方面的發表與研究成果,研究開始得很早,約莫在上個世紀的 90 年代初,未來有時間,希望能玩玩看再來跟大家分享一下!
結語
這篇文章真是超長,內容又很空洞,可是我打了兩天,大概是我在低潮吧…不過還是想跟大家分享一下幾點:
- Compiler is your friend!
- Constraint enforcement is your friend!
- Compile-time error report/checking is your friend!
- The last but most important point, C++ is your friend!