Contents

从0发现Lisp

一起来画画

假如说我们有一些画画的能力。这里使用JS来举个例子

drawPoint({x: 0, y: 1}, 'yellow')
drawLine({x: 0, y: 0}, {x: 1, y: 1}, 'blue')
drawCircle(point, radius, 'red')
rotate(shape, 90)
...

我们可以画一个点,画一条线,可以画圆,可以将图形旋转90度。

新的挑战

一般而言我们的这些能力我们都希望可以复用起来,不能开发一次就丢了,那就太浪费开发小哥哥的头发了。我们希望支持配置化,这意味着我们希望读取一些配置然后把不同的参数给不同的函数去执行,并得到一个结果。我们有个文件输入流,我们假定他叫stream

 stream.on('data',data =>{
   //TODO
 })

Eval

选择JS来举例子是因为JS可以使用eval,如果我们想偷懒的话可以直接使用eval来满足我们的需求。

 stream.on('data',data =>{
   eval(data)
 })

我们只要在配置里面写上"drawLine({x: 0, y: 0}, {x: 1, y: 1}, 'red')" 就可以画一条线了。 但是。。。只要稍微有点经验的程序员就会建议你不要这么做,因为你不知道data里面具体是什么,这么做就相当于让你的系统在配置面前裸奔,天晓得他会调用什么方法,会发生什么。

看来我们不能用eval了,但是我们还是需要解决我们的问题,我们想让用户可以自定义到底要怎么画画(毕竟要赋能业务嘛 🐶)

简单的想法

我们可以用JSON来做一个简单的配置,配置好要执行的函数,这个函数对应的参数是什么就可以了呀。假定我们设计好我们的配置格式如下:

{
  instructions: [
    { functionName: "drawLine", args: [{ x: 0, y: 0 }, { x: 1, y: 1 }, "blue"] },
  ];
}

我们拿到配置之后要做的就是将其翻译成对drawLine({x: 0, y: 0}, {x: 1, y: 1},"blue") 的调用就可以了。 感觉还是挺简单的。

 stream.on('data',instruction =>{
   const fns = {
    drawLine: drawLine,
    ...
  };
  data.instructions.forEach((ins) => fns[ins.functionName](...ins.args));
 })

easy!!

稍微简化一下

因为每条指令的开始都是函数名,然后都是参数,所以我们其实并不需要指出哪个是函数名,哪个是参数,我们可以将我们的配置简化如下:

{
  instructions: [["drawLine", { x: 0, y: 0 }, { x: 1, y: 1 }, "blue"]],
}

我们将我们的配置从一个json对象变成了一个数组。instructions 是一个包含指令数组的数组,一条指令的第一个对象一定是函数名,剩下的是函数的参数。修改一下我们的代码:

 stream.on('data',instruction =>{
   const fns = {
    drawLine: drawLine,
    ...
  };
  data.instructions.forEach(([fName, ...args]) => fns[fName](...args));
 })

好了,我们的drawLine 又可以欢快地跑起来了。

打开潘多拉的魔盒

现在为止一切都还很简单,我们还只是用drawLine 做了演示。

drawLine({x: 0, y: 0}, {x: 1, y: 1}, 'blue')
// 等价于
["drawLine", { x: 0, y: 0 }, { x: 1, y: 1 }]

假如说产品经理又有新的需求了,我们得支持图形旋转的配置。看看用代码我们怎么表达

rotate(drawLine({x: 0, y: 0}, {x: 1, y: 1}, 'blue'), 90)

我们把它翻译成配置,这行代码应该会看起来像:

["rotate", ["drawLine", { x: 0, y: 0 }, { x: 1, y: 1 }], 90]

发生什么了? rotate有一个参数,这个参数本身是一条指令,指令可以被嵌套了,要支持嵌套的指令也不复杂,只需要将我们原本的代码稍微修改一下就可以了:

stream.on('data',data =>{
   const fns = {
    drawLine: drawLine,
    ...
  };
  const parseInstruction = (ins) => {
    if (!Array.isArray(ins)) {
      //必须是原始类型,例如 { x: 0, y: 0 }
      return ins;
    }
    const [fName, ...args] = ins;
    return fns[fName](...args.map(parseInstruction));
  };
  data.instructions.forEach(parseInstruction);
 })

仔细看看我们的代码,我们加了一个函数parseInstruction ,这个函数会递归地解析指令,现在我们可以处理

["rotate", ["rotate", ["drawLine", { x: 0, y: 0 }, { x: 1, y: 1 }], 90] , 90]

厉害了,嵌套的配置我们也可以支持了。

再简化一下吧

我们再看看我们的JSON配置。

{
  instructions: [["drawLine", { x: 0, y: 0 }, { x: 1, y: 1 }]],
}

我们的配置只包含了指令,我们真的需要放一个叫做instructions 的key在这里么 如果我们改成

["do", ["drawLine", { x: 0, y: 0 }, { x: 1, y: 1 }]]

不在使用instructions ,取而代之的是一个顶层的命令,叫做“do”。do的作用就是执行do拿到的所有指令。我们又可以简化我们的代码了。

