JavaScript 编程精解 中文第三版 12、项目:编程语言

来源: ApacheCN『JavaScript 编程精解 中文第三版』翻译项目

原文:Project: A Programming Languagejavascript

译者:飞龙html

协议:CC BY-NC-SA 4.0java

自豪地采用谷歌翻译git

部分参考了《JavaScript 编程精解(第 2 版)》github

肯定编程语言中的表达式含义的求值器只是另外一个程序。正则表达式

Hal Abelson 和 Gerald Sussman,《计算机程序的构造和解释》apache

构建你本身的编程语言不只简单(只要你的要求不要过高就好),并且对人富有启发。编程

但愿经过本章的介绍,你能发现构建本身的编程语言其实并非什么难事。我常常感到某些人的想法聪明无比,并且十分复杂,以致于我都不能彻底理解。不过通过一段时间的阅读和实验,我就发现它们其实也并无想象中那么复杂。数组

咱们将创造一门名为 Egg 的编程语言。这是一门小巧而简单的语言,可是足够强大到能描述你所能想到的任何计算。它容许基于函数的简单抽象。数据结构

解析

程序设计语言中最直观的部分就是语法(syntax)或符号。解析器是一种程序,负责读入文本片断(包含程序的文本),并产生一系列与程序结构对应的数据结构。若文本不是一个合法程序,解析器应该指出错误。

咱们的语言语法简单,并且具备一致性。Egg 中一切都是表达式。表达式能够是绑定名称、数字,或应用(application)。不只函数调用属于应用,并且ifwhile之类的语言构造也属于应用。

为了确保解析器的简单性,Egg 中的字符串不支持反斜杠转义符之类的元素。字符串只是简单的字符序列(不包括双引号),并使用双引号包围起来。数值是数字序列。绑定名由任何非空白字符组成,而且在语法中不具备特殊含义。

应用的书写方式与 JavaScript 中同样,也是在一个表达式后添加一对括号,括号中能够包含任意数量的参数,参数之间使用逗号分隔。

do(define(x, 10),
   if(>(x, 5),
      print("large"),
      print("small")))

Egg 语言的一致性体如今:JavaScript 中的全部运算符(好比>)在 Egg 中都是绑定,可是能够像其余函数同样调用。因为语法中没有语句块的概念,所以咱们须要使用do结构来表示多个表达式的序列。

解析器的数据结构用于描述由表达式对象组成的程序,每一个对象都包含一个表示表达式类型的type属性,除此之外还有其余描述对象内容的属性。

类型为"value"的表达式表示字符串和数字。它们的value属性包含对应的字符串和数字值。类型为"word"的表达式用于标识符(名称)。这类对象以字符串形式将标识符名称保存在name属性中。最后,类型为"apply"的表达式表示应用。该类型的对象有一个operator属性,指向其操做的表达式,还有一个args属性,持有参数表达式的数组。

上面代码中> (x, 5)这部分能够表达成以下形式:

{
  type: "apply",
  operator: {type: "word", name: ">"},
  args: [
    {type: "word", name: "x"},
    {type: "value", value: 5}
  ]
}

咱们将这样一个数据结构称为表达式树。若是你将对象想象成点,将对象之间的链接想象成点之间的线,这个数据结构将会变成树形。表达式中还会包含其余表达式,被包含的表达式接着又会包含更多表达式,这相似于树的分支重复分裂的方式。

咱们将这个解析器与咱们第 9 章中编写的配置文件格式解析器进行对比,第 9 章中的解析器结构很简单:将输入文件划分红行,并逐行处理。并且每一行只有几种简单的语法形式。

咱们必须使用不一样方法来解决这里的问题。Egg 中并无表达式按行分隔,并且表达式之间还有递归结构。应用表达式包含其余表达式。

所幸咱们可使用递归的方式编写一个解析器函数,并优雅地解决该问题,这反映了语言自身就是递归的。

咱们定义了一个函数parseExpression,该函数接受一个字符串,并返回一个对象,包含了字符串起始位置处的表达式与解析表达式后剩余的字符串。当解析子表达式时(好比应用的参数),能够再次调用该函数,返回参数表达式和剩余字符串。剩余的字符串能够包含更多参数,也有能够是一个表示参数列表结束的右括号。

这里给出部分解析器代码。

