65.9K
CodeProject 正在变化。 阅读更多。
Home

编写你的第一个领域特定语言(DSL),第 2 部分(共 2 部分)

starIconstarIconstarIconstarIconstarIcon

5.00/5 (26投票s)

2008 年 9 月 3 日

CPOL

12分钟阅读

viewsIcon

94521

downloadIcon

1159

一个为初学者用 .NET 编写编译器的指南,使用 Irony。

引言

你好,欢迎来到本语言实现系列的第二部分。在第一部分中,我们定义了一个非常简单的语言,试图引入一种将源代码解析成抽象语法树的方法。

本文档假定您已阅读第一部分,这样我们就可以继续讨论一些更难的概念,特别是引入变量、if 语句和循环,以及二元运算符(+、-、*、/)。同样,我们将把源代码转换为 JavaScript,这样您就可以在浏览器中尝试执行它。在第一部分中,创建的语言控制了一个相当无用的 JavaScript 图片效果。在本文中,我们将放弃这种琐碎的内容,转而解决一个直接的业务问题。

示例程序

在我之前的一份工作中,我们似乎每隔几个月就要实现一次电子商务网站。在我看来,最大的挑战一直是计算订单的运费,尤其是当发送多个不同尺寸和重量的商品,并混合了订单区域影响价格时。当你认为计算已经相当不错时,一个订单就会出现,导致计算出不合理的运费。这意味着计算成本的代码可能很复杂,并且经常更改。如果可以由网站所有者直接编辑计算运费的规则,那会怎么样?

为了编写这个业务规则引擎,我们需要理解领域对象,并决定语言可以使用哪些数据。一个 `Order` 对象有一个送货区域、客户类型(“普通”或“VIP”)以及一个 `OrderItem` 列表。一个 `OrderItem` 有单价、数量和重量。

ObjectDiagram.png

运费政策的一个例子可以是按公斤收费,费率取决于送货区域。VIP 客户可享受 30% 的运费折扣。FL(Freight-Language)语言将通过条件语句、循环、变量和对订单数据的访问来实现这一点。下一节用英语描述了该语言。

语言定义

FL 程序由 0 个或多个语句组成,必须以“Freight cost is x”一行结束,其中 x 是数字或变量。在语句中,可以声明和设置变量(例如,“set price to 0”),可以使用条件语句(例如,“if region is "asia" [”,后跟 0 个或多个语句,并以“]”结束),并且可以循环遍历订单中的每个项目(例如,“loop through order [”,然后是可以使用关键字“weight”和“quantity”的语句,后跟“]”)。每个语句都以分号结束。

这是一个例子。

Set pricePerKG to 2.5;
If region is "asia" [
  set pricePerKG to 2.2;
]
Set totalWeight to 0;
loop through order [
  Set totalWeight to totalWeight + (quantity * weight);
]
Set freightCost to totalWeight * pricePerKG;
if customer is "VIP" [
  set freightCost to freightCost * 0.7;
]
Freight cost is freightCost;

在此语言中可以实现的一些功能包括确定订单中的最大和最小重量(还允许使用 < 和 > 运算符),并且嵌套循环和条件语句可以任意深度。一如既往,我们需要正式定义这种语言,并且将使用一个略微非标准的巴科斯-瑙尔范式 (BNF) 版本作为中间步骤,然后再使用 Irony 定义这种语言。

<Program> ::= <StatementList> <FreightDeclaration>
<StatementList> ::= <Statement>*
<Statement> ::= <SetVariable> ";" | <IfStatement> | <OrderLoop> | <Expression> ";"
<SetVariable> ::= "set" <variable> "to" <Expression>
<IfStatement> ::= "if" <Expression> "[" <StatementList> "]"
<OrderLoop> ::= "loop" "through" "order" "[" <StatementList> "]"
<FreightDeclaration> ::= "freight" "cost" "is" <Expression> ";"
<Expression> ::= <number> | <variable> | <string> | 
                 <Expression> <BinaryOperator> <Expression> | "(" <Expression> ")"
