领域特定语言一二三

语言的界限就是一个人的世界的界限。 ——维特根斯坦

领域特定语言是个啥

领域特定语言(domain-specific languages,简称DSL)之前只是听过,比如sql就是典型的领域语言,但其实我们平时接触的很多东西都属于DSL的范畴,比如UML、正则等等。下面是Martin Fowler给出的定义:Domain-Specific Language is a computer programming language of limited expressiveness focused on a particular domain

前一段时间去听openrestyCon,章亦春讲到自己领域语言方面的应用:产品经理有时会提出某些特定需求或问题,描述的逻辑非常清晰,限定也非常明确。在他看来,这种文档就是对需求最好的实现。他就发明出专门针对这个文档的小型语言,并用它来表示这个文档,相当于用自己定义的语言把产品经理的需求实现了一遍。然后,再利用perl脚本,根据自定义语言与实际的计算机语言(比如c、lua等)的映射规则,生成了最终的实现结果。当时听到这个,非常好奇,感觉很有意思,但是没想清楚到底是怎么回事。最近在读《The pragmatic programmer》这本书,恰好看到领域语言这一块儿,就试着多了解一下。

计算机语言会影响我们思考问题的方式、交流的方式。

通常情况下,一个需求或问题出现时,我们会在理解需求的基础上,思考相应的解决方案,接着就会涉及到实现细节,会想到要使用哪种计算机语言,会考虑要选定的语言的特性和局限性。大概的顺序应该是这样的:

1. 问题/需求 ->
2. 解决方案 <-> 语言选择、细节实现

在这里,实现的语言的特性、局限性及实现细节会反过来影响我们的解决方案。这可能会导致我们在解决方案上过早的妥协。

相反,领域语言相比需求本身更高一层,它是从问题中提炼出来的描述规范。有了领域语言之后,我们会抛开实现的细节,把注意力集中到问题的解决方案上。它解决问题的顺序应该是这样的:

1. 问题/需求 ->
2. 领域语言 ->
3. 解决方案 ->
4. 领域语言与具体实现语言之间的映射 ->
5. 实现的语言及实现细节。

最开始,这种领域语言不一定是可执行的,它只是被用来捕捉用户需求的一种方式、规范。当然也可以更进一步去实现它。事实上,对最终的实现语言的选择和权衡并没有被消除,而是被后置。领域语言充当了解决方案和最终的实现语言之间的桥梁,起到了一个解耦的作用。

总之,无论领域语言的简单还是复杂,都应该考虑让我们的项目靠近问题领域。通过在更高的抽象层面上编码,获得解决领域问题的自由,暂时忽略琐碎的实现细节,避免解决领域问题时被过早的影响。

领域语言的实现举例

面向行的易于解析的小语言

在最简单情况下,领域内的小型语言可以采用面向行的、易于解析的格式。这种方式我们平时可能用的也比较多,只是没有意识到这也算作一种小型语言。

想实现一种小型语言,用于控制一种简单的绘图包。这种语言由单字母命令组成。有些命令后跟一个数字。例如,下面的输入将会绘制出一个矩形。请实现解析这种语言的代码。它应该被设计成能够简单增加新的命令。painting_graphics.txt:

P 2 # select pen 2
D    # pen down
W 2 # draw west 2cm
N 1 # then north 1
E 2 # then east 2
S 1 # then back south
U    # pen up

把《The pragmatic programming》上面的例子补全后(忽略了一些参数判断)代码如painter_parser.c所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
#include <stdio.h>
#include <stdlib.h>

#define ARG 1
#define NO_ARG 0

#define ARRAY_SIZE(arr) (sizeof(arr) / sizeof(arr[0]))

void doSelectPen(char, int);
void doPenUp(char, int);
void doPenDown(char, int);
void doPenDir(char, int);

typedef struct {
char cmd;
int hasArg;
void (*func)(char, int);
} Command;

Command *findCommand(int cmd);
int getArg(const char *buff, int *result);

static Command cmds[] = {
{'P', ARG, doSelectPen},
{'U', NO_ARG, doPenUp},
{'D', NO_ARG, doPenDown},
{'N', ARG, doPenDir},
{'E', ARG, doPenDir},
{'S', ARG, doPenDir},
{'W', ARG, doPenDir}
};

