TDOP技术C++实现

前言:不知你有没有好奇过一门编程语言是怎么发明出来的。很多 Online Judge 上面都有类似于“输入一个表达式并输出表达式结果”的题目,但是那些对于一门编程语言来说过于小儿科了——输入格式很严格(强制要求用户的码风一致是很 yaml 的)、而且支持运算符一般比较有限(大部分局限于加减乘除,有的甚至不支持小括号)。当然作为一道模拟题的话,这样就够了;如果真的要作为有实用价值的,自然需要支持多得多的功能。


阅读本文需要了解的知识清单:

  • 比较熟悉 C++。对 JavaScript 有初等了解。
  • 程序源码分析中的 tokenize 技术、基本的 token 类型。
  • 虚拟机(Virtual Machine)、操作码(opcode)以及相关概念。

1. TDOP 简介

Top Down Operation Precedence,即自顶向下算符优先级语法分析技术(以下简称 TDOP)最早由 Vaughan Pratt 提出;最早由 Douglas Crockford 在其论文 《Top Down Operator Precedence》中实现。TDOP 用于解析用户的输入程序串,来达到分析程序源代码的目的。大部分情况下,TDOP 用来生成抽象一棵语法树(Abstract Syntax Tree,以下简称 AST)。

简单地说,AST 就是把程序源代码按照其语法建成了一棵树。表达式树就类似于 AST,通俗但不太严谨地说,表达式树就是 AST 的子集。如下就是 3+2*(1+4) 的一棵 AST(表达式树):
在这里插入图片描述
用如下 dfs 代码就可以非常简洁地计算出这棵 AST 的值,也就是计算出表达式 3+2*(1+4) 的值。

struct Node{
   char op;int lc,rc,val;};
vector<Node> nodes;
void dfs(int x){
   
	if(x.op==' ')return;//默认叶节点的操作符为空格,且 val 就是对应的数值
	dfs(nodes[x].lc),dfs(nodes[x].rc);//dfs 计算左右节点——这在特殊情况下并不严谨,见下
	switch(nodes[x].op){
   
		case'+':nodes[x].val=nodes[nodes[x].lc].val+nodes[nodes[x].rc].val;break;
		case'*':nodes[x].val=nodes[nodes[x].lc].val*nodes[nodes[x].rc].val;break;
		//... 可自行添加更多的 op 类型,如减法和除法
	}
}

可见,如果能正确建出 AST,那么对程序源码的解析就会变得非常容易。正确建造 AST 对运算优先级是有很高要求的。对于一个表达式,它的运算执行顺序(例:先乘除后加减)由运算符优先级(priority)决定。运算符优先级的一个体现是符号吸引它两边的操作数的能力。比如下面这棵 AST:

在这里插入图片描述
对于 5*3+4 这一表达式,优先级大的操作符更加能吸引操作数,因此 * 吸引了 53。这样的需要关心左边操作数的符号将被叫做 left denotation。简称 led

如果要具象化优先级的实现,可以采用一个数值“绑定权值”(binding-power,以下简称 bp)来表示优先级,且 bp 越高运算符的优先级越大。例如,乘号的 bp 可以设置成 60 60 60,因为它优先级更高;而加号的 bp 则较低,可以设置成 50 50 50。bp 越大则越能吸引两边的操作数。

有些情况下存在前缀运算符,比如按位非和逻辑非,它们不关心左边的操作数,比如 C++ 中的sizeof 运算符、逻辑非 ! 运算符。在 AST 上体现为它们只有一棵子树:
在这里插入图片描述
这种前缀运算符将被叫做 null denotation。简称 nud。前缀运算符不关心左边操作符,因此前缀运算符不存在 lbp。但是前缀运算符有优先级,即使
有 bp
,请不要混淆 bp 和 lbp 的概念以及用途。

一种特殊的运算符是字面量(literal),或者说操作数(比如数字、字符串)只拥有 nud 处理方式,它们的效果就是返回自己。比如 15+2 中,字面量 15 的 nud 处理方式将会返回 15 本身。字面量的 led 方法不存在,因为不存在关心左边操作符的操作数。

led 和 nud 体现在了对运算符的解析方式的差异上。同一个运算符可以同时具有 led 和 nud 方法,比如减号 -。它可以作为 led 运算符,比如 3-1;也可以体现为 nud 运算符,比如 -4在不同场合将被按照不同方式处理,这将体现在下面将要讲述的 TDOP 代码。

2. TDOP JavaScript 版代码

接下来将开始建造 AST。上文提到,每个运算符可能有两种被处理的方式:led 和 nud。所以,如果把运算符当成 struct 的话,每个运算符将会有两个成员函数:led()nud()。同时,每个具有 led 方法的运算符还会有一个 lbp 值用于判断。

每个操作符都有一个子表达式,可以理解为表达式的一部分(或全部)。在 AST 上,子表达式体现为右子树(如果是前缀运算符,则就是唯一的那棵子树)。仍然拿 1||10<=5*3+4 当例子,如下:

在这里插入图片描述

其中,<= 运算符的子表达式就是 5*3+4。很容易发现一个事实:对于任意一个运算符 c c c c c c 的子表达式中所有运算符的优先级均不低于 c c c 的优先级。请回忆 dfs 的过程:深度越大的子树越先被处理;因此运算符优先级越高(对应需要越先处理),在 AST 中深度也会越大。

下面的 JavaScript 代码是 TDOP 的核心代码,它用来解析一个子表达式。换种说法,它用来建造以参数 rbp 为根节点 bp 的一棵子树。来自原论文《Top Down Operator Precedence》(以下的 JavaScript 代码均来自论文,注释为本人添加):

var expression = function (rbp) {
   
// 参数 rbp:表示当前运算符的 bp,
// 由于现在在处理的是运算符右边的部分,因此 left 变成 right,参数叫做 rbp
    var left; // 最左端的运算符
    var t = token; // token 变量指向当前运算符
    advance(); // 读进一个 token
    left = t.nud(); // 尝试用前缀运算符的方式处理
    while (rbp < token.lbp) {
   
    // 只要当前运算符优先级大于 rbp 就会一直循环下去
        t = token; // 保存当前运算符 
        advance();
        left = t.led(left); // 告知当前运算符最左边的操作数
    }
    return left; // 返回建好的语法树
}

lednud 函数是用来处理运算符的。它们的具体内容根据需求而定。比如,如果目的是建造一棵真正意义的树型结构,那么 lednud 函数里可以写上对左右子树的赋值。但是不管需求如何,有一点不可缺少:递归调用 expression 函数进行 AST 的递归构建。比如,如下是加号 + 和乘号 *led() 函数实现:

symbol("+", 50).led = function (left) {
    // lbp 是 50
    this.first = left; // 左子树
    this.second = expression(50); // 重点:递归调用 expression 函数,建立一棵根节点 lbp 为 50 的 AST
    this.arity = "binary";
    return this; // 返回根节点(自己)
};
symbol("*", 60).led = function (left) {
    // lbp 是 60
    this.first = left;
    this.second = expression(60); // 重点:递归调用 expression 函数,建立一棵根节点 lbp 为 60 的 AST
    this.arity = "binary";
    return this;
};

可以看到,在调用 led() 之后,运算符将会尝试以自己为根节点建立自己的子树,并把根节点(也就是自己)返回。建立子树需要调用 expression 方法,而调用参数就是根节点(也就是自己)的 bp。

有些运算符具有右结合性。比如逻辑与 && 其实是右结合性的。但是上面的代码逻辑都是从左往右建立 AST。问题不难解决:将运算符的 lbp 稍微变小,这样就会使得靠右的表达式先被计算。

// 该代码由笔者在原文代码基础上改写得来
symbol("&&", 30).led = function (left) {
    // lbp 是 60
    this.first = left;
    this.second = expression(30-1); // 将 lbp 稍微减小,达到右结合性的目的
    this.arity = "binary";
    return this;
};

对于前缀运算符,它们没有左子树。同时它们拥有的是 nud 处理方式。

// 该代码由笔者在原文代码基础上改写得来
symbol("!", 70).nud = function () {
    // 不接参数,lbp=70 只用于判断优先级大小
    this.first = expression(70); // 唯一的子树
    this.arity = "unary";
    return this;
};

有了 expression 函数之后,就可以很方便的进行各种语法的解析。比如对于 while 语句,它的格式为 while ( expression ) block,代码如下:

stmt("while", function () {
   // 定义 while 函数的解析方式
    advance("("); // 读入左括号 
    this.first = expression(0); // 处理整个表达式
    advance(")"); // 读入右括号
    this.second = block(); // 处理语句块
    this.arity = "statement";
    return this;
});

各种复杂语句(比如 forif 语句)本质上都可以通过类似于上面的代码进行解析。最复杂的部分,也就是表达式部分可以用 expression 函数直接解析,从而跳过复杂的判断。以上就是 TDOP JavaScript 代码的大致思想以及优点所在。代码非常简短且符合逻辑,正如原论文所说:(用 JavaScript)写程序来创建 AST 并不需要太多的努力。

3. TDOP C++ 大体实现

UVa 上有一套系列题:Mua 语言。Mua 语言是 Lua 语言的一个子集。你并不需要特别了解 Lua,原题中已经给出了相关定义。实现一门自制的编程语言也需要解析用户输入代码,所以这套题非常合适。这套题也在刘汝佳的《算法竞赛训练之南》中有提及。

题目链接 说明
UVa12421 实现词法分析 Tokenize
UVa12422 表达式求值
UVa12423 完整实现一个 Mua 解释器

建议先自行读题,对题目有一定的了解。有条件的可以尝试自行实现 UVa12421。事实上,后两道题都需要依赖上一道题的解法,需要在上一道题的代码基础上扩充得来。

接下来我们将用更为选手们所了解的 C++ 语言实现 TDOP。下面笔者将实现一个类似功能的 C++ 程序。为了不造成阅读困难,提前说明一些定义:

  • Token 是一个 struct,包含两个字段:val 代表该 token 的值、type 代表该 token 的类型。
  • TOK_XXX 是一些常量,表示各种 token 的类型。如 TOK_ADD 表示该 token 为加号 +
  • tok 是一个变量,指向当前正在处理的 token
  • int prior(int type) 返回操作符 type 的优先级(也可称 lbp);
  • Token readtok(int type) 如果下一个 token 类型为 type,读入它;否则报错 expected a (type)
  • Token nexttok() 直接读入下一个 token,不检查。
  • void concat(codeset &a, codeset &b)ab 两个指令集合连接起来,保存到 a 中。
  • void new_scope() 创建一个新的作用域;
  • void del_scope() 将当
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值