Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

如何用JavaScript实现一门编程语言 - 真实示例 #27

Open
llwanghong opened this issue Apr 28, 2023 · 0 comments
Open

如何用JavaScript实现一门编程语言 - 真实示例 #27

llwanghong opened this issue Apr 28, 2023 · 0 comments

Comments

@llwanghong
Copy link
Owner

llwanghong commented Apr 28, 2023

整篇译文的目录章节如下:

真实示例

我以少量例子来结束本教程,也希望它们能展示出λanguage语言可以比ES5做得更好。我知道ES6就在眼前, 但其还需要数年(未来N年)才可能得到广泛支持 — 当下我们只有两个选择,一个是写一个成功的浏览器提供替代JavaScript的方案(可能性为0),或者将一门更好的语言编译成JavaScript(成功的概率100%)。

  • catDir(pathname) — 一个函数会递归遍历目录树并打印每个文件的内容,除了以点号开始的文件(即隐藏文件)。
  • copyTreeSeq(srcdir, destdir) — 一个函数会递归拷贝一个目录树。
  • copyTree(srcdir, destdir) — 上面拷贝目录树函数的并行版本。它一次拷贝多个文件所以会快很多。
  • In fairness to Node — 写了一个parallelEach抽象并丢掉错误处理(失败情况)尝试使得纯NodeJS代码尽可能看起来更好。
  • Exceptions — 演示了一个可以支持跨异步调用的简单异常系统。

原始功能函数

本节的示例需要下面的原始功能函数库。将其保存为"library.lambda"并确保在需要它的程序之前引入它,例如:

cat library.lambda program.lambda | node lambda.js

所有原始功能函数都将接收continuation(k)作为第一个参数。并请注意其中一些需要使用Execute来调用回调函数。因为它们会调用异步API,所以Execute中的循环将在结果生成之前就结束了。你在写原始功能函数时一定要切记这点 — 如果你不会立即调用continuation,请使用Execute。

## primitives
​
## these assignments are required so that we create globals.readFile = false;
readDir = false;
writeFile = false;
makeDir = false;
symlink = false;
readlink = false;
fstat = false;
lstat = false;
isFile = false;
isDirectory = false;
isSymlink = false;
pathJoin = false;
pathBasename = false;
pathDirname = false;
forEach = false;
length = false;
arrayRef = false;
arraySet = false;
stringStartsWith = false;
parallel = false;
cons = false;
isCons = false;
car = false;
cdr = false;
NIL = false;
setCar = false;
setCdr = false;
RESET = false;
SHIFT = false;
​
js:raw "(function(){// filesystemvar fs = require('fs');
  var path = require('path');readFile = function(k, filename) {
    fs.readFile(filename, function(err, data){
      if (err) throw new Error(err);
      Execute(k, [ data ]);
    });
  };readDir = function(k, dirname) {
    fs.readdir(dirname, function(err, files){
      if (err) throw new Error(err);
      Execute(k, [ files ]);
    });
  };writeFile = function(k, filename, data) {
    fs.writeFile(filename, data, function(err){
      if (err) throw new Error(err);
      Execute(k, [ false ]);
    });
  };makeDir = function(k, dirname) {
    fs.mkdir(dirname, function(err){
      if (err) throw new Error(err);
      Execute(k, [ false ]);
    });
  };symlink = function(k, src, dest) {
    fs.symlink(src, dest, function(err){
      if (err) throw new Error(err);
      Execute(k, [ false ]);
    });
  };readlink = function(k, path) {
    fs.readlink(path, function(err, target){
      if (err) throw new Error(err);
      Execute(k, [ target ]);
    });
  };fstat = function(k, pathname) {
    fs.stat(pathname, function(err, stat){
      if (err && err.code != 'ENOENT') throw new Error(err);
      Execute(k, [ err ? false : stat ]);
    });
  };lstat = function(k, pathname) {
    fs.lstat(pathname, function(err, stat){
      if (err && err.code != 'ENOENT') throw new Error(err);
      Execute(k, [ err ? false : stat ]);
    });
  };isFile = function(k, stat) {
    k(stat.isFile());
  };isDirectory = function(k, stat) {
    k(stat.isDirectory());
  };isSymlink = function(k, stat) {
    k(stat.isSymbolicLink());
  };pathJoin = function(k) {
    k(path.join.apply(path, [].slice.call(arguments, 1)));
  };pathBasename = function(k, x) {
    k(path.basename(x));
  };pathDirname = function(k, x) {
    k(path.dirname(x));
  };// listsfunction Cons(car, cdr) {
    this.car = car;
    this.cdr = cdr;
  }isCons = function(k, thing) {
    k(thing instanceof Cons);
  };cons = function(k, car, cdr) {
    k(new Cons(car, cdr));
  };car = function(k, cell) {
    k(cell.car);
  };cdr = function(k, cell) {
    k(cell.cdr);
  };setCar = function(k, cell, car) {
    k(cell.car = car);
  };setCdr = function(k, cell, cdr) {
    k(cell.cdr = cdr);
  };NIL = {};
  NIL.car = NIL;
  NIL.cdr = NIL;// arrayslength = function(k, thing) {
    k(thing.length);
  };arrayRef = function(k, array, index) {
    k(array[index]);
  };arraySet = function(k, array, index, value) {
    k(array[index] = value);
  };// stringsstringStartsWith = function(k, str, prefix) {
    k(str.substr(0, prefix.length) == prefix);
  };parallel = function(k, f){
    var n = 0, result = [], i = 0;
    f(function(){ if (i == 0) k(false) }, pcall);
    function pcall(kpcall, chunk){
      var index = i++;
      n++;
      chunk(function(rchunk){
        n--;
        result[index] = rchunk;
        if (n == 0) Execute(k, [ result ]);
      });
      kpcall(false);
    }
  };// delimited continuationsvar pstack = [];function _goto(f) {
    f(function KGOTO(r){
      var h = pstack.pop();
      h(r);
    });
  }RESET = function(KRESET, th){
    pstack.push(KRESET);
    _goto(th);
  };SHIFT = function(KSHIFT, f){
    _goto(function(KGOTO){
      f(KGOTO, function SK(k1, v){
        pstack.push(k1);
        KSHIFT(v);
      });
    });
  };})()";
