如何设计一门语言(十)——正则表达式与领域特定语言(DSL)
几个月前就一直有博友关心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的原则其实很简单,只有以下三个:
- 短的语法要分配给常用的功能
- 语法要么可读性特别好(从而比直接用C#写直接),要么很紧凑(从而比直接用C#写短很多)
- 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用来实现循环的那个技巧实在是我操太他妈难受了。
于是,我们就可以把需求整理成这样:
- Fpmacro的代码由函数组成,每一个函数的唯一目的都是生成C++代码的片段。
- 函数和函数之间的空白可以用来写代码。把这些代码收集起来就可以组成“main函数”了,从而构成Fpmacro代码的主体。
- 函数可以有内部函数,在代码复杂的时候可以充当一些namespace的功能,而且内部函数都是私有的。
- Fpmacro代码可以include另一份Fpmacro代码,可以实现全局配置的功能。
- Fpmacro必须支持分支和循环,而且他们的语法和函数调用应该一致。
- 用来代表C++代码的部分需要的转义应该降到最低。
- 即使是非功能代码部分,括号也必须配对。这是为了定义出一个清晰的简单的语法,而且因为C++本身也是括号配对的,所以这个规则并没有伤害。
- C++本身对空格是有很高的容忍度的,因此Fpmacro作为一个以换行作为分隔符的语言,并不需要具备特别精确的控制空格的功能。
为什么要强调转义呢?因为如果用Fpmacro随便写点什么代码都要到处转义的话,那还怎么写得下去呀!
这个时候我们开始从结构入手。Fpmacro的结构是简单的,只有下面几种:
- 普通C++代码
- 宏名字引用
- 宏调用
- 连接
- 括号
- 表达数组字面量(最后这被证明是没有任何意义的功能)
根据上面提到的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,用起来别提多爽了。