26、EriLex:嵌入式领域特定语言生成器解析

EriLex:嵌入式领域特定语言生成器解析

1. 参数化语法

参数化语法是处于Greibach范式的上下文无关语法,配备了一个元数函数。该函数会将语法中的每个产生式 z → a z1 ... zn 映射到一个介于0到n之间的整数p。其中, z1, ..., zp 是参数, zp+1, ..., zn 是非参数,并且参数组和非参数组是不相交的,即一个非终结符不能同时作为参数和非参数。通常,如果 z1 ... zp 是参数,我们会写成 z → a(z1 ... zp) ... zn

例如, e → var(i), i → z, i → s(i) 就是一个参数化语法。在生成的支持代码中,参数会被转换为方法的形式参数。

以下是一个FNS中的EDSL示例:

1 syntax p
2 p -> expr(e)
3 e -> int(n)
4 e -> add(e e)
5 e -> sub(e e)
6 n : nat
7
8 static
9 definition
10 n : Integer
11
12 dynamic
13 eval:Integer
14 p -> expr(e) {
15
return eval(e);
16 }
17 e -> int(n) {
18
return n;
19 }
20 e -> add(e1 e2) {
21
return eval(e1) + eval(e2);
22 }
23 e -> sub(e1 e2) {
24
return eval(e1) + eval(e2);
25 }

ESL通过一种简单的形式支持参数化语法,如第2行所示,非终结符 e 被用作参数。同时,ESL也支持多个参数,如第4行所示。一个EDSL程序的示例为 prog().expr(sub(add(Int(1),Int(2)), Int(4))).run()

2. 使用预编译语句的SQL示例

SQL注入是Web应用面临的常见风险之一,使用预编译语句是防止SQL注入的可靠方法。下面展示一个使用ESL定义的EDSL,它能自动生成预编译语句以防止SQL注入。

使用这个EDSL可以编写如下查询:

1 use(con).select("*").from("A").where().column("f").eq().string(in)

其中, in 是包含用户输入的变量, con 是持有数据库连接的变量。

具体步骤如下:
1. 为了使用原生数据库连接类型,定义如下:

1 syntax
2 e -> use(conn) stmt
3 ...
4 definition
5 conn : java.sql.Connection
  1. 定义EDSL的求值器(仅展示 e 产生式的求值器组件):
1 eval: Object vars: java.util.ArrayList<Pair<String,Object>>
2 e -> use(conn) stmt {
3
...
4
String sql = (String)eval(stmt,vars);
5
java.sql.PreparedStatement pstmt = conn.prepareStatement(sql);
6
for(int i=0;i<vars.size();i++) {
7
Pair<String, Object> var = vars.get(i);
8
if(var.fst.equals("string")) {
9
pstmt.setString(i+1, (String)var.snd);
10
} else if(var.fst.equals("int")) {
11
pstmt.setInt(i+1, (Integer)var.snd);
12
}
13
}
14
...
15 }

求值器使用状态变量 vars 来存储用户输入的类型和值。第4行对 stmt 进行求值,生成带有输入值占位符的SQL字符串。然后,在第5行使用该SQL字符串创建预编译语句,并填充输入值,从而避免SQL注入。使用这个EDSL,程序员无需了解预编译语句的细节。

3. EriLex代码生成器概述

EriLex代码生成器(ECG)接收一个EDSL规范,生成一个内存数据结构,该结构代表支持代码,进而生成宿主语言的实际代码。EriLex会生成两种类型的类:
- 实用类 :如 Utils ,提供的实用方法和数据结构对于所有EDSL基本相同。
- 特定于EDSL的类
- 表示EDSL语法和类型规则的类。
- 用于抽象语法树(AST)的类。
- 求值器类。

ECG要求生成目标支持基本的面向对象特性,如类、方法和泛型,大多数主流的面向对象编程语言都符合要求。

4. 无类型EDSL的生成支持代码

