前言:不知你有没有好奇过一门编程语言是怎么发明出来的。很多 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 可以设置成 606060,因为它优先级更高;而加号的 bp 则较低,可以设置成 505050。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。很容易发现一个事实:对于任意一个运算符 ccc,ccc 的子表达式中所有运算符的优先级均不低于 ccc 的优先级。请回忆 dfs 的过程:深度越大的子树越先被处理;因此运算符优先级越高(对应需要越先处理),在 AST 中深度也会越大。

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

var expression = function (rbp) {// 参数 rbp:表示当前运算符的 bp,
// 由于现在在处理的是运算符右边的部分,因此 left 变成 right,参数叫做 rbpvar left; // 最左端的运算符var t = token; // token 变量指向当前运算符advance(); // 读进一个 tokenleft = 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 是 50this.first = left; // 左子树this.second = expression(50); // 重点:递归调用 expression 函数,建立一棵根节点 lbp 为 50 的 ASTthis.arity = "binary";return this; // 返回根节点(自己)
};
symbol("*", 60).led = function (left) { // lbp 是 60this.first = left;this.second = expression(60); // 重点:递归调用 expression 函数,建立一棵根节点 lbp 为 60 的 ASTthis.arity = "binary";return this;
};

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

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

// 该代码由笔者在原文代码基础上改写得来
symbol("&&", 30).led = function (left) { // lbp 是 60this.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() 将当前作用域删除;
  • codeset compile_xxx(xxx) 返回编译 xxx 的结果,比如 compile_num 返回编译数字的结果。

基于栈的虚拟机比较容易实现,因此我们采用基于栈的虚拟机。这里简要说明一下本代码中使用的 opcode 指令集合虚拟机架构:

opcode 符号 功能
LOAD_NUM x 把一个数字 x 加压入栈中
ADD / SUB / … 弹出栈顶的两个值,再把相加 / 相减 / … 之后的结果压栈
BITNOT 弹出栈顶的一个值,再把按位取非之后的结果压栈
CALL 根据在栈中的函数的地址进行调用
GETADDR 弹出栈顶的两个值,返回以次栈顶为基准地址、次栈顶为偏移量的地址

例如,如下 opcode 序列用来计算 ~(2 * 3 + 5)

LOAD_NUM 2   // 将 2 压入栈中(此时栈中的数为 2)
LOAD_NUM 3  // 将 3 压入栈中(此时栈中的数为 2,3)
MUL         // 取出栈顶两个数(2 和 3),压入相乘(MULtiply)的结果(6)
LOAD_NUM 5  // 将 5 压入栈中(此时栈中的数为 6,5)
ADD         // 取出栈顶两个数(6 和 5),压入相加的结果(11)
BITNOT          // 取出栈顶的数(11),返回取反的结果(-12)

首先明确一下目标——解析输入串,并根据输入串生成 opcode,这个过程其实就是编译(compile)。虽然可以直接在 AST 上递归求值,但是考虑到递归所产生的的常数、以及可能需要将 opcode 保存至文件(避免每次都编译),采用 AST 递归求值并不是一个好的选择。AST 主要用于代码功能解析而非执行代码,比如各类 IDE 的代码补全、实时查错等功能就需要在内存中建立真正的 AST。

JavaScript 是一门动态函数型语言,如你所见,可以将函数直接赋值到变量里。这使得 expression 函数十分简洁——只需要调用 led 或 nud 变量所存储的函数即可。但是 C++ 比较难做到。C++ 并没有如此方便的动态函数机制——尽管可以使用函数指针、functor 和 lambda 等。

对 JavaScript 比较熟悉的读者应该能发现:expression 调用的时候,是从 token 对象中提取 led 或 nud 进行调用。而 token 对象的实例非常有限。通俗地说,真正不同的 token 是很少的。因此可以采取比较直接的方式:直接在函数中特判每一个 token 的类型,根据类型执行相应的代码。如下伪代码:

codeset expression(int rbp){ // codeset 为编译出的 opcode 的序列switch(操作符类型){case ADD:直接把加号 + 的 nud 代码写在这里;break;case BITNOT:直接把按位非 ~ 的 nud 代码写在这里;break;...case SEMICOL: return;// 分号(semicolon)表示表达式结束default:输出错误信息; // 出现意料之外的符号}while(当前代码.lbp>=rbp){switch(操作符类型){case ADD:直接把加号 + 的 led 代码写在这里;break;case MUL:直接把乘法 * 的 led 代码写在这里;break;...default:break; // 表达式末尾}}
}

可以看出,这样的代码和上面 JavaScript 版本是等效的。

首先是 prior 函数。它直接使用 switch 语句判断各种类型的操作符并返回对应的 bp:

inline int prior(int type){switch(type){case TOK_ASS:return 20; //赋值(assign)case TOK_OR:return 30; // 逻辑或case TOK_AND:return 40; // 逻辑与case TOK_BITOR:return 50; // 按位或case TOK_XOR:return 60; // 按位异或case TOK_BITAND:return 70; // 按位与case TOK_EQL:case TOK_NEQ:return 80; // 相等 / 不相等case TOK_LE:case TOK_SML:case TOK_GE:case TOK_BIG:return 90; // 比较case TOK_LSHF:case TOK_RSHF:return 100; // 移位case TOK_ADD:case TOK_SUB:return 110; // 加减case TOK_MUL:case TOK_DIV:case TOK_MOD:return 120; // 乘除模case TOK_NOT:case TOK_BITNOT:return 130; // 逻辑非、按位非case TOK_LPR:case TOK_LBK:case TOK_DOT:return 140; // 函数调用、取址、取成员 —— 本文不体现default:return 1; // 未知运算符}
}

接下来是 expression 函数的实现。使用 switch-case 结构比使用连续的 if-else 结构更加快速,因为不需要在各分支之间连续跳转(跳转的时间消耗是很大的):

codeset expression(int rbp){codeset s;switch(tok.type){case TOK_SEMICOL:nexttok();return s; //读入分号,说明到达表达式末尾,退出//字面量的 nud 方法:直接把自身作为值。// str2num 把字符串转为数字,compile_loadnum 生成的加载数字的指令,类似于 java 中的 loadint 等case TOK_NUM:concat(s,compile_loadnum(str2num(tok.val)));readtok(TOK_NUM);break;//左括号的 nud 方法,说明这是一个用括号括起来的表达式,比如 4*(2+3) 中的 (2+3)// 读入左括号、接着编译整个表达式、再读入右括号case TOK_LPR:readtok(TOK_LPR);concat(s,expression(0));readtok(TOK_RPR);break;//按位非的 nud 方法// BITNOT 是 opcodecase TOK_BITNOT:readtok(TOK_BITNOT);concat(s,expression(prior(TOK_BITNOT)));s.push_back(BITNOT);break;//负号的 nud 方法case TOK_SUB:readtok(TOK_SUB);concat(s,expression(130));s.push_back(NEGATIVE);break;//负号的优先级为 130//不应该出现在这里的符号default:puts("unexpected token.");exit(1);break;//报错退出}// 减少重复代码量用的宏;大部分中缀操作符的 led 方法都是这个形式#define PF(code) // code 参数是对应的 opcodev=tok.val,\ // 保存当前操作符pr=prior(tok.type);\ // 获取新操作符的 lbpnexttok();\ // 读入下一个运算符concat(s,expression(pr));\ // 解析子表达式s.push_back(code);\ // 生成对应计算 opcodebreakwhile(curtok<toks.size()&&prior(tok.type)>rbp){//注意特判越界int pr;string v;switch(tok.type){case TOK_ADD:PF(ADD); // 加号的 ledcase TOK_SUB:PF(SUB); // 减号的 led// [ 的 led// 出现 [ 说明是取地址,比如 a[2]case TOK_LBK:{// 读入 [readtok(TOK_LBK);// 解析整个表达式concat(s,expression(0));// 读入 ]readtok(TOK_RBK);// 生成取址指令s.push_back(GETADDR);break;}// ( 的 led// 如果出现 ( 说明是函数调用case TOK_LPR:{uint argcnt;// 此处逻辑:读入左括号 ——> 解析整个参数列表 arg1,arg2,arg3... ——> 读入右括号readtok(TOK_LPR);concat(s,parse_args(TOK_RPR,argcnt));readtok(TOK_RPR);// 生成 CALL 指令,不过多赘述s.push_back(CALL);concat(s,loadint(argcnt));break;}// 如果是 ) 或者 ] 说明解析到末尾,它们的 led 方法是直接返回case TOK_RPR:case TOK_RBK:return s;//分号或者是其他符号就退出case TOK_SEMICOL:nexttok();return s;default:return s;}}return s;
}

为了节约篇幅,此处省略了大量操作符,比如乘号的操作符。上面节选的是较为典型的几种操作符的代码。其实可以看出,代码的大框架和原版 JavaScript 的代码是一致的,只是加入了自己的东西:生成 opcode 操作码。大部分运算符的格式都是 a op b 这样的,因此采用一个宏来缩减重复代码是很有必要的。对于一些不一样的,比如 [] 这样需要配对的符号,在 led 里需要按步骤一步一步来:读入 [、解析括号内的表达式 expression(0)、最后读入 ]。这也是为什么在计算 led 方法时,遇到 )] 需要退出的原因:此时 expression 函数解析到了表达式的末尾,应当返回,并让调用方读入终止符号。

可以发现,如果要加入更多的符号(比如乘号等)、甚至创建自定义符号(比如 Python 中的 in),都可以简单地在 expression 函数中添加新的 case 分支来实现。下面我们就来实际体会一下,添加新运算符有多么简单。

4. 实战:TDOP 实现表达式求值(UVa12422)

赋值号 =

如果只需要计算表达式,那么 expression 函数就足够使用了。在 UVa12422 中便是如此,只不过加入了赋值语句、取成员(即 obj.age 这样的 . 操作符)。事实上,赋值语句 a=1 也是表达式,只不过它是有副作用的表达式。赋值号属于中缀运算符,也就是说它需要 led 方法,因此需要在 expression 函数中添加相应的 case。

同时,由于加入了变量标识符系统,可以创建一个哈希表来保存变量名与变量编号、变量引用的对应关系。这个哈希表叫做符号表(symbol table)。顾名思义,变量名就是各种标识符。作为字面量,我们也要添加对应的 nud 方法。

upd:变量名只是一个代号,用来替代变量的地址。其实质也是一个字面量,因此也有 nud 方法。

opcode 符号 功能
LOAD_VAR x 把编号为 x 的变量的引用压入栈中
ASSIGN 弹出栈顶的值,赋到次栈顶的引用指向的变量中
//变量名(标识符,identifier)的 nud 方法
// 生成 LOAD_VAR 指令并连接
case TOK_ID:concat(s,compile_loadvar(tok.val));nexttok();break;
...//赋值号 = 的 led 方法
// 本质上赋值和 +、- 等中缀运算符差不多,因此直接使用 PF 宏即可
// PF 宏的定义见上文
case TOK_ASS:PF(ASSIGN);

数组 list、字符串 string 等

list 属于字面量,因此它拥有 nud 方法。数组的格式是 {val1, val2, ...},开头是个大括号,因此我们可以使用 { 作为 list 的开始标志(即:读到大括号就认定接下来是个数组)。

中间的元素数量不固定,因此需要一个 while 循环不断判断是否有下一个元素。上文中提到了一个 parse_args 函数,它用来处理 val1, val2, ... 这种形式的字面量序列。此处给出实现:

codeset parse_args(Token start,uint&cnt){// start 表示起始 token,cnt 用于返回读入的元素个数codeset s;cnt=0;Token endtok=start^1;//在笔者的实现里,与 1 异或相当于把左右对换// 如左括号 TOK_LPR 异或之后变成右括号 TOK_RPR// 解析 val1, val2, ... 这种形式的值序列while(1){// 编译表达式,并连接到 s 上concat(s,expression(0)),cnt++;// 如果是逗号,读入它if(tok.type==TOK_COMMA)nexttok();// 如果是终止符(比如读到了右括号),返回else if(tok.type==endtok)break;// 不应该出现的符号else puts("Unexpected token in list."),exit(0);}return s;
}

以上代码完全按照数组的格式进行解析。每次循环,先调用 expression 生成编译第一个元素的值,接着检查下面是不是逗号,如果是则接着解析第二个元素… 以此类推,一直读到出现终止符为止。之所以要把 start 当做参数传进去,是因为终止符要根据上下文环境来决定。比如数组定义是 { args }、而函数调用的定义是 ( args ),终止符不相同。

之后我们就可以写出 list 的 nud 了:

opcode 符号 说明
MAKEARR x 从栈中弹出 x 个值,构建成一个数组对象并压栈
case TOK_LBR:{uint cnt;//直接按照 { args } 的形式解析readtok(TOK_LBR);concat(s,parse_args(TOK_LBR,cnt));readtok(TOK_RBR);s.push_back(MAKEARR);concat(s,compile_number(cnt));// compile_number 用于把数字写进 opcode 序列中
}

字符串比较容易。字符串属于字面量,因此有 nud 方法。

opcode 符号 说明
LOAD_STR x s1,s2,… 用 s1, s2,… 构建一个长度为 x 的字符串
case TOK_STR:concat(s,compile_loadstr(tok.val));nexttok();break;

类似的还有 true、false 和 nil。只需要加上类似于 TOK_TRUE 的 case,生成类似于 LOAD_TRUE 之类的 opcode 就能实现。代码很简单,不再给出。

取成员 .

取成员是比较特殊的一种运算。语法也比较特别,比如 obj.age 中,age 的 Token 类型是 TOK_ID。然而如果我们直接按照标识符的 nud 方法编译的话,会出现把 age 当作变量名而生成 LOAD_VAR 指令,并非我们期望的 GETADDR 指令(取成员本质上也是取址)。

其实从题面中也可以看出来,obj.ageobj["age"] 是等价的。换句话说,obj.ageobj["age"] 的另一种写法(即语法糖)。所以,age 其实就是字面量,可以按照取址的方式编译。

既然如此,解决方法就是在 expression 函数中添加一个可选参数 is_literal,表示下一个 TOK_ID 的编译逻辑。如下:

codeset expression(int rbp,bool is_literal=false){

同时我们需要修改标识符的 nud 方法。

//变量名(标识符,identifier)的 nud 方法
// 生成 LOAD_VAR 指令并连接
case TOK_ID:// 如果是 literal,那么按照 obj["age"] 的方式编译并生成 GETADDR// 否则编译为变量if(is_literal)concat(s,compile_loadstr('"'+tok.val+'"'));else concat(s,compile_loadvar(tok.val));nexttok();break;

以及 . 的 led 方法:

case TOK_DOT:nexttok();concat(s,expression(prior(TOK_DOT),true));s.push_back(GETADDR);break;

短路运算

这部分和 TDOP 关系不大,只是供完成题目用的补充说明。

我们知道逻辑与 && 和逻辑非 || 是短路运算符。以 a && b 为例,当左边的条件 a 测试出来之后,可能不需要计算 b 了。然而如果根据我们上面的“压入两个操作数”的逻辑,并不好处理短路运算。

仔细思考,对于下面这部分代码:

a = (b==c) && (d==e);

如果显示展示出短路运算的过程,那么可以写成:

if(b==c)a=(d==e);
elsea=false;

所以,短路运算也是一个类似于语法糖的存在。在编译的时候我们将按照上面的代码编译逻辑与和逻辑或。

杂项

  • 取长度的 # 运算符属于前缀运算符,需要一个 nud 方法。
  • 对于内置函数,可以提前在符号表里把对应的标识符定义好。这种“在内部用 C++ 实现的”库函数称作原生函数(primitive function)。

如上,利用 TDOP 技术,使用相比之下非常少的代码就能完成原本需要用 BNF 自动机完成的功能,在保留灵活性的同时也十分高效。此外,书写代码的时候非常自然——你甚至不需要了解诸如 LL(1)、尾递归消除等较深入的编译知识。只需要根据需要实现的代码的语法,编写对应的解析器就非常容易。

5. 使用 TDOP 解析程序结构

必须说明的一点是,上面的 expression 函数不等同于 TDOP,它只是 TDOP 的核心之一:OP(Operator Precedence)。完整的 Top Down Operator Precedence 还包含自顶向下语法解析(即 TD 部分)。一门语言的语法不仅仅体现在表达式上。

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

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

大部分语言拥有语句(statement)的概念,而一组语句的集合称作语句块(block)。我们可以先写一个编译单条语句的函数以方便接下来的工作。目前,我们的程序仅支持表达式语句和块语句(即用 doend 包含起来的语句块)

// 编译单条语句
codeset statement(){codeset s;switch(tok.type){// 根据每条语句开头的 token 决定编译策略// do 开头,说明是语句块// block 函数用于编译语句块——见下case TOK_DO:s=block();break;// 是分号的话直接读入,不处理case TOK_SEMICOL:nexttok();break;// 否则尝试按照表达式编译default:s=expression(0);s.push_back(POP);break;// POP 的功能是无条件弹栈,因为这里我们不关心表达式返回的结果(抛弃)}return s;
}

有了编译单条语句的 statement,那么编译语句块也非常容易了。只需要不断读入,读到出现 end 终结符为止:

codeset block(){codeset s;new_scope(); // 创建新变量作用域readtok(TOK_DO);while(tok.type!=TOK_END)concat(s,statement());// 不断读入,读到 end 关键字为止readtok(TOK_END);del_scope(); // 销毁作用域return s;
}

上面的代码中间接递归调用了本身(statement 调用 blockblock 又调用 statement),这就实现了任意层数的代码块嵌套书写。

可以看到,在 statement 函数中,我们根据每条语句开头的 token 判断当前语句类型,进而进行编译操作。这个过程是用 switch 进行的。这也给我们提供了很大的拓展性:只需要添加新的 case 就能把新的语句编译函数挂载到编译函数上。

6. 实战:TDOP 实现完整程序编译(UVa12423)

我们将根据上文 JavaScript 代码的逻辑来模仿实现这道题所要求的所有语句。

while 语句

对于 while 语句而言,格式为 while expr do block end。需要加入判断和跳转功能。在这里,因为跳转指令需要知道接下来的指令数量,需要使用回填技术(patch)。这不是我们的重点,不必过多在意细节。

opcode 符号 功能
JIF x Jump-if-false,弹出栈顶,当栈顶为 false 的时候直接向后跳转 x 条指令
LOOP x 无条件直接向前跳转 x 条指令
JUMP x 无条件直接向后跳转 x 条指令
codeset parse_while(){codeset condition,body;readtok(TOK_WHILE);condition=expression(0); // 条件body=statement(); // 循环体// 跳转处理部分,不理解没关系,这不是 TDOP 的重点condition.push_back(JIF),concat(condition,compile_number(0));// 0 是占位符,见下body.push_back(LOOP),concat(condition,compile_number(0));// cover_tail(a,b) 函数用于将 a 的末尾字节用 b 覆盖// 此处代码相当于将 JIF 0 的 0 替换成 body.size()// 之所以这么做是因为有些跳转偏移量在编译完成之前是不知道的cover_tail(condition,compile_number(body.size()));cover_tail(body,compile_number(body.size()+condition.size()));concat(condition,body);return condition;
}

local 变量声明

局部变量声明的语法是 local var1 [= expr1], var2[= expr2], ...。注意到这里也是不定长度的逗号隔开的序列,因此可以采用类似于 parse_args 的代码实现解析。

codeset parse_local(){codeset s,t;readtok(TOK_LOCAL);while(1){string name=tok.val;readtok(TOK_ID);// 读入变量名if(tok.type==TOK_ASS){// 后面跟着等号说明包含初始值readtok(TOK_ASS);t=expression(0);// 编译初始值}else t.clear(),t.push_back(LOADNIL);// 否则压入一个 nil 作为初始值regist_localvar(name); // 注册新局部变量concat(s,compile_loadvar(name)),concat(s,t);s.push_back(ASSIGN);if(tok.type==TOK_COMMA)nexttok();// 逗号说明后面还有else break;}return s;
}

根据需求,对于没有初始值的变量,赋值应为 nil。此外,在上面代码中,编译初始值和注册新局部变量的顺序不可颠倒。因为在声明之后,这个局部变量才真正存在。比如 local x=x 这句程序,如果先注册局部变量,在编译初始值表达式中 x 指向的是新局部变量(也就是自身),这并不是我们想要的;当注册放在后面时,x 将会指向外层的变量,符合预期。

for 语句

TDOP 技术只是帮助我们将用户输入处理成了能够方便解析的样式,至于解析之后做什么事,那是由我们自己决定的。也就是说,“当面一套背后一套”在 TDOP 中是非常合理的——即语法糖。语义分析的功能是帮助我们理解源代码的意图,后续的处理是基于但不限于语义处理的。

对于 for 语句,C++ 中的 for(xxx; yyy; zzz){ block },在幕后其实可以转写成:

xxx;
while(yyy) {block;zzz;
}

相比于 for 语句,while 语句是比较好处理的,因此我们将采用这种方式——转写之后编译。转写的过程并不需要显示表现出来,只要能够按照相应的逻辑,将 for 语句转化成类似 while 语句的方式编译,即可达成目的。

本节代码可能较长,不必拘泥于编译指令的细节,只需要了解语法的解析逻辑。

opcode 符号 功能
FOR x 以栈顶为步值、次栈顶为上下限,检查次次栈顶的变量引用是否超过限制,若超过则跳转 x 条指令
codeset parse_for(){codeset s,init,condition,step,body,t;readtok(TOK_FOR);string I=tok.val;// 循环变量名readtok(TOK_ID);readtok(TOK_ASS);init=expression(0); // 初始值readtok(TOK_COMMA);condition=expression(0); // 上下界if(tok.type==TOK_COMMA)step=expression(0);// 如果有步值则编译else step=compile_loadnum(1);// 否默认为 1body=block();regist_localvar(I);concat(s,compile_loadvar(I));concat(s,init),s.push_back(ASSIGN); // 给变量赋初值,即编译成:I=initialconcat(s,condition),concat(s,step);t.push_back(FOR),concat(t,compile_number(0));// 以下为回填技术,不多赘述body.push_back(LOOP),concat(body,compile_number(0));cover_tail(body,compile_number(body.size()+t.size()));cover_tail(t,compile_number(body.size()));concat(s,t),concat(s,body);return s;
}

以上代码按照如下逻辑编译 for 循环:

  1. 读入 for 循环参数,并创建变量;
  2. 给变量赋初值;
  3. 生成 FOR 字节码;
  4. 编译循环体,生成跳转指令。

再次申明:TDOP 只是用于解析语法、理解语义。

关于 for in 语句,这里不给出代码,只给出思路:把 pairsipairs 当做内置函数,让它们返回一个保存所有键值的数组 list ,接着再通过取长度运算符 # 编译。也就是把:

for i in pairs(t) doblock

编译成

for __i = 0, #pairs(t), 1 doi = t[__i]block

相信读到这里的读者一定能很轻松的完成这步工作。

function 函数声明

首先让我们来看看 JavaScript 中的定义函数的例子——一种是传统的定义语法:

function f(a, b) {return a + b;}

JavaScirpt 中函数闭包(closure)属于基本类型之一,因此可以把它像普通的数字、字符串一样直接赋值给变量。比如上面的例子,实际上可以这样写:

f  = function (a, b) {return a + b;};

也就是说,我们可以采用语法糖的策略,把传统方式定义的函数变为如上的赋值方式,这样不仅可以实现直接 f = math.sin 这类函数赋值,还可以很轻松地支持传递闭包。本题中函数定义方式只有唯一的一种:传统方式。因此我们根据语法解析即可。

codeset parse_function(){codeset s;readtok(TOK_FUNCTION);string funcname=tok.val; // 函数名readtok(TOK_ID);readtok(TOK_LPR);int fid=register_func(); // 注册一个新的函数 idwhile(1){string param=tok.val;readtok(TOK_ID);param_list[fid].push_back(param);// 将参数名字加入形参列表if(tok.type==TOK_COMMA)nexttok();else if(tok.type==TOK_RPR)break;else puts("Unexpected token in function definition."),exit(1);}readtok(TOK_RPR);// 编译函数体funcbody[fid]=block();// 将函数赋值给变量concat(s,compile_loadvar(funcname));concat(s,compile_loadfunc(fid));s.push_back(ASSIGN),s.push_back(POP);//需要把栈中剩余的一个变量引用 pop 掉return s;
}

链接到编译循环上

光写了这些函数还不够,还需要能够让 statement 函数能够识别到它们。因此在 statement 函数的循环中加入对应的 case 分支。

// 编译单条语句
codeset statement(){codeset s;switch(tok.type){// 根据每条语句开头的 token 决定编译策略// do 开头,说明是语句块// block 函数用于编译语句块——见下case TOK_DO:s=block();break;// 是分号的话直接读入,不处理case TOK_SEMICOL:nexttok();break;// while 语句case TOK_WHILE:s=compile_while();break;// function 定义case TOK_FUNCTION:s=compile_function();break;// for 语句case TOK_FOR:s=compile_for();break;// 可自行加入更多...// 否则尝试按照表达式编译default:s=expression(0);s.push_back(POP);break;// POP 的功能是无条件弹栈,因为这里我们不关心表达式返回的结果(抛弃)}return s;
}

以上,我们的 TDOP 实现 Mua 就告一段落了。通过上文,我们可以看到,TDOP 巧妙采用了自上而下的方式递归处理语法,使得整个解析过程非常高效——无论是编码效率还是运行效率均是如此。调试起来也非常容易:程序本身就是模块化的。

7. 拓展

正如我们所已经见到的,使用 TDOP 技术可以非常轻松地添加运算符、制造新的语句;那么为什么我们不添加一些自己的元素,来让我们的这门语言更加具有特色呢?

相信很多学了两门及以上编程语言的读者都有过这样的体会:有些用 C++ 要上百行实现的功能;在一部分编程语言,比如 Python 中只需要十来行(当然这里不考虑运行效率——我们只关心的是语法简洁与否)。这背后,除去一些语言设计特性之外,是大量语法糖的功劳。

函数式编程有一个很重要的元素是 lambda。Lua 语言似乎没有 lambda 的概念,只有闭包。既然如此,那么我们就自己设计一个 lambda 语法。如下:

'lambda' '<' arg1,arg2,... '>' '->' expression

比如一个计算两数之积的 lambda 可以这样写:

f = lambda <x,y> -> x*y
f(2,3)

在内部,其实我们可以把 lambda 函数当作一个真正的函数来编译。用上面的例子,在内部将会按照如下形式编译:

function ___lambda_0x1234(x,y) -- 这里的 __lambda_0x1234 是随机指定的一个名字return x*y
end
f = __lambda_0x1234
f(2,3)

容易发现,这份解析代码和上面解析 function 语句的代码有很多地方是相同的:

codeset compile_lambda(){codeset s;readtok(TOK_LAMBDA);string funcname="__lambda_"+random_string(); // 函数名readtok(TOK_SML); // 小于号int fid=register_func(); // 注册一个新的函数 idwhile(1){string param=tok.val;readtok(TOK_ID);param_list[fid].push_back(param);// 将参数名字加入形参列表if(tok.type==TOK_COMMA)nexttok();else if(tok.type==TOK_BIG)break;else puts("Unexpected token in function definition."),exit(1);}readtok(TOK_BIG);// 编译函数体funcbody[fid]=expression(0);// 将函数赋值给变量concat(s,compile_loadvar(funcname));concat(s,compile_loadfunc(fid));s.push_back(ASSIGN),s.push_back(POP);//需要把栈中剩余的一个变量引用 pop 掉return s;
}

之后就是把这个函数挂载到 lambda 的 nud 方法上去:

case TOK_LAMBDA:concat(s,compile_lambda());break;

8. TDOP 与混淆器

混淆器(obfuscator)用于将源代码混淆成难以阅读的 soup-code(即一锅汤一样混乱的代码)。这样做的目的是保护我们的代码不被轻易修改。有时候我们不希望别人把我们的程序反编译之后对代码进行修改来达到自己的目的(也就是所谓破解软件),因此需要让代码难以阅读。

流传的依靠 #define 的混淆器非常轻松就能实现,可惜的是宏混淆属于非常粗浅的混淆——甚至不需要用反编译技术,使用 g++ -E 就能直接让它原形毕露。作为一个混淆器,不仅要保证程序功能不缺失,还要保证代码不会被轻易读懂或者读加工(有专门的反混淆器,用于把混乱的代码重新写成规范的代码)。

混淆器不能改变程序功能,因需要分析程序意图。比方说,下面这段 JavaScript 代码:

a=0;b=0;
while(a!=10)a++,b+=a;

通过混淆器,它可能会变成:

a=5^5; b=-5+5;
status=1;
while(status!=0){switch(status){case 1:status=a!=10?2:0;break;case 2:a++;status=3;break;case 3:b+=a;status=1;break;}
}

如上,代码功能并没有变化,只是可读性降低了(并且效率也降低了)。事实上,如果加入更多的代码混淆元素,比如把变量名替换成随机字符串、把常量(比如 0)替换成更加复杂的书写方式(比如 2==0&&11==3+6+2,实际比这长的多),代码将更加难以阅读。

要做到这一点,我们可以把 expression 函数修改,让里面的 led 和 nud 不再生成 opcode,而是生成混淆后的代码。比如,对于数字和变量名:

case TOK_ID:{// 新变量,需要为其生成一个新变量名if(!namelist.count(tok.val))namelist[tok.val]=random_string();cout<<namelist[tok.val]<<endl;
}
case TOK_NUM:{// 采取 xor 加密方式。可以自行替换成更加有效的混淆,此处只是举例int val=str2num(tok.val);int xorer=rand();cout<<"("<<(val^xorer)<<"^"<<xorer<<")"<<endl;
}

对于代码结构,也可以这么做。比如 while 循环:

readtok(TOK_WHILE);
string status=random_string();
int val1=rand(),val2=rand();
if(val2==val1)val2++;
cout<<status<<"="<<val1<<endl;
cout<<"while("<<status<<"!=0){"<<endl;
cout<<"    switch("<<status<<"){"<<endl;
cout<<"        case "<<val1<<":("; expression(0); cout<<")?"<<val2<<":0;break;"<<endl;
cout<<"        case "<<val2<<":{"; block(); cout<<status<<"="<<val1<<"; break; }"<<endl;
cout<<"    }"<<endl;
cout<<"}"<<endl;

上面的代码的逻辑就是按照我们之前的例子进行的。可以加入更多混淆方式让它功能更强大。

后记

TDOP 产生的 AST 可以用来帮助我们对源代码进行处理。可以看到,使用 TDOP 技术来进行解析非常方便。遗憾的是,目前主流的各编译器似乎都没有采用 TDOP 技术。不过,既然它如此方便,那为什么我们自己不用呢?

一些可能对本文有帮助的拓展资料:

  • JVM 虚拟机实现;
  • BNF 自动机;
  • 郑刚《自制编程语言 基于 C 语言》;

一些语义分析的例子:

  • github cpc:用 C 语言以及非常少的代码实现了简单的 C 语言编译器;
  • github sparrow:郑刚《自制编程语言 基于 C 语言》一书的项目地址,用 C 实现了面向对象的脚本语言,非常具有学习价值。在书里也有对 TDOP 的详尽介绍;
  • github rbqscript6:笔者用 C++ 自制的一门编程语言,支持几乎所有主流编程语言的功能(lib 文件夹里有 NTT 实现代码)。

TDOP技术C++实现相关推荐

  1. 收集的计算机编程电子书目录,仅供日后查阅方便

    本人有收集电子书的癖好.每日在网上收集经典的电子书籍,尤其喜欢原版的,看起来舒服.不过总是心血来潮,当时下载后瞅几眼,之后就束之高阁,再也不问津了.很为此苦恼,过后找某本书时也总是不知道在哪,为了查找 ...

  2. Java实现lucene搜索功能

    直接上代码: package com.sand.mpa.sousuo;//--------------------- Change Logs---------------------- //<p ...

  3. 如何判断飞机的年限_技术流带你鉴定前风挡玻璃更换,不再使用日期判断!

    ​ 这又是一篇关于前风挡玻璃鉴定的文章,我记得在二手车鉴定微信公众号里面已经发布好几篇这样的文章了,当然每篇文章的住重点不同,今天这一篇应该是完结篇,它们在一起能组成一套玻璃更换系列专题课程: 我们回 ...

  4. 【置顶】利用 NLP 技术做简单数据可视化分析教程(实战)

    置顶 本人决定将过去一段时间在公司以及日常生活中关于自然语言处理的相关技术积累,将在gitbook做一个简单分享,内容应该会很丰富,希望对你有所帮助,欢迎大家支持. 内容介绍如下 你是否曾经在租房时因 ...

  5. HTTP服务器端常用推送技术

    服务器端推送技术描述 不论是传统的HTTP请求-响应式的通信模式, 还是异步的AJAX式请求, 服务器端始终处于被动的应答状态, 只有在客户端发出请求的情况下, 服务器端才会返回响应. 这种通信模式被 ...

  6. 程序员如何讲清楚技术方案

    最近在评审技术方案,和代码review的时候,遇到刚入行的同学们,很多都讲不清楚技术方案. 具体表现是: – 上来不说需求,直接说算法实现.台下一头雾水,根本不知道设计方案是否合理. – 描述完需求后 ...

  7. Electron、QT和JAVA PC桌面开发技术比较

    近几年PC桌面开发越来越多的被Electron,QT和Java技术占领.下面简单比较一下它们的优劣. Electron,势是开发用时快,社区轮子多,整合一下就能用.缺点是打包大,js计算弱. Java ...

  8. 低代码技术与市场(Mendix与 OutSystems)

    低代码技术与市场(Mendix与 OutSystems) 本文主要参考文章 参考链接 https://mp.weixin.qq.com/s/OXCBORheAx99o3fS-ZfUdg https:/ ...

  9. GPU、AI芯片技术市场分析

    GPU.AI芯片技术市场分析 市场将高速增长,GPU曙光初现,预计到2024年,国内人工智能技术市场规模将达到172亿美元:全球占比将从2020年12.5%上升到15.6%,是全球市场增长的主要驱动力 ...

最新文章

  1. ColorMatrix 彩色矩阵
  2. C++拾趣——C++11的语法糖auto
  3. 数据持久化框架为什么放弃Hibernate、JPA、Mybatis,最终选择JDBCTemplate!
  4. 脂肪肝,应该拿你怎么办
  5. Tomcat 安装与使用
  6. NYOJ 52 无聊的小明
  7. CV中多的空格导致报错
  8. HTML JS正方形轮播,js,html一个页面里面多个页面轮播
  9. 「Python基础知识」Python中常用的内建函数有哪些
  10. linux中wget 、apt-get、yum rpm区别
  11. Python 算法交易实验30 退而结网7-交易策略思考
  12. linux sata 3驱动下载,linux – SSD SATA3驱动器可能存在的问题
  13. c语言的缺陷与陷阱,C语言 宏陷阱与缺陷
  14. python调用crt自动登录_secureCRT自动登录脚本(python)
  15. python链式函数_python 链式
  16. 2019计算机就业形势图表分析,2019毕业生就业形势分析
  17. 【练习】canvas——flappyBird
  18. pyhanlp机构名识别时动态添加自定义词表(CustomDictionary)
  19. React Native 每日一学(Learn a little every day)
  20. 增值电信业务经营许可证是什么?怎么办理?

热门文章

  1. 操作系统学习(一)(B站视频)
  2. 360影视爬虫--python
  3. 前端基础 es6、vue
  4. 网易云音乐推荐算法分析
  5. 鸿蒙华为5G老总是任正非吗,微信迟迟不用“鸿蒙”,华为难道不着急吗?任正非:5G不是摆设...
  6. 父Shell与子Shell
  7. wap.php区别,WAP与PHP程序设计之基础篇
  8. LXC、LXD、Docker的区别与联系
  9. 【Android】轻松实现 APK 在线升级
  10. C/Cpp贪吃蛇(数组)