<BinaryOperator> ::= "+" | "-" | "*" | "/" | "<" | ">" | "<=" | ">=" | "is"

定义语法有很多方法,而且通常会使用一些直觉,这意味着每次你这样做时都会变得更容易。关于这个定义,可以这样说:

  • 一个语句可以是 IfStatement,它本身可以包含 Statements。正是这种递归声明使得以很小的难度将条件语句和循环嵌套到任何级别。
  • 布尔表达式(例如,“i < 4”)和数字表达式(例如,“i + 4”)被视为相同的方式。这样做是为了在本文章的目的是保持这种语言的简单性;然而,它也允许一些无意义的语句(例如,“if i + 4 then []”)。另一种选择是声明一个 Expression 为 NumberExpression 或 BooleanExpression。
  • 一个 Expression 包含递归定义“()”,这允许各种复杂的表达式,例如:(1 + ((2 * variable) - 3) ) / anotherVariable。
  • 一个 Statement 可以仅仅是一个表达式。虽然在这种语言中可能不需要,但这在语言中是一个非常普遍的功能(例如,带有返回值的函数调用可以用作表达式,或者在不捕获返回值时,用作语句)。

在 Irony 中定义语言

正如第一部分所演示的,从 BNF 到 Irony 编译器并不难。它涉及创建一个继承 `Irony.Compiler.Grammar` 的类,并创建一个构造函数来声明每个终结符,然后定义语法。

// define all the non-terminals
var program = new NonTerminal("program");
var statementList = new NonTerminal("statementList");
var statement = new NonTerminal("statement");
var ifStatement = new NonTerminal("ifStatement");
// etc…
 
// define the grammar
 
//<Program> ::= <StatementList> <FreightDeclaration>
program.Rule = statementList + freightDeclaration;
 
//<StatementList> ::= <Statement>*
statementList.Rule = MakeStarRule(statementList, null, statement);
 
//<Statement> ::= <SetVariable> ";" | <IfStatement>
//                      | <OrderLoop> | <Expression> ";"
statement.Rule = setVariable + ";" | ifStatement | orderLoop | expression + ";";
 
//<SetVariable> ::= "set" <variable> "to" <Expression>
setVariable.Rule = Symbol("set") + variable + "to" + expression;
 
//<IfStatement> ::= "if" <Expression> "[" <StatementList> "]"
ifStatement.Rule = Symbol("if") + expression + "[" + statementList + "]";

为了简洁起见,该代码片段省略了不少内容。虽然您应该查看附带的源代码以获取完整版本,但我希望提请您注意以下几点:

  • 所有变量(包括全局变量“region”、“customer”、“weight”和“quantity”)都声明为 Irony 自带的 `IdentifierTerminal` 类的实例。这涵盖了大多数字母数字变量名,并提供了一些自定义选项。您还可以定义全新的变量,例如,如果您的语言规定每个变量名都用 '$' 符号括起来。语言中可能被误解为变量名的关键字(例如,“loop”、“if”等)在终端实例上指定。
  • 区域和客户终端的实际值(“asia”、“vip”等)可以使用 Irony 提供的 `StringLiteral` 类,该类提供了许多选项来定义字符串在您的语言中的定义方式(例如,您可以选择用反斜杠括起所有字符串,而不是用引号括起)。

解析源代码

可以使用以下代码将源代码解析成抽象语法树:

FLGrammar grammar = new FLGrammar();
LanguageCompiler compiler = new LanguageCompiler(grammar);
AstNode program = compiler.Parse("the source code as a string");

假设源代码有效,`program` 现在是树的根节点,可以使用 `ChildNodes` 属性进行遍历。为了可视化这一点,请看下面这个简单的程序,它将运费设置为 5 美元,除非送货地点是非洲,在这种情况下会给予 1 美元的折扣。

Set total to 5;
If region is "Africa" [
Set total to 4;
]
Freight cost is total;

由于我们将“[" 和 "]" 等无关紧要的终结符定义为标点符号而忽略了它们,因此上面的程序将被解析成以下抽象语法树:

FreightLanguageTree.png

虽然这看起来可能很复杂,但图的每个部分都相当简单。您可以看到 `Program` 是一个 `StatementList` 和一个 `FreightDeclaration`;`IfStatement` 的表达式包含一个 `BinaryOperator`(相等关键字“is”)来比较两个值;等等。

能够将您的语言解析成抽象语法树是一个重要的里程碑。现在我们只需要从这个东西生成代码!

生成代码

在本系列的第 1 部分中,语言非常简单,我们可以简单地遍历节点并从树中提取有用的信息。现在我们有了递归循环,并且语言变得更复杂了一些,如果为树中的每个节点创建一个类,并让树中的每个节点负责其自己的小部分代码,那将更加优雅和易于维护。

作为每个节点需要执行的工作类型的示例,请看上面图中的 `IfStatement` 节点。这个节点如何为自身及其所有子节点生成 JavaScript 代码?这真的很简单:它需要创建一个带有文本“if (”的字符串,然后让第一个子节点将自己的代码添加到此字符串中,然后连接“) {”,然后让第二个节点(`StatementList`)添加其代码,最后以“}”结束。而 `StatementList` 如何生成代码?好吧,每个子节点都是一个 `Statement` 节点,所以它只需要循环遍历每个子节点,并让每个 `Statement` 生成自己的代码。希望您能看到,嵌套在另一个 `IfStatement` 中的 `IfStatement` 将自动生成正确、嵌套的 JavaScript 代码。深度嵌套的 FL 代码可以被输入到编译器中,并且从每个树节点中非常简单的代码将生成正确、复杂的代码。对我而言,这是优雅代码的一个真正例子。

因此,对于每个非终结符,我们将创建一个继承 `Irony.Compiler.AstNode` 的类,并提供暴露其每个子节点的属性。

class IfStatementNode : AstNode {

    // This constructor pattern is required. The base class
    // sets the ChildNodes property, used in the code below.
    public IfStatementNode(AstNodeArgs args)
        : base(args) {}
 
    private ExpressionNode Condition {
        get { return (ExpressionNode)ChildNodes[1]; }
    }
 
    private StatementListNode StatementList {
        get { return (StatementListNode)ChildNodes[2]; }
    }

}

我们需要每个节点都有一个生成代码的方法。一种方法是在基类或接口中定义一个方法,该方法接受一个 `StringBuilder` 并向其写入代码。一个接口就可以了。

interface IJavascriptGenerator
{
    void GenerateScript(StringBuilder builder);
}

这是为 'if' 语句生成代码的方法:

public void GenerateScript(StringBuilder builder) {
    builder.Append("if (");
    Condition.GenerateScript(builder);
    builder.AppendLine(") {");
    StatementList.GenerateScript(builder);
    builder.AppendLine("}");
}

您必须花一些时间思考生成代码将在什么上下文中运行。在此示例中,正在生成 JavaScript,因此将程序转换为 JavaScript 函数是有意义的。该函数将具有 `region` 和 `items` 等参数,代码可以使用这些参数。`ProgramNode` 将生成函数声明,然后让其 `ChildNode` 将自己的代码添加到函数中。考虑到这一点,这是 `OrderLoopNode` 的代码生成函数:

public void GenerateScript(StringBuilder builder) {
    builder.AppendLine("for (var __i = 0; __i < items.length; __i++) {");
    builder.AppendLine("var weight = items[__i].weight;");
    builder.AppendLine("var quantity = items[__i].quantity;");
    StatementList.GenerateScript(builder);
    builder.AppendLine("}");
}

就像 C# 编译器在后台生成一些奇怪的类名一样,`__i` 用于计数器,以避免与用户自己的变量名冲突。变量 `global` 和 `weight` 没有被混淆,因为它们存在的目的是允许用户代码访问这些值。请注意,尽管语法允许嵌套循环,但生成的 JavaScript 将不起作用,仅仅是因为您将重新声明 `__i`、`weight` 和 `quantity` 变量。有可能生成 JavaScript 来避免这个问题,但那超出了本文的范围(但请注意 `IfStatementNode` 将成功创建嵌套的 JavaScript)。