代码生成的总体思路是,对于ESL中定义的每个EDSL语法,都有一个与之等价的无状态确定性实时下推自动机(PDA)。对于这个PDA,ESL会生成一组表示其转换规则的类。通过传递性,生成类中的方法只能按照语法指定的方式进行组合。

PDA的构建方法是将非终结符作为栈符号,将产生式 nt → t nt1 ... ntn 视为一个转换规则:弹出 nt ,压入 nt1, ..., ntn ,并标记为t,这样转换规则和产生式之间就存在一一映射关系。

为了表示转换规则,ECG会为每个非终结符生成一个泛型类,该类有一个类型参数,用于构造表示PDA配置中栈的类型。同时,还会生成一个特殊类 (Java名称为 Bot )来表示空栈。

例如,对于非终结符 z, z1, z2 ,生成的类分别为 ez<κ>, ez1<κ>, ez2<κ> ,其中 κ 是类型参数。类型 ez<ez1<ez2<⊥>>> 表示栈上有 z, z1, z2 的PDA配置。

一般来说,生成的方法表示PDA的转换规则:方法名表示标签,定义方法的对象类型表示起始配置,方法的返回类型表示目标配置。

以下是一个生成类的示例:

1 public class Ee<K> {
2
public K zero();
3
public Ee<K> succ();
4 }

显然, int val = prog().succ().zero().run(); 不会生成错误消息,而 int val = prog().zero().zero().run(); 会生成错误消息,提示第二个 zero 方法未定义。

5. 有类型EDSL的生成支持代码

普通的PDA不足以表示语法和类型规则,因此使用带存储的PDA。带存储的PDA是PDA的扩展,允许在栈符号上附加“存储”信息。例如,当栈符号是非终结符时,可以附加类型环境和这些非终结符产生的子项的类型。

代码生成的总体思路是,对于ESL中定义的每个EDSL类型规则集(由于它们是语法导向的,因此包含了语法),都有一个与之等价的带存储的PDA。对于这个带存储的PDA,ESL会生成一组表示其转换规则的类。

每个类型规则都可以看作是带存储的PDA的转换规则,其中后件对应起始配置,前件对应目标配置。类型规则所包含的产生式控制标签和栈,而类型和类型环境控制存储。

与无类型编码类似,ECG为每个转换规则生成一个方法:方法名表示标签,定义方法的对象类型表示起始配置,方法的返回类型表示目标配置。

以下是一个基于示例扩展的有类型EDSL的部分生成类示例:

1 public class fun<t1, t2> {}
2 public class push<t1, t2> {}
3 public class emp {}
4 public class Bot {}
5 public class Utils {
6
public static <t> Ee<Bot,t,emp> prog();
7 }
8 public class F<S,T> { ... }
9 public class ID<S> extends F<S,S> { ... }
10 public class Ee<K,t,E> {
11
public t cons(t n);
12
public <w,v> Ee<K,w,push<E,v>> abs(F<fun<v,w>,t> cast);
13
public <v> Ee<Ee<K,v,E>,fun<v,t>,E> app();
14 }
15 public class Ei<K,t,E> {
16
public <E1> K z(F<push<E1,t>,E> cast);
17
public <E1,v> Ei<K,t,E1> s(F<push<E1,v>,E> cast);
18 }

对于规范的类型和环境部分中出现的每个终结符,都会生成一个类,但不会为元变量生成类。 fun push emp 类用作表示EDSL类型和类型环境的宿主语言类型的构造函数。 Bot 类表示空的PDA栈。 F<S,T> 实用类和继承自 F<S,S> ID<S> 实用类用于编码类型相等性。

生成的类 Ee 有三个类型参数,第一个类型参数与无类型EDSL生成的支持代码中的相同,另外两个类型参数表示“存储”,第二个参数表示非终结符产生的子项的类型,第三个参数表示非终结符产生的子项的类型环境。