​
forEach = λ(array, f)
  let (n = length(array))
    let loop (i = 0)
      if (i < n) {
        f(arrayRef(array, i), i);
        loop(i + 1);
      };

catDir

我抛出一个简单的问题。查找并打印一个目录下的文件!完成一个函数,递归遍历一个目录并打印下面除了以点号开头的每个文件的内容。NodeJS中,要使用异步API。

换句话说,我们正在寻求(几乎)相当于下面命令的功能:

find . -type f -exec cat {} \;

区别是它不应该搜寻以点号开头的目录,并应该排除以点号开头的文件。我将很乐观:用Node完成这个功能会更容易,相对于去探索如何使用find命令工具来完成。

NodeJS实现

如果有一定的经验,写一个原始Node程序来完成这个功能不会太难,但看起来不太美观:

"use strict";var fs = require("fs");
var path = require("path");function catDir(dirname, callback) {
  fs.readdir(dirname, function(err, files){
    if (err) throw new Error(err);
    (function loop(i){
      if (i < files.length) {
        var f = files[i];
        if (f.charAt(0) != ".") {
          var fullname = path.join(dirname, f);
          fs.stat(fullname, function(err, stat){
            if (err) throw new Error(err);
            if (stat.isDirectory()) {
              catDir(fullname, function(){
                loop(i + 1);
              });
            } else if (stat.isFile()) {
              fs.readFile(fullname, "utf8", function(err, data){
                if (err) throw new Error(err);
                process.stdout.write(String(data));
                loop(i + 1);
              });
            }
          });
        } else {
          loop(i + 1);
        }
      } else {
        callback();
      }
    })(0);
  });
}console.time("catDir");
catDir("/home/mishoo/work/my/uglifyjs2/", function(){
  // rest of the program goes here.
  console.timeEnd("catDir");
});

λanguage实现

看看吧,既简洁又优雅。实际上仍使用了异步API,虽然看起来好像没有 — 换句话说,catDir不会阻塞进程。但没有回调地狱。

catDir = λ(dirname) {
  forEach(readDir(dirname), λ(f){
    if !stringStartsWith(f, ".") {
      let (fullname = pathJoin(dirname, f),
           stat = fstat(fullname)) {
        if isDirectory(stat) {
          catDir(fullname);
        } else if isFile(stat) {
          print(readFile(fullname));
        }
      }
    }
  });
};catDir("/home/mishoo/work/my/uglifyjs2/");