function parseExpression(program) {
  program = skipSpace(program);
  let match, expr;
  if (match = /^"([^"]*)"/.exec(program)) {
    expr = {type: "value", value: match[1]};
  } else if (match = /^\d+\b/.exec(program)) {
    expr = {type: "value", value: Number(match[0])};
  } else if (match = /^[^\s(),"]+/.exec(program)) {
    expr = {type: "word", name: match[0]};
  } else {
    throw new SyntaxError("Unexpected syntax: " + program);
  }

  return parseApply(expr, program.slice(match[0].length));
}

function skipSpace(string) {
  let first = string.search(/\S/);
  if (first == -1) return "";
  return string.slice(first);
}

因为 Egg 和 JavaScript 同样,容许其元素之间有任意数量的空白,因此咱们必须在程序字符串的开始处重复删除空白。 这就是skipSpace函数能提供的帮助。

跳过开头的全部空格后,parseExpression使用三个正则表达式来检测 Egg 支持的三种原子的元素:字符串、数值和单词。解析器根据不一样的匹配结果构造不一样的数据类型。若是这三种形式都没法与输入匹配,那么输入就是一个非法表达式,解析器就会抛出异常。咱们使用SyntaxError而不是Error做为异常构造器,这是另外一种标准错误类型,由于它更具体 - 它也是在尝试运行无效的 JavaScript 程序时,抛出的错误类型。

接下来,咱们从程序字符串中删去匹配的部分,将剩余的字符串和表达式对象一块儿传递给parseApply函数。该函数检查表达式是不是一个应用,若是是应用则解析带括号的参数列表。

function parseApply(expr, program) {
  program = skipSpace(program);
  if (program[0] != "(") {
    return {expr: expr, rest: program};
  }

  program = skipSpace(program.slice(1));
  expr = {type: "apply", operator: expr, args: []};
  while (program[0] != ")") {
    let arg = parseExpression(program);
    expr.args.push(arg.expr);
    program = skipSpace(arg.rest);
    if (program[0] == ",") {
      program = skipSpace(program.slice(1));
    } else if (program[0] != ")") {
      throw new SyntaxError("Expected ',' or ')'");
    }
  }
  return parseApply(expr, program.slice(1));
}

若是程序中的下一个字符不是左圆括号,说明当前表达式不是一个应用,parseApply会返回该表达式。

不然,该函数跳过左圆括号,为应用表达式建立语法树。接着递归调用parseExpression解析每一个参数,直到遇到右圆括号为止。此处经过parseApplyparseExpression互相调用,实现函数间接递归调用。

由于咱们可使用一个应用来操做另外一个应用表达式(好比multiplier(2)(1)),因此parseApply解析完一个应用后必须再次调用自身检查是否还有另外一对圆括号。

这就是咱们解析 Egg 所需的所有代码。咱们使用parse函数来包装parseExpression,在解析完表达式以后验证输入是否到达结尾(一个 Egg 程序是一个表达式),遇到输入结尾后会返回整个程序对应的数据结构。

function parse(program) {
  let {expr, rest} = parseExpression(program);
  if (skipSpace(result.rest).length > 0) {
    throw new SyntaxError("Unexpected text after program");
  }
  return expr;
}

console.log(parse("+(a, 10)"));
// → {type: "apply",
//    operator: {type: "word", name: "+"},
//    args: [{type: "word", name: "a"},
//           {type: "value", value: 10}]}

程序能够正常工做了!当表达式解析失败时,解析函数不会输出任何有用的信息,也不会存储出错的行号与列号,而这些信息都有助于以后的错误报告。但考虑到咱们的目的,这门语言目前已经足够优秀了。

求值器(evaluator)

在有了一个程序的语法树以后,咱们该作什么呢?固然是执行程序了!而这就是求值器的功能。咱们将语法树和做用域对象传递给求值器,执行器就会求解语法树中的表达式,而后返回整个过程的结果。

const specialForms = Object.create(null);

function evaluate(expr, scope) {
  if (expr.type == "value") {
    return expr.value;
  } else if (expr.type == "word") {
    if (expr.name in scope) {
      return scope[expr.name];
    } else {
      throw new ReferenceError(
        `Undefined binding: ${expr.name}`);
    }
  } else if (expr.type == "apply") {
    let {operator, args} = expr;
    if (operator.type == "word" &&
        operator.name in specialForms) {
      return specialForms[operator.name](expr.args, scope);
    } else {
      let op = evaluate(operator, scope);
      if (typeof op == "function") {
        return op(...args.map(arg => evaluate(arg, scope)));
      } else {
        throw new TypeError("Applying a non-function.");
      }
    }
  }
}