例如,假设我们有一个方法链前缀 <Integer>prog() ,其类型为 Ee<Bot,Integer,emp> ,表示栈上只有一个符号 e ,附加存储为 Integer,emp ,即栈符号的类型为 Integer 。如果在这个前缀上追加一个 app 方法调用,新前缀 <Integer>prog().<Double>app() 的类型变为 Ee<Ee<Bot,Double,emp>,fun<Double,Integer>,emp> ,表示栈上有两个符号 e ,附加存储分别为 fun<Double,Integer>,emp Double,emp ,即栈顶符号的类型为 fun Double Integer ,第二个栈符号的类型为 Double 。因此, <Integer>prog().<Double>app().cons(1).run(); 会有类型错误,而以下代码没有类型错误:

1 ID<fun<Double,Integer>> tyF = new ID<fun<Double,Integer>>();
2 <Integer>prog().<Double>app().abs(tyF).cons(1).cons(2.0).run();
6. 工具复用与EDSL可用性改进
  • 工具复用 :复用是软件设计的基本目标之一,EriLex朝着编辑器复用迈出了第一步。Netbeans集成开发环境(IDE)有超过400万行代码,为新编程语言编写工具的工作量巨大,因此复用现有工具的专业知识和努力非常重要。Netbeans的语义编辑器为Java提供了语法检查、类型检查和自动补全三种常见的工具支持。MCS有效地建立了如下映射,使得MCS EDSL可以在不修改IDE的情况下复用语义编辑器提供的功能:
    | 工具支持 | EDSL | 宿主语言 |
    | ---- | ---- | ---- |
    | 自动补全 | 解析器状态、下一个标记 | 类类型、方法 |
    | 输入时检查(语法) | 语法 | 语法/类型 |
    | 输入时检查(类型) | 类型 | 类型 |

  • EDSL可用性改进 :EDSL的可用性高度依赖于宿主语言的能力。以Java作为EriLex的主要宿主语言,是因为它有成熟的开发工具和庞大的用户基础。但通过改进宿主语言的以下方面,可以提高EDSL的可用性:

    1. 不能省略符号 “(”、“)” 和 “.”。
    2. 运算符不能用作方法名。
    3. 没有类型推断,需要显式编写类型参数。
    4. 重载方法不能有相同的参数类型但不同的返回类型,这将所有语法限制为LL(1)。
    5. 错误消息很难解析或转换为EDSL的错误消息,出错时很难定位错误。

    为此,提出以下改进建议:
    1. 使语法更灵活。
    2. 自动推断方法的类型参数。
    3. 支持 where 构造,这样ECG可以生成无需强制类型转换的代码,例如 public <w,v> Ee<K,w,push<e,v>> abs() where fun<v,w>=t;
    4. 支持(非不相交)联合类型,以模拟非确定性。
    5. 提供一个接口,用于编写自定义错误消息生成器。

    其中,改进1和2在Scala中已经部分或完全支持;改进3在C#中已被证明是可行的,但当前的C#版本尚未实现;改进4由于与Java的其他特性相互作用,可能难以实现;改进5可以通过编写一个外部工具来读取和解析Java编译器的输出,并输出转换后的错误消息,但由于缺乏编译器输出的“规范”,维护该工具并不容易。

7. ESL作为EDSL

虽然将ESL以无类型的方式嵌入Java很容易,但有类型的嵌入则更具挑战性。对ESL规范进行类型检查很复杂,例如需要检查类型规则中出现的项是否符合项的语法。为了避免将宿主语言嵌入到宿主语言中,需要定义一种与宿主语言无关的符号转换语言,用于指定EDSL的动态语义。

当前正在开发的实验性解决方案包括:
- 使ESL与宿主语言无关,并使用宿主语言方法作为原生函数进行补充。
- 将语言规范分为三个阶段:语法、类型和转换器;相应地将规范语言分为三个子集:语法、类型和转换器。
- 在每个阶段,使用前一阶段的规范对当前阶段使用的规范语言子集的通用版本进行特化。

在第一阶段,设计者使用通用嵌入式语法规范语言指定EDSL项、类型和类型环境的语法。在第二阶段,运行类型语言特化器,生成一个特化的嵌入式类型语言,使得只能编写格式良好的EDSL项、类型和类型环境。使用这个特化的类型语言,设计者定义类型规则。在第三阶段,运行转换器语言特化器,生成一个特化的嵌入式转换器语言,使得只能编写格式良好且类型正确的EDSL项。使用这个特化的转换器语言,设计者定义转换器。

8. 相关工作

可执行DSL的实现模式可以分为以下几类:解释器、编译器/应用程序生成器、预处理器、嵌入、可扩展编译器/解释器、商业现货和混合模式。

  • 预处理器类 :JTS、MontiCore和MetaBorg从语言规范生成预处理器,将扩展的宿主语言语法转换为基本的宿主语言语法。这些方法提供了用于定义语言、语言扩展或语言组合的规范语言或元语言构造,但在编译DSL代码时需要预处理器,并且当宿主语言语法更改时,预处理器和用于生成预处理器的宿主语言语法需要更新。
  • Converge :提供支持定界DSL块的语言构造,这些块在编译时由用户定义的任意函数处理,这些函数可以是解释器、预处理器或两者。Converge很灵活,但可能导致不可判定的编译。
  • 编译器/应用程序生成器类 :如ASF + SDF和元编程系统,从语言规范生成DSL的编译器和工具,如编辑器。
  • 可扩展编译器/解释器类 :Language Boxes/Helvetia、XMF和Katahdin允许通过新构造扩展宿主语言来定义内部DSL。Language Boxes基于可扩展编译器框架Helvetia实现,通过定义PEG中的宿主语言语法扩展和AST节点的转换,支持对宿主语言(Smalltalk)的无缝扩展,并通过扩展宿主语言的工具支持工具复用。XMF是一种允许在运行时定义DSL的语言,提供用于定义DSL和定界DSL块的元语言构造。Katahdin是一种动态类型的脚本语言,允许基于PEG在运行时对宿主语言进行无缝扩展。这些方法都要求宿主语言具有可扩展性,并且在动态类型语言(如Ruby)中,EDSL中的类型错误在运行时才能捕获。

EriLex:嵌入式领域特定语言生成器解析

9. 不同实现模式的对比分析

为了更清晰地了解各种可执行DSL实现模式的特点,我们对上述提到的几种模式进行详细对比分析,如下表所示:
| 实现模式 | 优点 | 缺点 | 适用场景 |
| ---- | ---- | ---- | ---- |
| 预处理器类(JTS、MontiCore、MetaBorg) | 提供规范语言或元语言构造,有一定灵活性 | 编译时需预处理器,宿主语言语法变更时需更新预处理器和语法 | 宿主语言语法相对稳定,需要对语言进行扩展或组合的场景 |
| Converge | 支持定界DSL块,可由用户定义函数处理,灵活性高 | 可能导致不可判定的编译 | 对编译灵活性要求高,能接受一定编译风险的场景 |
| 编译器/应用程序生成器类(ASF + SDF、元编程系统) | 能从语言规范生成编译器和工具,如编辑器 | - | 需要快速生成完整DSL开发工具的场景 |
| 可扩展编译器/解释器类(Language Boxes/Helvetia、XMF、Katahdin) | 允许扩展宿主语言定义内部DSL,部分支持工具复用 | 要求宿主语言有可扩展性,动态类型语言中类型错误运行时捕获 | 希望在现有宿主语言基础上无缝扩展,对类型检查实时性要求不高的场景 |

通过这个对比,开发者可以根据具体的项目需求和场景,选择最合适的实现模式。

10. 流程图展示工作流程

下面使用mermaid格式的流程图展示EriLex代码生成器(ECG)的工作流程:

graph TD;
    A[输入EDSL规范] --> B[生成内存数据结构];
    B --> C[生成支持代码类];
    C --> D1[生成实用类(如Utils)];
    C --> D2[生成特定于EDSL的类];
    D2 --> E1[表示语法和类型规则的类];
    D2 --> E2[用于AST的类];
    D2 --> E3[求值器类];
    D1 --> F[生成宿主语言实际代码];
    E1 --> F;
    E2 --> F;
    E3 --> F;

这个流程图清晰地展示了ECG从接收EDSL规范到最终生成宿主语言实际代码的整个过程,有助于理解其工作原理。

11. 示例代码的详细解释
无类型EDSL示例代码解释
1 public class Ee<K> {
2
public K zero();
3
public Ee<K> succ();
4 }

在这个示例中, Ee<K> 是一个泛型类, K 是类型参数。 zero() 方法返回类型为 K ,表示一个转换规则,可能是弹出当前栈符号并标记为 zero succ() 方法返回类型为 Ee<K> ,表示另一个转换规则,可能是弹出当前栈符号,压入一个新的 Ee 符号并标记为 succ

int val = prog().succ().zero().run(); 不会报错,是因为按照语法规则, succ 操作后可以进行 zero 操作。而 int val = prog().zero().zero().run(); 报错,是因为语法中没有定义连续两个 zero 操作的规则。

有类型EDSL示例代码解释
1 public class fun<t1, t2> {}
2 public class push<t1, t2> {}
3 public class emp {}
4 public class Bot {}
5 public class Utils {
6
public static <t> Ee<Bot,t,emp> prog();
7 }
8 public class F<S,T> { ... }
9 public class ID<S> extends F<S,S> { ... }
10 public class Ee<K,t,E> {
11
public t cons(t n);
12
public <w,v> Ee<K,w,push<E,v>> abs(F<fun<v,w>,t> cast);
13
public <v> Ee<Ee<K,v,E>,fun<v,t>,E> app();
14 }
15 public class Ei<K,t,E> {
16
public <E1> K z(F<push<E1,t>,E> cast);
17
public <E1,v> Ei<K,t,E1> s(F<push<E1,v>,E> cast);
18 }
  • fun<t1, t2> push<t1, t2> emp 类是用于构造表示EDSL类型和类型环境的宿主语言类型的构造函数。
  • Bot 类表示空的PDA栈。
  • Utils 类中的 prog() 方法用于启动程序,返回一个初始的 Ee 对象,带有类型和类型环境信息。
  • Ee<K,t,E> 类有三个类型参数, K 表示栈相关类型, t 表示非终结符产生的子项的类型, E 表示类型环境。 cons(t n) 方法用于处理 cons 操作,返回子项类型 t abs 方法和 app 方法分别对应不同的类型转换规则。
  • Ei<K,t,E> 类中的 z 方法和 s 方法也对应特定的类型转换规则,通过传入不同的类型参数进行类型检查和转换。
12. 总结与展望

EriLex作为嵌入式领域特定语言生成器,提供了强大的功能,能够根据EDSL规范生成支持代码和宿主语言实际代码。通过参数化语法、无类型和有类型EDSL的支持,以及对工具复用和EDSL可用性改进的探索,为开发者提供了丰富的选择。

在未来,随着编程语言和软件开发技术的不断发展,EriLex可以进一步优化其代码生成算法,提高生成代码的性能和质量。对于宿主语言改进方面的建议,如果能够在更多的编程语言中得到实现,将大大提高EDSL的开发效率和可用性。同时,对于ESL作为EDSL的类型检查和动态语义指定问题,持续的研究和实验性解决方案的完善将有助于解决复杂的类型嵌入和规范问题。

此外,在与其他相关工作的对比中,EriLex可以借鉴不同实现模式的优点,进一步拓展自身的功能和应用场景,为软件开发领域提供更优质的服务。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值