说明:

  • 程序需要一个原始功能函数库
  • λanguage语言在一些语法上还相当局限,比如不能直接进行 f.charAt(0) != ".",所以我写了一个 stringStartsWith 作为原始功能函数,并使用了新增加的求反运算符(再说一遍,也是一个语法糖)。
  • readDir 和 readFile 函数也都是原始功能函数,它们会分别调用NodeJS “fs”模块中的相应函数。同样它们使用的都是异步版本。fstat也是一样的情况。
  • 你可能会提出反对我在作弊,使用了forEach,而在JavaScript中使用了一个丑陋的loop函数,但这就是问题所在:λanguage中我们可以使用看起来像同步的循环;JavaScript中却不能(即使用辅助工具库如async.eachSeries,仍需要将剩下代码嵌在一个continuation中传给抽象的循环)。
  • 与纯NodeJS相比的速度差别可以忽略不计。有趣的是,即使我禁用了优化器,性能也不会降低很多。这就是比JavaScript慢10倍的循环也不应该吓到你 — 在真实app中,这些循环往往不会是瓶颈;如果变成了瓶颈,你也可以实现原始功能函数。

串行copyTree

下面是另一个问题,与第一个问题差不多相同的难度。我们想实现一个目录拷贝函数。它接收两个参数:源目录(必须存在且必须是一个目录)和目标路径(必须不存在,将会被创建)。

我们仍处于“说教模式” — 这个函数也不是完美的。它不会考虑文件权限或者恰当的错误处理。同样,它会将源文件读入内存然后写入目标位置(正确的做法应当是分块读取,以支持比RAM容量大的文件)。

NodeJS实现

首先,下面是完整恐怖的NodeJS版本:

function copyTreeSeq(srcdir, destdir, callback) {
  fs.mkdir(destdir, function(err){
    if (err) throw new Error(err);
    fs.readdir(srcdir, function(err, files){
      if (err) throw new Error(err);
      (function loop(i){
        function next(err) {
          if (err) throw new Error(err);
          loop(i + 1);
        }
        if (i < files.length) {
          var f = files[i];
          var fullname = path.join(srcdir, f);
          var dest = path.join(destdir, f);
          fs.lstat(fullname, function(err, stat){
            if (err) throw new Error(err);
            if (stat.isSymbolicLink()) {
              fs.readlink(fullname, function(err, target){
                if (err) throw new Error(err);
                fs.symlink(target, dest, next);
              });
            } else if (stat.isDirectory()) {
              copyTreeSeq(fullname, dest, next);
            } else if (stat.isFile()) {
              fs.readFile(fullname, function(err, data){
                if (err) throw new Error(err);
                fs.writeFile(dest, data, next);
              });
            } else {
              next();
            }
          });
        } else {
          callback();
        }
      })(0);
    });
  });
}

之所以将它命名为copyTreeSeq,因为尽管使用了异步API,但它还是以串行的方式来运行的 — 也就是说,每次仅拷贝一个文件。

λanguage实现

λanguage语言版本很简单,仍然完全是异步的并且和NodeJS版本代码运作的一样好:

copyTreeSeq = λ(srcdir, destdir) {
  makeDir(destdir);
  forEach(readDir(srcdir), λ(f){
    let (fullname = pathJoin(srcdir, f),
         dest = pathJoin(destdir, f),
         stat = lstat(fullname)) {
      if isSymlink(stat) {
        symlink(readlink(fullname), dest);
      } else if isDirectory(stat) {
        copyTreeSeq(fullname, dest);
      } else if isFile(stat) {
        writeFile(dest, readFile(fullname));
      }
    }
  });
};

我通过拷贝一个RAM文件系统中包含2676个嵌套子目录的路径,共11012个文件(180M),来测试上述程序。λanguage程序耗时3.6s,纯NodeJS程序耗时3.5s,也就是Node会快大概2%。现在再来看看代码。2%的速度是否能挽救代码的丑陋?

但注意,这个代码是串行的 — 每次仅拷贝一个文件。既然我们的文件系统操作是异步的,可以尝试每次并行拷贝多个文件。我之前不相信会有多大差别,但事实差别很大:纯NodeJS和λanguage的并行版本都要快大概3倍。