static int penNo = 0;
static int penUp = 1;
static int penDown = 0;

int main() {
char buff[1024];
FILE *fp;
char fileName[] = "painting_graphics.txt";

fp = fopen(fileName, "r");
if (fp == NULL) {
printf("%s\n", "open file failed\n");
return 0;
}

while (fgets(buff, sizeof(buff), fp)) {
Command *cmd = findCommand(*buff);
if (cmd) {
int arg = 0;
if (cmd->hasArg && !getArg(buff + 2, &arg)) {
printf("'%c' needs an argument \n", *buff);
continue;
}
cmd->func(*buff, arg);
}
}

system("PAUSE");

return 0;
}

Command *findCommand(int cmd) {
int i;
for (i = 0; i < ARRAY_SIZE(cmds); i++) {
if (cmds[i].cmd == cmd) {
return cmds + i;
}
}
printf("unknown command '%c' \n", cmd);
return 0;
}

int getArg(const char *buff, int *result) {
return sscanf(buff, "%d", result) == 1;
}

void doSelectPen(char _selectPen, int _penNo) {
penNo = _penNo;
}

void doPenUp(char _penUp, int arg) {
penUp = 1;
penDown = 0;
}

void doPenDown(char _penDown, int arg) {
penUp = 0;
penDown = 1;
}

void doPenDir(char _dir, int step) {
if (penDown == 1 && penNo > 0) {
printf("%c", _dir);
int i;
for (i = 0; i < step; i++) {
printf("%d", penNo);
}
printf("\n");
}
}

运行后输出:

W22
N2
E22
S2

上面的这个例子,和我们平时做的一些东西没有什么区别,只是程序的输入被当成了语言。这里面重要的是,在绘制矩形的需求出来的时候,首先要想到的是实现之后的绘制方式,即能以这种简单的方式去绘图,相当于在最上层做好了抽象,屏蔽了具体的实现细节。

P 2 # 选择2号笔
D   # 下笔
W 2 # 向西2划厘米
N 1 # 向北划1厘米
E 2 # 向东划2厘米
S 1 # 向南划1厘米
U   # 提笔

painter_parser.c 就是上述小语言的解释器。

有正式语法的复杂语言

除了上述面向行的易于解析的小语言,还可用更为正式的语法去实现较为复杂的领域语言。

在计算机科学领域,有两种可用于递归的规定context-free的文法范式,一种是BNF(Backus–Naur Form),另一种是Van Wijngaarden grammar。它们可以应用在计算机编程语言、文件格式、指令集、通讯协议、官方语言规范、手册、编程理论的教科书等等。这里边BNF更常用一些。

使用BNF文法

看下《The pragmatic programming》书中给出的例子:

设计一种解析时间规范的BNF文法。应该接受下面的所有例子:
4pm
7:38pm
23:42
3:16
3:16am

使用BNF编写时间规范代码如下:

<time> ::= <hour> <ampm> | <hour> : <minute> <ampm> | <hour> : <minute>
<ampm> ::= am | pm
<hour> ::= <digit> | <digit> <digit>
<minute> ::= <digit> <digit>
<digit> ::= 0|1|2|3|4|5|6|7|8|9
用解释器生成器实现解释器

解释器生成器(parser generator),从字面意思理解就是一个生成器,能够生成一个语言的解释器。下面是具体平台上的解释器生成器的一些介绍。

  • c/c++之yacc/bison

yacc(yet another compiler compiler):是一个用来生成编译器的编译器,就是编译器的代码生成器。Bison基于yacc并在其基础上有所改进。它们生成的编译器主要是C语言写成的语法解析器(parser)。需要与词法解析器 Lex 或 Flex 一起使用。

  • java之javacc

javacc(java compiler compiler):是一个用JAVA开发的受欢迎的语法分析生成器。这个分析生成器工具可以读取上下文无关且有着特殊意义的语法并把它转换成可以识别且匹配该语法的JAVA程序。JavaCC可以在Java虚拟机(JVM) V1.2或更高的版本上使用,它是100%的纯Java代码,可以在多种平台上运行,与Sun当时推出Java的口号”Write Once Run Anywhere”相一致。JavaCC还提供JJTree工具来帮助我们建立语法树,JJDoc工具为我们的源文件生成BNF范式(巴科斯-诺尔范式)文档(Html)