《学习编译原理》使用js编写一个计算器

613 阅读9分钟

前言

编译原理在学习前总是给人一种高大上的感觉,但当实际了解后就会发现编译原理的本质其实并不复杂,在最近的学习中,我发现计算器可以很好的诠释编译原理的部分原理,所以,我将编写一个计算器程序来解释编译原理中的部分概念。

基础概念

没有仔细了解过编译原理当然也知道词法分析、语法分析、语义分析等与编译原理相关的概念,这三者关系如图所示

未命名文件.png

不管是何种编程语言,其本质就是一段字符串,我们和计算机约定规范,我们按照规范去编写代码,计算机按照规范去执行,这个规范就是语法规则,执行这个规范的工具就叫做编译器。

词法分析将句子拆成词,语法分析将词构造成有层次关系的抽象语法树,语义分析就是把一些模棱两可的地方消除掉,去除一些歧义部分,比如"2"+1需不需要对2进行类型转换等,本次计算器的编写中不涉及语义分析。

词法分析

编程的第一步是词法分析,阅读文章时,文章是由一个个单词组成的,而程序也一样,只不过程序中我们称为“词法记号”,简称Token。在js程序中,例如if、else、let等关键词, +、-、=这样的符号,还有数字、字符串这样的值,这些都是Token

我们现在有一个计算式,如下所示

12 + 34

从这个表达式中,我们可以看到有两个数字和一个加法符号,那我们怎么来提取出来这几个元素呢

首先,英文中经常用空格和标点符号来把单词拆分开,方便阅读和理解,但是表达式中用空格来区分是不合适的,因为,例如1 + 2是有三个元素,但是我们是可以写成连在一起的1+2,中间不一定非得用空格。

所以,第一步我们就需要识别以下两种类型的Token

  • 识别1或者2这样的数字变量
  • 识别+符号的加法运算符

词法分析器分析整个程序的字符串,它的核心就是当遇到不同的字符时会进入到不同的状态,例如,词法分析器扫描到数字的时候会处于数字状态,查看后面跟的字符是否还是数字,等它遇到一个+的时候进入到加法符号的状态,当它再遇到一个数字的时候,就会再次进入到数字状态。

未命名文件 (1).png

我们可以用代码来解释

const addition = "12+34";

const isDigit = (char) => {
  return /[0-9]/.test(char);
};

function tokenizer(code) {
  let tokens = [];
  let current = 0;
  let char = "";
  let tokenText = "";

  while (current <= code.length) {
    char = code[current];
    // 提取数字
    if (isDigit(char)) {
      tokenText += char;
    } else if (tokenText) {
      tokens.push({type: "number", value: tokenText,});
      tokenText = "";
    }
    // 提取+符号
    if (char === "+") {
      tokens.push({type: "symbol", value: char,});
    }

    current++;
  }
  return tokens;
}

const tokens = tokenizer(addition)

通过运行,我们可以得到一个token数组,如下所示

[
  { type: 'number', value: '12' },
  { type: 'symbol', value: '+' },
  { type: 'number', value: '34' }
]

得到这个token数组后,我们想要计算值就非常简单了

// 声明一个计算函数
function evaluate(tokens) {
  let result;
  if (tokens[1].type === "symbol" && tokens[1].value === "+") {
    let value1 = Number(tokens[0].value);
    let value2 = Number(tokens[2].value);
    result = value1 + value2;
  }

  return result;
}
// 计算得出结果
const result = evaluate(tokens);
// 46

+换成-*都是可以的,原理是一样的,大家可以想一下要改动哪些部分可以同时支持两数相加,相减以及相乘。同时存在加法和乘法表达式将在后面讲解。

语法分析(加法)

上面的evaluate计算方式存在一个很严重的问题,那就是我们定死了两数相加,如果表达式变成下面这样:

12 + 34 + 56

使用evaluate方法就无法计算出正确的结果了,当然我们可以继续扩展evaluate函数的内容,使其支持三个数相加,但是计算式可能有无限个数相加,我们不可能全部写在表达式中,这个时候就需要我们先整体识别出代码的语法结构然后再去遍历运行,这样就不需要我们在函数中一步一步判断了。