求值器为每一种表达式类型都提供了相应的处理逻辑。字面值表达式产生自身的值(例如,表达式100的求值为数值100)。对于绑定而言,咱们必须检查程序中是否实际定义了该绑定,若是已经定义,则获取绑定的值。

应用则更为复杂。若应用有特殊形式(好比if),咱们不会求解任何表达式,而是将表达式参数和环境传递给处理这种形式的函数。若是是普通调用,咱们求解运算符,验证其是不是函数,并使用求值后的参数调用函数。

咱们使用通常的 JavaScript 函数来表示 Egg 的函数。在定义特殊格式fun时,咱们再回过头来看这个问题。

evaluate的递归结构相似于解析器的结构。二者都反映了语言自身的结构。咱们也能够将解析器和求值器集成到一块儿,在解析的同时求解表达式,但将其分离为两个阶段使得程序更易于理解。

这就是解释 Egg 所需的所有代码。这段代码很是简单,但若是不定义一些特殊的格式,或向环境中添加一些有用的值,你没法使用该语言完成不少工做。

特殊形式

specialForms对象用于定义 Egg 中的特殊语法。该对象将单词和求解这种形式的函数关联起来。目前该对象为空,如今让咱们添加if

specialForms.if = (args, scope) => {
  if (args.length != 3) {
    throw new SyntaxError("Wrong number of args to if");
  } else if (evaluate(args[0], scope) !== false) {
    return evaluate(args[1], scope);
  } else {
    return evaluate(args[2], scope);
  }
};

Egg 的if语句须要三个参数。Egg 会求解第一个参数,若结果不是false,则求解第二个参数,不然求解第三个参数。相较于 JavaScript 中的if语句,Egg 的if形式更相似于 JavaScript 中的?:运算符。这是一条表达式,而非语句,它会产生一个值,即第二个或第三个参数的结果。

Egg 和 JavaScript 在处理条件值时也有些差别。Egg 不会将 0 或空字符串做为假,只有当值确实为false时,测试结果才为假。

咱们之因此须要将if表达为特殊形式,而非普通函数,是由于函数的全部参数须要在函数调用前求值完毕,而if则只应该根据第一个参数的值,肯定求解第二个仍是第三个参数。while的形式也是相似的。

specialForms.while = (args, scope) => {
  if (args.length != 2) {
    throw new SyntaxError("Wrong number of args to while");
  }
  while (evaluate(args[0], scope) !== false) {
    evaluate(args[1], scope);
  }

  // Since undefined does not exist in Egg, we return false,
  // for lack of a meaningful result.
  return false;
};

另外一个基本的积木是do,会自顶向下执行其全部参数。整个do表达式的值是最后一个参数的值。

specialForms.do = (args, scope) => {
  let value = false;
  for (let arg of args) {
    value = evaluate(arg, scope);
  }
};

咱们还须要建立名为define的形式,来建立绑定对绑定赋值。define的第一个参数是一个单词,第二个参数是一个会产生值的表达式,并将第二个参数的计算结果赋值给第一个参数。因为define也是个表达式,所以必须返回一个值。咱们则规定define应该将咱们赋予绑定的值返回(就像 JavaScript 中的=运算符同样)。

specialForms.define = (args, scope) => {
  if (args.length != 2 || args[0].type != "word") {
    throw new SyntaxError("Incorrect use of define");
  }
  let value = evaluate(args[1], scope);
  scope[args[0].name] = value;
  return value;
};

环境

evaluate所接受的做用域是一个对象,它的名称对应绑定名称,它的值对应这些绑定所绑定的值。 咱们定义一个对象来表示全局做用域。

咱们须要先定义布尔绑定才能使用以前定义的if语句。因为只有两个布尔值,所以咱们不须要为其定义特殊语法。咱们简单地将truefalse两个名称与其值绑定便可。

const topEnv = Object.create(null);

topScope.true = true;
topScope.false = false;

咱们如今能够求解一个简单的表达式来对布尔值求反。

let prog = parse(`if(true, false, true)`);
console.log(evaluate(prog, topScope));
// → false

为了提供基本的算术和比较运算符,咱们也添加一些函数值到做用域中。为了确保代码短小,咱们在循环中使用 Function来合成一批运算符,而不是分别定义全部运算符。