stream.on('data',data =>{
  const fns = {
    ...
    do: (...args) => args[args.length - 1],
  };
  const parseInstruction = (ins) => {
    if (!Array.isArray(ins)) {
      return ins;
    }
    const [fName, ...args] = ins;
    return fns[fName](...args.map(parseInstruction));
  };
  parseInstruction(instruction);
 })

我们只是在fns里面加了一下do的实现。现在我们可以支持这种命令了。

[
  "do",
  ["drawPoint", { x: 0, y: 0 }],
  ["rotate", ["drawLine", { x: 0, y: 0 }, { x: 1, y: 1 }], 90]],
];

加一点魔法

我们要怎么把创造力进一步赋予我们的用户呢?不如让我们支持一下定义变量吧。

const shape = drawLine({x: 0, y: 0}, {x: 1, y: 1}, 'red')
rotate(shape, 90)

上面这两行代码先画了一条线,然后将其旋转90度。如果翻译成配置大概可以是这样的:

["def", "shape", ["drawLine", { x: 0, y: 0 }, { x: 1, y: 1 }]]
["rotate", "shape", 90]

这里我们增加了def指令,def会新增一个变量,此处叫做shape。来看看我们怎么实现吧

stream.on('data',data =>{
 const variables = {};
  const fns = {
    ...
    def: (name, v) => {
      variables[name] = v;
    },
  };
  const parseInstruction = (ins) => {
    if (variables[ins]) {
      return variables[ins];
    }
    if (!Array.isArray(ins)) {
      return ins;
    }
    const [fName, ...args] = ins;
    return fns[fName](...args.map(parseInstruction));
  };
  parseInstruction(instruction);
 })

创造能力又被极大地释放了。def其实是一个内置的函数,他做的仅仅是将变量的名字和变量对应的值存起来。 现在我们有了def可以用来自定义变量了。

[
  "do",
  ["def", "shape", ["drawLine", { x: 0, y: 0 }, { x: 1, y: 1 }]],
  ["rotate", "shape", 90],
];

看上去非常不错。

本配置具有超级牛力

我们已经支持用户自定义变量了,如果我们可以让用户自定义函数会怎么样呢?我们来举个例子吧:假如我们想要实现一个画三角形的函数。

const drawTriangle = function(left, top, right, color) { 
   drawLine(left, top, color);
   drawLine(top, right, color); 
   drawLine(left, right, color); 
} 
drawTriangle(...)

跟随我们的直觉,如果我们吧这些定义做成配置,这大概会是这样的:

["def", "drawTriangle",
  ["fn", ["left", "top", "right", "color"],
    ["do",
      ["drawLine", "left", "top", "color"],
      ["drawLine", "top", "right", "color"],
      ["drawLine", "left", "right", "color"],
    ],
  ],
],
["drawTriangle", { x: 0, y: 0 }, { x: 3, y: 3 }, { x: 6, y: 0 }, "blue"]

如果我们可以完成这个翻译工作我们就可让用户自定义函数了。

const drawTriangle

对应的是

["def", "drawTriangle",...]

然后

function(left, top, right, color) {...}

对应的是

["fn", ["left", "top", "right", "color"], ["do" ...]]

这里的关键点是怎么实现 fn 。来看看这段代码

const parseFnInstruction = (args, body, oldVariables) => {
  return (...values) => {
    const newVariables = {
      ...oldVariables,
      ...mapArgsWithValues(args, values),
    };
    return parseInstruction(body, newVariables);
  };
};

当我们遇到 fn 的时候我们使用 parseFnInstruction 来返回一个新的js函数。当我们执行 drawTriangle 的时候

["drawTriangle", { x: 0, y: 0 }, { x: 3, y: 3 }, { x: 6, y: 0 }, "blue"]

函数里面的 values 就会变成

[{ x: 0, y: 0 }, { x: 3, y: 3 }, { x: 6, y: 0 }, "blue"]

然后

const newVariables = {...oldVariables, ...mapArgsWithValues(args, values)}

也就是说我们创建了一个新的 variables 对象,这里面包含了形参和实参的对应关系。放到这个例子里面

const newVariables = {
  ...oldVariables,
  left: { x: 0, y: 0 }, 
  top: { x: 3, y: 3 },
  right: {x: 6, y: 0 }, 
  color: "blue", 
}

接下来我们会执行函数的 body ,这里是

		["do",
      ["drawLine", "left", "top", "color"],
      ["drawLine", "top", "right", "color"],
      ["drawLine", "left", "right", "color"],
    ]

然后我们会执行 parseInstruction ,body就是上面的,新的newVariables也如上所示,body里面的 left 对应的就是  { x: 0, y: 0 } 。所以我们来实现我们的parseInstruction 吧。

const parseInstruction = (ins, variables) => {
    ...
    return fn(...args.map((arg) => parseInstruction(arg, variables)));
  };
  parseInstruction(instruction, variables);

我们需要检查一下我们拿到的指令是不是 fn .