词法分析是识别一个个token,那语法分析就是词法分析的基础上,识别出程序的语法结构。这个语法结构是一个树状结构,是计算机容易理解和执行的,上面表达式的树状结构,我们可以将其转化成如下结构:

未命名文件 (2).png

一个表达式就是一棵树,针对这棵树,我们从根节点去遍历就可以得到计算结果。

为了生成这颗语法树,我们需要在代码中引入一个新函数parse,用于处理token列表,我们先声明语法树的节点类型AstNode,类型声明如下

class AstNode {
  nodeType = "";
  children = [];
  text = "";

  constructor(nodeType, text) {
    this.nodeType = nodeType;
    text && (this.text = text);
  }

  addChild(node) {
    this.children.push(node);
  }
}

每个语法树节点就是一个AstNode类实例,parse实现如下

function parse(tokens) {
  let token = tokens.shift();
  let child1 = new AstNode(token.type, token.value);
  let node = child1;
  while (true) {
    token = tokens.shift();
    if (token && token.type === "symbol" && token.value === "+") {
      node = new AstNode("add", token.value);
      token = tokens.shift();
      let child2 = new AstNode(token.type, token.value);
      node.addChild(child1);
      node.addChild(child2);
      child1 = node;
    } else {
      break;
    }
  }
  return node;
}

代码理解起来有点困难,我用图来描述

未命名文件 (3).png

第一步:遇到第一个token,我们先给它声明为node节点和child1节点,继续看后面有没有节点来决定child1是不是最终返回的节点

第二步:我们发现有一个token,这个token符号是+号,所以我们新建一个加法节点,把node节点声明放到这个加法语法树上

第三步:因为是一个加法表达式,所以后一个token一定是这个加法语法树的第二个子节点,我们声明child2节点

第四步:我们继续往后看,发现还有加号,所以我们把前面这整个加法语法树声明为child1,重新开始后面token的判断,最后就生成如上图所示的语法树。

因为生成这个语法树,计算表达式的值已经不能用之前的evaluate函数了,我们要对其改造,使其支持遍历加法语法树并求出结果。改动如下

function evaluate(astNode) {
  let child1, child2;
  let value1, value2;
  let result;

  switch (astNode.nodeType) {
    case "add":
      child1 = astNode.children[0];
      value1 = evaluate(child1);
      child2 = astNode.children[1];
      value2 = evaluate(child2);

      result = value1 + value2;
      break;
    case "number":
      result = Number(astNode.text);
      break;
  }

  return result;
}

const tokens = tokenizer(addition);
console.log(tokens);
const ast = parse(tokens)
console.log(ast)
const result = evaluate(ast);
console.log(result);

evaluate添加条件判断进行递归求值,即可得出最后的结果。

语法分析(乘法)

在上面的表达式我们只考虑加法运算法则,如果新增一个乘法运算法则,那么目前并不支持,乘法法则和加法不太相同,乘法运算法则的优先级要高于加法运算法则。例如

12 + 34 * 56

我们的抽象语法树就不能生成下面展示的样式

未命名文件 (4).png

因为乘法的优先级是高于加法的,所以,我们的语法树要生成如下样式。

未命名文件 (5).png

那我们需要如何才能转化为上图所示的语法树,需要做的是先查找存不存在乘法的表达式,存在的话先消掉乘法表达式。乘法表达式不需要考虑左结合,所以我们可以直接递归消除。实现函数如下

function multiplicate(tokens) {
  let token = tokens[0];
  let node = null;

  if (token.type === "number") {
    tokens.shift();
    let child1 = new AstNode(token.type, token.value);
    node = child1;
    token = tokens[0];
    if (token && token.type === "symbol" && token.value === "*") {
      tokens.shift();
      node = new AstNode("star", token.value);
      let child2 = multiplicate(tokens);
      node.addChild(child1);
      node.addChild(child2);
    }
  }

  return node;
}

乘法表达式先判断是否存在*符号,有的话就生成乘法语法树,因为我构建表达式抽象语法树的时候需要先消除乘法,所以加法表达式也需要做相应的调整。

function parse(tokens) {
  let child1 = multiplicate(tokens);
  let node = child1;
  while (true) {
    let token = tokens.shift();
    if (token && token.type === "symbol" && token.value === "+") {
      node = new AstNode("add", token.value);
      let child2 = multiplicate(tokens);
      node.addChild(child1);
      node.addChild(child2);
      child1 = node;
    } else {
      break;
    }
  }
  return node;
}