for (let op of ["+", "-", "*", "/", "==", "<", ">"]) {
  topScope[op] = Function("a, b", `return a ${op} b;`);
}

输出也是一个实用的功能,所以咱们将console.log包装在一个函数中,并称之为print

topScope.print = value => {
  console.log(value);
  return value;
};

这样一来咱们就有足够的基本工具来编写简单的程序了。下面的函数提供了一个便利的方式来编写并运行程序。它建立一个新的环境对象,并解析执行咱们赋予它的单个程序。

function run(program) {
  return evaluate(parse(program), Object.create(topScope));
}

咱们将使用对象原型链来表示嵌套的做用域,以便程序能够在不改变顶级做用域的状况下,向其局部做用域添加绑定。

run(`
do(define(total, 0),
   define(count, 1),
   while(<(count, 11),
         do(define(total, +(total, count)),
            define(count, +(count, 1)))),
   print(total))
`);
// → 55

咱们以前已经屡次看到过这个程序,该程序计算数字 1 到 10 的和,只不过这里使用 Egg 语言表达。很明显,相较于实现一样功能的 JavaScript 代码,这个程序并不优雅,但对于一个不足 150 行代码的程序来讲已经很不错了。

函数

每一个功能强大的编程语言都应该具备函数这个特性。

幸运的是咱们能够很容易地添加一个fun语言构造,fun将最后一个参数看成函数体,将以前的全部名称用做函数参数。