copyTree并行版本

下面是目录拷贝函数的并行版本实现。区别就是不用等待当前文件拷贝完成才开始下一个文件的拷贝,会同时处理多个文件。两种语言版本实现都比它们串行版本快了大概3倍。

NodeJS实现

如何在NodeJS中实现并行似乎比在λanguage语言中更加明显。使用Node,回调函数由你传入,并知道调用者会立即继续执行,所以这开放了一个可以使用for循环的可能。但我们必须谨慎处理保证直到所有文件都被处理后才最终调用callback回调函数。NodeJS的并行版本和串行版本一样丑陋:

function copyTree(srcdir, destdir, callback) {
  fs.mkdir(destdir, function(err){
    if (err) throw new Error(err);
    fs.readdir(srcdir, function(err, files){
      if (err) throw new Error(err);
      var count = files.length;
      function next(err) {
        if (err) throw new Error(err);
        if (--count == 0) callback();
      }
      if (count == 0) {
        callback();
      } else {
        files.forEach(function(f){
          var fullname = path.join(srcdir, f);
          var dest = path.join(destdir, f);
          fs.lstat(fullname, function(err, stat){
            if (err) throw new Error(err);
            if (stat.isSymbolicLink()) {
              fs.readlink(fullname, function(err, target){
                if (err) throw new Error(err);
                fs.symlink(target, dest, next);
              });
            } else if (stat.isDirectory()) {
              copyTree(fullname, dest, next);
            } else if (stat.isFile()) {
              fs.readFile(fullname, function(err, data){
                if (err) throw new Error(err);
                fs.writeFile(dest, data, next);
              });
            } else {
              next();
            }
          });
        });
      }
    });
  });
}

使用forEach循环开始并行拷贝目录下面的所有文件。回调函数(next)需要保证所有文件都已拷贝完成才最终调用最顶层callback。除此之外,还需要校验如果目录没有文件则直接调用callback。

λanguage实现

我们需要一个原始功能函数来完成λanguage语言的并行版本。那是因为我们基于异步Node API的包装程序仅在执行完成后才会调用它们的回调函数,所以默认我们的程序只能串行执行。

我实现了一个原始功能函数,它会接收一个函数参数,并给该函数参数传入叫做pcall的单一参数。pcall接收一个函数参数并对其进行“异步”调用 — 也就是说,当函数参数还在运行的过程中,pcall就会立即重新开始执行。最终,parallel() 调用仅在其函数体中所有pcall结束后才返回。如果听起来很复杂,看下面的用法可能会有一定的启发:

copyTree = λ(srcdir, destdir) {
  makeDir(destdir);
  parallel(λ(pcall){
    forEach(readDir(srcdir), λ(f){
      pcall(λ(){
        let (fullname = pathJoin(srcdir, f),
             dest = pathJoin(destdir, f),
             stat = lstat(fullname)) {
          if isSymlink(stat) {
            symlink(readlink(fullname), dest);
          } else if isDirectory(stat) {
            copyTree(fullname, dest);
          } else if isFile(stat) {
            writeFile(dest, readFile(fullname));
          }
        }
      });
    });
  });
};

所以相对于串行版本字面上的改动很少:将forEach循环包装到一个parallel的调用中,然后使用pcall处理每个文件。我们不需要计算有多少文件并随机地调用回调函数;原始功能函数会处理所有事情。代码看起来像是串行的,但实际是并行运行的!

一个组合了parallel和forEach的原始功能函数将可以编写出更优雅的代码,并且不难实现;但parallel看起来更通用。

可能会有人争论λanguage代码之所以出色是因为对并行的抽象,如果NodeJS做了类似的抽象也可以同样出色。在某种程度上确实如此,但也不会跟上面代码一样清晰简洁。

原始功能函数:parallel

我将稍微讨论一下parallel的实现。下面是实现代码:

parallel = js:raw "function(k, f){
  var n = 0, result = [], i = 0;
  f(function(){ if (i == 0) k(false) }, pcall);
  function pcall(kpcall, chunk){
    var index = i++;
    n++;
    chunk(function(rchunk){
      n--;
      result[index] = rchunk;
      if (n == 0) Execute(k, [ result ]);
    });
    kpcall(false);
  }
}";