我们先去判断一个节点能否被乘法消除,如果可以的话就返回乘法节点,如果不可以的话,就返回普通的数字类型节点。已经添加了乘法语法了,那我们的evaluate方法同样也需要修改,添加上乘法计算的条件判断。

function evaluate(astNode) {
  let child1, child2;
  let value1, value2;
  let result;

  switch (astNode.nodeType) {
    case "add":
      child1 = astNode.children[0];
      value1 = evaluate(child1);
      child2 = astNode.children[1];
      value2 = evaluate(child2);

      result = value1 + value2;
      break;
    case "star":
      child1 = astNode.children[0];
      value1 = evaluate(child1);
      child2 = astNode.children[1];
      value2 = evaluate(child2);

      result = value1 * value2;
      break;
    case "number":
      result = Number(astNode.text);
      break;
  }

  return result;
}

最终我们可以得到计算结果

image.png

语法分析(括号优先级)

在上面的开发中,我们支持了加法和乘法的混合表达式,如果我们要支持括号表达式应该如何入手呢,例如下面的例子

(12 + 34) * 56

首先我们会发现,括号的优先级会高于乘法,而括号里面又会存在新的表达式,所以我们需要先判断括号,并且消除包括括号内的token。

第一步,先能生成括号的token节点,我们照例改造tokenizer函数,使其能捕获括号token

function tokenizer(code) {
  let tokens = [];
  let current = 0;
  let char = "";
  let tokenText = "";

  while (current <= code.length) {
    char = code[current];
    if (isDigit(char)) {
      tokenText += char;
    } else if (tokenText) {
      tokens.push({
        type: "number",
        value: tokenText,
      });
      tokenText = "";
    }

    if (char === "+") {
      tokens.push({
        type: "symbol",
        value: char,
      });
    }

    if (char === "*") {
      tokens.push({
        type: "symbol",
        value: char,
      });
    }
    // 新增部分内容
    if (char === "(") {
      tokens.push({
        type: "symbol",
        value: char,
      });
    }

    if (char === ")") {
      tokens.push({
        type: "symbol",
        value: char,
      });
    }

    current++;
  }

  return tokens;
}

之后我们对parse函数进行改造,将其内容封装成additive函数,改造结果如下

function parse(tokens) {
  return additive(tokens);
}

function additive(tokens) {
  let child1 = multiplicate(tokens);
  let node = child1;

  while (true) {
    let token = tokens[0];
    if (token && token.type === "symbol" && token.value === "+") {
      tokens.shift();
      node = new AstNode("add", token.value);
      let child2 = multiplicate(tokens);
      node.addChild(child1);
      node.addChild(child2);
      child1 = node;
    } else {
      break;
    }
  }
  return node;
}

这样做的目的是为了方便调用additive消除括号内的表达式,因为括号的优先级是高于乘法的,所以在消除乘法表达式之前,我们需要先尝试消除括号表达式,我们声明primary函数用来消除括号以及其中的表达式token,函数实现如下

function primary(tokens) {
  let token = tokens[0];
  let node = null;
  if (token.type === "number") {
    tokens.shift();
    node = new AstNode(token.type, token.value);
  } else if (token.type === "symbol" && token.value === "(") {
    tokens.shift();
    node = additive(tokens);
    token = tokens[0];
    if (token.type === "symbol" && token.value === ")") {
      tokens.shift();
    }
  }
  return node;
}

因为他的优先级高于乘法,所以在乘法消除之前我们需要先调用primary消除括号以及括号内的内容,因此我们需要改在乘法函数,改造实现如下

function multiplicate(tokens) {
  let child1 = primary(tokens);
  let node = child1;
  let token = tokens[0];

  if (token && token.type === "symbol" && token.value === "*") {
    tokens.shift();
    node = new AstNode("star", token.value);
    let child2 = multiplicate(tokens);
    node.addChild(child1);
    node.addChild(child2);
  }

  return node;
}

最后让我们来看一下最后的运行结果

image.png

后记

运算符号我漏掉了简单的减和除,如果要加上这两种运算方式,改造也非常简单,在此不做过多描述

git源码:github.com/Mrlgm/calcu…