specialForms.fun = (args, scope) => {
  if (!args.length) {
    throw new SyntaxError("Functions need a body");
  let body = args[args.length - 1];
  let params = args.slice(0, args.length - 1).map(expr => {
    if (expr.type != "word") {
      throw new SyntaxError("Parameter names must be words");
    }
    return expr.name;
  });

  return function() {
    if (arguments.length != argNames.length) {
      throw new TypeError("Wrong number of arguments");
    }
    let localScope = Object.create(scope);
    for (let i = 0; i < arguments.length; i++) {
      localScope[params[i]] = arguments[i];
    }
    return evaluate(body, localScope);
  };
};

Egg 中的函数能够得到它们本身的局部做用域。 fun形式产生的函数建立这个局部做用域,并将参数绑定添加到它。 而后求解此范围内的函数体并返回结果。

run(`
do(define(plusOne, fun(a, +(a, 1))),
   print(plusOne(10)))
`);
// → 11

run(`
do(define(pow, fun(base, exp,
     if(==(exp, 0),
        1,
        *(base, pow(base, -(exp, 1)))))),
   print(pow(2, 10)))
`);
// → 1024

编译

咱们构建的是一个解释器。在求值期间,解释器直接做用域由解析器产生的程序的表示。

编译是在解析和运行程序之间添加的另外一个步骤:经过事先完成尽量多的工做,将程序转换成一些能够高效求值的东西。例如,在设计良好的编程语言中,使用每一个绑定时绑定引用的内存地址都是明确的,而不须要在程序运行时进行动态计算。这样能够省去每次访问绑定时搜索绑定的时间,只须要直接去预先定义好的内存位置获取绑定便可。

通常状况下,编译会将程序转换成机器码(计算机处理能够执行的原始格式)。但一些将程序转换成不一样表现形式的过程也被认为是编译。

咱们能够为 Egg 编写一个可供选择的求值策略,首先使用Function,调用 JavaScript 编译器编译代码,将 Egg 程序转换成 JavaScript 程序,接着执行编译结果。若能正确实现该功能,可使得 Egg 运行的很是快,并且实现这种编译器确实很是简单。

若是读者对该话题感兴趣,愿意花费一些时间在这上面,建议你尝试实现一个编译器做为练习。

站在别人的肩膀上

咱们定义ifwhile的时候,你可能注意到他们封装得或多或少和 JavaScript 自身的ifwhile有点像。一样的,Egg 中的值也就是 JavaScript 中的值。

若是读者比较一下两种 Egg 的实现方式,一种是基于 JavaScrip t之上,另外一种是直接使用机器提供的功能构建程序设计语言,会发现第二种方案须要大量工做才能完成,并且很是复杂。无论怎么说,本章的内容就是想让读者对编程语言的运行方式有一个基本的了解。

当须要完成一些任务时,相比于本身完成全部工做,借助于别人提供的功能是一种更高效的方式。虽然在本章中咱们编写的语言就像玩具同样,十分简单,并且不管在什么状况下这门语言都没法与 JavaScript 相提并论。但在某些应用场景中,编写一门微型语言能够帮助咱们更好地完成工做。

这些语言不须要像传统的程序设计语言。例如,若 JavaScript 没有正则表达式,你能够为正则表达式编写本身的解析器和求值器。

或者想象一下你在构建一个巨大的机械恐龙,须要编程实现恐龙的行为。JavaScript 可能不是实现该功能的最高效方式,你能够选择一种语言做为替代,以下所示:

behavior walk
  perform when
    destination ahead
  actions
    move left-foot
    move right-foot

behavior attack
  perform when
    Godzilla in-view
  actions
    fire laser-eyes
    launch arm-rockets

这一般被称为领域特定语言(Domain-specific Language),一种为表达极为有限的知识领域而量身定制的语言。它能够准确描述其领域中须要表达的事物,而没有多余元素。这种语言比通用语言更具表现力。

习题

数组

在 Egg 中支持数组须要将如下三个函数添加到顶级做用域:array(...values)用于构造一个包含参数值的数组,length(array)用于获取数组长度,element(array, n)用于获取数组中的第n个元素。

// Modify these definitions...

topEnv.array = "...";

topEnv.length = "...";

topEnv.element = "...";

run(`
do(define(sum, fun(array,
     do(define(i, 0),
        define(sum, 0),
        while(<(i, length(array)),
          do(define(sum, +(sum, element(array, i))),
             define(i, +(i, 1)))),
        sum))),
   print(sum(array(1, 2, 3))))
`);
// → 6

闭包

咱们定义fun的方式容许函数引用其周围环境,就像 JavaScript 函数同样,函数体可使用在定义该函数时能够访问的全部局部绑定。

下面的程序展现了该特性:函数f返回一个函数,该函数将其参数和f的参数相加,这意味着为了使用绑定a,该函数须要可以访问f中的局部做用域。

run(`
do(define(f, fun(a, fun(b, +(a, b)))),
   print(f(4)(5)))
`);
// → 9

回顾一下fun形式的定义,解释一下该机制的工做原理。

注释

若是咱们能够在 Egg 中编写注释就太好了。例如,不管什么时候,只要出现了井号(#),咱们都将该行剩余部分当成注释,并忽略之,就相似于 JavaScript 中的//

解析器并不须要为支持该特性进行大幅修改。咱们只须要修改skipSpace来像跳过空白符号同样跳过注释便可,此时调用skipSpace时不只会跳过空白符号,还会跳过注释。修改代码,实现这样的功能。

// This is the old skipSpace. Modify it...
function skipSpace(string) {
  let first = string.search(/\S/);
  if (first == -1) return "";
  return string.slice(first);
}

console.log(parse("# hello\nx"));
// → {type: "word", name: "x"}

console.log(parse("a # one\n   # two\n()"));
// → {type: "apply",
//    operator: {type: "word", name: "a"},
//    args: []}

修复做用域

目前绑定赋值的惟一方法是define。该语言构造能够同时实现定义绑定和将一个新的值赋予已存在的绑定。

这种歧义性引起了一个问题。当你尝试为一个非局部绑定赋予新值时,你最后会定义一个局部绑定并替换掉原来的同名绑定。一些语言的工做方式正和这种设计同样,可是我老是认为这是一种笨拙的做用域处理方式。

添加一个相似于define的特殊形式set,该语句会赋予一个绑定新值,若绑定不存在于内部做用域,则更新其外部做用域相应绑定的值。若绑定没有定义,则抛出ReferenceError(另外一个标准错误类型)。

咱们目前采起的技术是使用简单的对象来表示做用域对象,处理目前的任务很是方便,此时咱们须要更进一步。你可使用Object.getPrototypeOf函数来获取对象原型。同时也要记住,咱们的做用域对象并未继承Object.prototype,所以若想调用hasOwnProperty,须要使用下面这个略显复杂的表达式。

Object.prototype.hasOwnProperty.call(scope, name);
specialForms.set  = function(args, env) {
  // Your code here.
};

run(`
do(define(x, 4),
   define(setx, fun(val, set(x, val))),
   setx(50),
   print(x))
`);
// → 50
run(`set(quux, true)`);
// → Some kind of ReferenceError
相关文章
相关标签/搜索