k是parallel自身的continuation。f是接收pcall参数进行调用的函数。f接收一个执行 if (i == 0) k(false) 的continuation — 这在当f函数体中没有pcall调用的情况下很必要;我们直接调用顶层parallel的continuation(k)并传入结果false。

pcall算是一个循规蹈矩的函数:接收一个continuation(kpcall)和一个将运行的函数(chunk)。它调用chunk后会立即调用它自身的continuation(传入false;我们此时并没有一个有意义的结果)。所以,如果chunk是异步的,它将与程序剩余部分并行运行(即kpcall所代表的)。

pcall做了一些管家工作来记录它被调用了多少次。在最后一次调用(即 n == 0 )时,它会执行顶层parallel的continuation,并给它传入结果数组(这个结果数组对于我们copyTree例子没有用,但通常情况下可能会有用)。

希望尝试一下吗?下面的dostuff函数是“异步”的 — 它仅仅会在timeout时间片段后打印txt文本内容并将其返回。对比两种情况下的区别(是否使用parallel)。

dostuff = λ(txt, timeout) {
  sleep(timeout);
  println(txt);
  txt;
};println("## without parallel:");
time(λ(){
  dostuff("Foo", 1000);
  dostuff("Bar", 500);
  dostuff("Baz", 200);
});println("## with parallel:");
result = time(λ(){
  parallel(λ(pcall){
    pcall( λ() dostuff("Foo", 1000) );
    pcall( λ() dostuff("Bar", 500) );
    pcall( λ() dostuff("Baz", 200) );
  });
});println("## all done, result is:");
println(result);

注意到非并行版本调用的耗时是每个任务耗时的总和,也就是1700ms;并行版本的耗时粗略为最长任务的耗时(1000ms)。在所有pcall调用结束后,结果会以恰当的顺序被收集到一个数组中并返回。

copyTree的问题

如果你尝试在包含数千个文件的目录中执行,可能会惊讶碰到“EMFILE”的异常。发生这个异常是因为我们达到了文件系统允许同时打开文件数目的限制。幸运的是,仅仅通过更新原始功能函数就能非常简单地修复这个问题。下面是更新后的新版本:

PCALLS = 1000;parallel = js:raw "function(k, f){
  var n = 0, result = [], i = 0;
  f(function(){ if (i == 0) k(false) }, pcall);
  function pcall(kpcall, chunk){
    n++;
    if (PCALLS <= 0) {
      setTimeout(function(){
        n--;
        Execute(pcall, [ kpcall, chunk ]);
      }, 5);
    } else {
      PCALLS--;
      var index = i++;
      chunk(function(rchunk){
        PCALLS++;
        n--;
        result[index] = rchunk;
        if (n == 0) Execute(k, [ result ]);
      });
      kpcall(false);
    }
  }
}";

一个全局变量PCALLS保存了每次允许并行调用的最大数量。当到达了最大并行数量,我们会5ms后进行重试。我发现1000对PCALLS是一个不错的值(如果设置的太小容易饿死并可能比同步版本更慢)。

这就是所有内容了,λanguage实现的copyTree保持不变 — 仅需要在原始功能函数中节流pcall的调用,将其并行运行的数量限制在1000以内。我们的程序依然保持优雅并看起来像是串行的,但它很快,并行执行并能处理任意数量规模的目录。

修复NodeJS版本实现

我想说下面的代码是至今为止最丑陋的:

var PCALLS = 1000;function copyTree(srcdir, destdir, callback) {
  fs.mkdir(destdir, function(err){
    if (err) throw new Error(err);
    fs.readdir(srcdir, function(err, files){
      if (err) throw new Error(err);
      var count = files.length;
      function next(err) {
        PCALLS++;
        if (err) throw new Error(err);
        if (--count == 0) callback();
      }
      if (count == 0) {
        callback();
      } else {
        (function loop(i){
          if (PCALLS <= 0) {
            setTimeout(function(){
              loop(i);
            }, 5);
          } else if (i < files.length) {
            PCALLS--;
            var f = files[i];
            var fullname = path.join(srcdir, f);
            var dest = path.join(destdir, f);
            fs.lstat(fullname, function(err, stat){
              if (err) throw new Error(err);
              if (stat.isSymbolicLink()) {
                fs.readlink(fullname, function(err, fullname){
                  if (err) throw new Error(err);
                  fs.symlink(fullname, dest, next);
                });
              } else if (stat.isDirectory()) {
                copyTree(fullname, dest, next);
              } else if (stat.isFile()) {
                fs.readFile(fullname, function(err, data){
                  if (err) throw new Error(err);
                  fs.writeFile(dest, data, next);
                });
              } else {
                next();
              }
            });
            loop(i + 1);
          }
        })(0);
      }
    });
  });
}