const parseInstruction = (ins, variables) => {
    ...
    const [fName, ...args] = ins;
    if (fName == "fn") {
      return parseFnInstruction(...args, variables);
    }
    ...
    return fn(...args.map((arg) => parseInstruction(arg, variables)));
  };
  parseInstruction(instruction, variables);

然后是我们的 parseFnInstruction  :

const mapArgsWithValues = (args, values) => { 
  return args.reduce((res, k, idx) => {
    res[k] = values[idx];
    return res;
  }, {});
}
const parseFnInstruction = (args, body, oldVariables) => {
  return (...values) => {
    const newVariables = {...oldVariables, ...mapArgsWithValues(args, values)}
    return parseInstruction(body, newVariables);
  };
};

这里做了两件事情:

  1. 做了参数的绑定
  2. 在新的参数绑定下解析我们的body

最后一点点:

const parseInstruction = (ins, variables) => {
    ...
    const [fName, ...args] = ins;
    if (fName == "fn") {
      return parseFnInstruction(...args, variables);
    }
    const fn = fns[fName] || variables[fName];
    return fn(...args.map((arg) => parseInstruction(arg, variables)));

因为fn现在可以是函数也可以是变量,所以我们都要检查一下 const fn = fns[fName] || variables[fName]; OK,我们看看完整的代码是什么样子的。

stream.on('data',data =>{
 const variables = {};
  const fns = {
    drawLine: drawLine,
    drawPoint: drawPoint,
    rotate: rotate,
    do: (...args) => args[args.length - 1],
    def: (name, v) => {
      variables[name] = v;
    },
  };
  const mapArgsWithValues = (args, values) => {
    return args.reduce((res, k, idx) => {
      res[k] = values[idx];
      return res;
    }, {});
  };
  const parseFnInstruction = (args, body, oldVariables) => {
    return (...values) => {
      const newVariables = {
        ...oldVariables,
        ...mapArgsWithValues(args, values),
      };
      return parseInstruction(body, newVariables);
    };
  };
  const parseInstruction = (ins, variables) => {
    if (variables[ins]) {
      return variables[ins];
    }
    if (!Array.isArray(ins)) {
      return ins;
    }
    const [fName, ...args] = ins;
    if (fName == "fn") {
      return parseFnInstruction(...args, variables);
    }
    const fn = fns[fName] || variables[fName];
    return fn(...args.map((arg) => parseInstruction(arg, variables)));
  };
  parseInstruction(instruction, variables);
  });

我们就可以支持用户自定义的函数啦。

[
  "do",
  [
    "def",
    "drawTriangle",
    [
      "fn",
      ["left", "top", "right", "color"],
      [
        "do",
        ["drawLine", "left", "top", "color"],
        ["drawLine", "top", "right", "color"],
        ["drawLine", "left", "right", "color"],
      ],
    ],
  ],
  ["drawTriangle", { x: 0, y: 0 }, { x: 3, y: 3 }, { x: 6, y: 0 }, "blue"],
  ["drawTriangle", { x: 6, y: 6 }, { x: 10, y: 10 }, { x: 6, y: 16 }, "purple"],
])

我们可以构造函数,可以定义变量甚至可以创造我们自己的函数。我们真的只是做了一个配置么?我们似乎用json创造了一门编程语言。 试试看吧,画一个三角形

画一个开心的小人

到底发生了什么?

我们自定义了我们自己的配置能力,这里面其实都是数组,没有任何特殊的元素,我们可以不叫 do 我们可以不叫 def 所有的这些东西我们都可以自己定义。我们甚至可以加入更多的内置函数。比如你觉得js没有unless,我们可以在我们的配置中支持一下unless,这都非常简单。我们的配置是非常简单且结构化的,某种程度上而言这就是一个树状结构。 我们获得了以下能力

  • 自定义任何东西
  • 代码其实就是配置
  • 结构化编辑能力

我们到底发现了什么? 下面是迄今为止我们支持的最复杂的配置

[
  "do",
  [
    "def",
    "drawTriangle",
    [
      "fn",
      ["left", "top", "right", "color"],
      [
        "do",
        ["drawLine", "left", "top", "color"],
        ["drawLine", "top", "right", "color"],
        ["drawLine", "left", "right", "color"],
      ],
    ],
  ],
  ["drawTriangle", { x: 0, y: 0 }, { x: 3, y: 3 }, { x: 6, y: 0 }, "blue"],
  ["drawTriangle", { x: 6, y: 6 }, { x: 10, y: 10 }, { x: 6, y: 16 }, "purple"],
])

我们来转化一下

(do 
  (def draw-triangle (fn [left top right color]
                       (draw-line left top color)
                       (draw-line top right color)
                       (draw-line left right color)))
  (draw-triangle {:x 0 :y 0} {:x 3 :y 3} {:x 6 :y 0} "blue")
  (draw-triangle {:x 6 :y 6} {:x 10 :y 10} {:x 6 :y 16} "purple"))

是不是对于眼睛的压力小多了,其实他们是等价的。

翻译自:https://stopa.io/post/265