几个月前就一直有博友关心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,用起来别提多爽了。