`Expression` 可能包含非终结符和终结符,因此必须以自己的方式处理。它基本上检查子节点是否实现了 `IJavaScriptGenerator`。如果没有,则将文本逐字添加到程序中。这有效,因为 FL 中的所有终结符(变量、字符串和数字)在 FL 和 JavaScript 中是完全相同的。如果源语言中的字符串与目标语言中的字符串不同,则需要进行一些转换。

因此,我们有了这些类,当它们成为抽象语法树的一部分时,它们应该会生成代码,但您可能想知道这些类究竟是如何成为树的一部分的。嗯,答案在于 `FLGrammar` 构造函数中的非终结符声明。这是 `IfStatement` 声明:

var ifStatement = new NonTerminal("ifStatement");

我们可以在非终结符声明中通过在重载的 `NonTerminal` 构造函数中指定类型来指定用于树的类的类型。

var ifStatement = new NonTerminal("ifStatement", typeof(IfStatementNode));

要做到这一点,指定的类必须继承 `AstNode`,并提供一个调用其基类构造函数的适当构造函数。

public SomeNonTerminalNode(AstNodeArgs args)
    : base(args) { }

我们现在可以生成 JavaScript 代码如下:

var grammar = new FLGrammar();
var compiler = new LanguageCompiler(grammar);
IJavascriptGenerator program =
    (IJavascriptGenerator)compiler.Parse(sourceCode);

// (snipped out the error handling)
StringBuilder js = new StringBuilder();
program.GenerateScript(js);

假设输入有效,`js` 变量现在包含一个 JavaScript 函数,该函数返回给定输入的运费。然后将此函数包含在页面中并在需要时运行。请参阅 *FLCompiler.js* 以获取完整的代码,包括错误处理,并参阅 *Default.aspx* 以查看调用生成函数的代码。

您可以尝试编写自己的 FL 程序,查看生成的 JavaScript,并在演示页面上执行该 JavaScript。

给读者的练习

正如文章前面指出的,即使是经过深思熟虑的算法,对于某些输入也可能产生奇怪的结果。假设源代码是在某个管理区域的文本框中输入的,一个绝妙的主意是查看生成的抽象语法树并确定哪些执行路径是可能的,然后动态生成测试数据并显示不同订单的运费示例,以确保算法有意义。例如,如果有一个语句如 `if region is "asia"`,那么就使用两组输入:一组用于亚洲,一组用于世界其他地区。虽然这超出了本文的范围,但显然需要遍历生成的树并查看每个节点(这就是 C# 编译器在警告代码不可达等时所做的)。

添加这种额外的智能水平可能是区分一个非常烦人的、不值得付出的 FL 语言和一个防止可笑的业务规则出现的、非常有用的工具。

摘要

我希望这两篇文章能让您明白,创建一个简单的语言实际上非常简单(尽管复杂的语言仍然可能很复杂)。特别是,我希望本文能阐明以下几点:

  • 允许嵌套代码是通过递归定义非终结符来实现的(例如,当一个非终结符是一种语句,它包含可能包含语句的子节点时)。
  • 让每种类型的节点仅负责生成与之相关的代码区域,使得生成代码(即使是深度嵌套的、意大利面条式的代码)相对简单。
  • Irony 提供了很多帮助,例如提供预定义的终结符,如字符串字面量。Irony 仍有许多我未曾触及的功能,您应该始终查看 Irony 的源代码和 CodePlex 上的论坛,看看 Irony 是否已经提供了您所需的功能。

但是等等,我知道你在想什么……

“实际上,整篇文章都没用”

嗯,你可能说得有道理。在什么情况下,您会想要在 JavaScript 中执行运费计算?考虑到这只是一个说明性的例子,有很多情况是,您想要的不是生成代码,而是直接执行它。换句话说,您想编写自己的解释器。哦,不,我感觉到另一篇文章要来了……

有用链接

© . All rights reserved.