糟透了,是不是?我们在下一节通过类似parallel函数的抽象来将其变得稍微好一点。

公平对待Node

好吧,也许我有点夸张了。我想指出的是,想避免回调地狱但也想使用异步和并行计算的唯一解决方案就是语言本身要支持显式的continuation。我并没有试图隐藏在Node中手动编写异步代码的丑陋之处,但也许我们可以做些什么。

一方面,校验错误的代码 if (err) throw new Error(err) 到处可见。为了公平起见(λanguage中它们被Node API的包装程序处理),我将丢掉它们,但如果你曾使用NodeJS写过程序,你知道它们不可能被丢掉(除非实现类似的API包装处理程序)。另一方面,我将写一个parallelEach帮助函数(下面你会看到为什么async库中的async.eachLimit不能继续使用)。

这次实现的代码看起来确实更漂亮了,可能是我们在NodeJS中能做到的最好的了;但仍然没有λanguage语言版本一样好。

function copyTree(srcdir, destdir, callback) {
  fs.mkdir(destdir, function(err){
    fs.readdir(srcdir, function(err, files){
      parallelEach(files, function(f, next){
        var fullname = path.join(srcdir, f);
        var dest = path.join(destdir, f);
        fs.lstat(fullname, function(err, stat){
          if (stat.isSymbolicLink()) {
            fs.readlink(fullname, function(err, target){
              fs.symlink(target, dest, next);
            });
          } else if (stat.isDirectory()) {
            copyTree(fullname, dest, next);
          } else if (stat.isFile()) {
            fs.readFile(fullname, function(err, data){
              fs.writeFile(dest, data, next);
            });
          } else {
            next();
          }
        });
      }, callback);
    });
  });
}

async.eachLimit不能正确工作的原因(我事实也确实试过)是因为它不允许做全局的限制。每次递归进入copyTree我们会进入另一个拥有自己局部limit限制的async.eachLimit,但这也意味着运行的任务可能潜在地增加一倍(并且它们也可以递归下去)。 我们很快就触发了“EMFILE”错误。所以,我自己重新实现了这部分代码抽象,并不十分复杂:

var PCALLS = 1000;function parallelEach(a, f, callback) {
  if (a.length == 0) {
    callback();
    return;
  }
  var count = a.length;
  (function loop(i){
    if (i < a.length) {
      if (PCALLS <= 0) {
        setTimeout(function(){
          loop(i);
        }, 5);
      } else {
        PCALLS--;
        f(a[i], function(err){
          if (err) throw new Error(err);
          PCALLS++;
          if (--count == 0)
            callback();
        });
        loop(i + 1);
      }
    }
  })(0);
}

错误处理(Error handling)

在上一小节代码中我丢掉了 if (err) throw new Error(err) 代码行。因为它禁止了错误恢复,所以这种“处理”错误的方式是无意义的。我们仅仅抛出错误,然后中断掉程序(除非你设置一个全局的捕获处理程序,但全局也意味着它将没有足够的上下文信息来很好地从错误中恢复)。

然而,如果你曾使用NodeJS认真做过一些事情,就会知道这些代码在每个回调函数中都非常必要。除此之外,除了将错误抛出,另一个好想法是将错误传给之前的回调函数(或者处理该错误,并酌情恢复)。NodeJS中的惯例是将错误error作为第一个参数,结果result作为第二个参数。所以我们需要在每个回调函数中插入这行,并插在最前面:if (err) return callback(err)。退一步说,这很粗糙。

λanguage语言中的异常

得益于显式的continuation,λanguage语言中我们可以实现支持跨异步调用的异常系统。

我们将在这里实现一个基本的系统 — 两个原始功能函数,TRY和THROW(全部大写,为了避免与JavaScript本身的try和throw关键字冲突)。下面是可能的用法:

TRY(
  λ() {
    let (data = readFile("/tmp/notexists")) {
      print("File contents:");
      print(data);
    }
  }
​
  ## "ENOENT" means file not found
  , "ENOENT", λ(info){
    print("File not found:", info);
  }
​
  ## generic handler
  , true, λ(info, code){
    print("Some error.  Code:", code, "info:", info);
  }
);

看起来很丑陋是因为我们没有给解析器扩展语法 — TRY和THROW都是普通的函数。但如果我们想要代码更优雅,当然可以修改解析器。

如果你想测试这个实例,请下载支持异常的原始功能函数库

TRY接收一个函数及一堆错误处理程序。每个错误处理程序之前必须有错误代码(所以参数数量应该总是奇数的)。如果传入true而不是一个错误代码,则对应处理程序将被用来处理前面未处理的错误。为了能实现这点,当错误发生时,需要修改原始功能函数来调用THROW(而不是JS的throw):

readFile = function(k, filename) {
  fs.readFile(filename, function(err, data){
    if (err) return Execute(THROW, [ k, err.code, filename ]);
    Execute(k, [ data ]);
  });
};

当然需要在Execute循环中来调用THROW。

你可以在浏览器中试着运行一下:

TRY(
  λ(){
    sleep(500);
    THROW("foo", "Info for foo");
    println("Not reached");
  },
  "foo", λ(info){
    println("Got foo:");
    println(info);
    "HANDLED";
  }
);

请注意TRY,会像一个普通函数一样返回一个值。在没有错误抛出时,它的结果就是函数的结果,否则就是匹配的错误处理程序的结果,所以你可以像下面例子这样写:

data = TRY(λ(){
             readFile("/foo/bar.data");
           },
           "ENOENT", λ(){
             "default data"; # in case file was not found
           });

我们系统的美妙之处就是允许我们仅在需要的地方才来关注错误,而不是每个函数都需要(像NodeJS每个函数都需要)。我的意思是就像try/catch一样。例如,copyTreeSeq函数并不需要关心错误,因为调用者可以将调用整体包裹在一个TRY中:

copied = TRY(
  λ(){ copyTreeSeq(source, destination); true },
  ## generic error handler
  true, λ(code, info){ print("Some error occurred", code, info); false }
);
## .. our program resumes from here either way.
##    `copied` will be true if the operation was successful.

原生NodeJS中为了确认操作是否成功,需要在每个回调函数前面增加一行代码 if (err) return callback(err)。通过fs API进一步封装也不能抽象优化这一点。

我们这里实现的异常系统在面对并行时将展现出不足。对并行版本copyTree的恰当支持需要更多的思考。。。

实现

下面将 TRY 和 THROW 实现为原始功能函数:

var exhandlers = [];window.THROW = function(discarded, code, info) {
  while (exhandlers.length > 0) {
    var frame = exhandlers.pop();
    for (var i = 0; i < frame.length; ++i) {
      var x = frame[i];
      if (x.code === true || x.code === code) {
        x.handler(x.continuation, info, code);
        return;
      }
    }
  }
  throw new Error('No error handler for [' + code + '] ' + info);
};window.TRY = function(k, f) {
  var frame = [];
  for (var i = 2; i < arguments.length;) {
    var x = {
      code: arguments[i++],
      handler: arguments[i++],
      continuation: k
    };
    if (typeof x.handler != 'function')
      throw new Error('Exception handler must be a function!');
    frame.push(x);
  }
  exhandlers.push(frame);
  f(function(result){
    exhandlers.pop();
    k(result);
  });
};

实现很简单且可靠因为没有对外暴露像CallCC的原始功能函数。我们通过exhandlers维护了函数执行帧的堆栈。TRY将压入新函数帧,包含了新的一组错误处理程序。THROW弹出一函数帧并执行第一个匹配的错误处理程序。它传给错误处理程序的continuation(x.continuation)是TRY中压入的某一错误处理程序的continuation — 所以THROW并不会return,而是跳转到之前设定的某一位置(就像大多数编程语言中的异常一样)。当没有更多可用的函数帧,THROW将通过抛出一个硬编码的JS错误来中断程序。

顺便提一下,这里也应该解释了为什么通常异常的代价很高,无论做了多大程度的优化,进入try块时都将做一些类似上述的事情。

结束

希望本教程值得你花费时间。
感谢任何反馈

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant