作者:王垠
我曾经希望设计出一种“终极语言”,然而我却发现一种语言其实是不够用的。这是为什么呢?
我们都知道,程序语言里包含了变量,数字,对象,函数等“元素”。它们就像物理学的基本粒子一样,可以用于构造我们所需要的几乎任何“模型”。既然所有的东西都是用基本粒子组成的,那么除了物理学,我们为什么还要有化学和生物?化学家使用的语言是化学元素,它们比基本粒子大很多。生物学家的语言就更大一些了,处于细胞的级别。那么为什么化学家和生物学家不使用基本粒子来描述他们的领域呢?
那是因为基本粒子无法提供足够的“抽象”。它们到底是如何组成原子,原子又如何能产生细胞,这些事情到现在还没搞清楚。用基本粒子来表示化学和生物学,那么我们恐怕要等很久很久以后才能描述化学和生物的现象和原理。
同样的,变量,数字,对象,函数等语言的要素,也是不足以表达我们需要的所有程序的。有人认为函数是这些元素的“终极粘合剂”,可是函数的结构组合能力却不是万能的。函数接受一些参数,返回一个结果。然而有些我们需要表达的概念却不是这样的结构,比如一个带有多根电线的黑匣子,它可以从任何电线输入东西,然后从剩下的电线输出。每根电线的输入和输出方式,在不同的“调用”可以随意的更改。比如,电线 A 第一次被调用是输入,第二次被调用就变成了输出。
这个黑匣子就是逻辑语言(比如 Prolog,miniKanren)的基本元素。你如何用函数来表示它呢?你不能。因为不管你怎么把函数组合起来,这个黑匣子的电线不是连接到函数的输入,就是连接到输出。所以它们总是有“固定”的方向,不能满足这种奇怪的黑匣子的工作方式。所以虽然这个元素可以用更加基本的元素(比如变量等)组成,然而函数却不能作为这里的粘合剂。
所以一旦出现了这样用现有的元素和粘合剂没法表示的东西,我们就需要为语言加入新的构造,也就是把语言扩展。这个构造必须经过一个比函数更加剧烈的转化,才能实现我们想要的功能。
第一种方式是写一个解释器,用来描述这个黑匣子的工作方式。黑匣子被作为一个普通的数据结构,输入解释器,然后我们从解释器得到它的结果。这貌似是万能的方式。
另外一种“几乎万能”的方式是使用宏(macro)。宏可以把这个黑匣子的“语言描述”(也就是AST)拆散,然后组成一个用原来的语言元素组成的结构,并且把它插入到原来的程序里面。这就相当于把黑匣子“编译”成了我们已有的语言,然后“嵌入”。比如对于逻辑语言的黑匣子,当我们在调用它的时候,宏就会知道它的输入和输出的“方向”。一旦知道了这个方向,它的行为方式就会像一个函数,所以我们就可以以此把它编译成函数。在下一个调用的地方,输入输出的方向又有不同,所以就把它编译成另外一个函数。
所以,每一个宏其实就是一个编译器。你可以用 Lisp/Scheme 的宏来实现几乎任何语言结构。这种“嵌入式语言”通常被叫做 EDSL (Embedded Domain Specific Language)。
有些其它语言也提供一些构建 EDSL 的能力,比如 Haskell, Scala 等。虽然它们有一定构建 EDSL 的能力,却不能达到 Lisp/Scheme 宏的地步。它们往往使用“重载”的方式来定义一些操作符,比如"+"号,然后把这些操作符作用于这个 EDSL 的 AST 所特有的类型,从而让操作符“自动切换”到这个 EDSL 的语义。
Haskell 有一个 EDSL 叫 Accelerate 就是这样实现的,它使用 type class 来重载操作符,用于 GPU 的操作。然而我却发现使用它的时候,有时候我必须打进一些莫名其妙的,跟我想要表达的概念毫无关系东西。这是因为重载的能力是有限的,它并不能像宏一样,可以任意的拆解和拼装整个表达式。所以 Accelerate 在很多时候需要你写一些特定的东西,这样它才能避免歧义,实现正确的重载操作。到后来,我发现这些多余的符号成为了非常碍眼的东西,让我无法直接的看到我所要表达的概念。
所以我觉得 Lisp 和 Scheme 的宏是很重要的东西。然而有一个前提:宏一定要少用,要在非常有必要的时候才定义宏。否则你的语言里就会出现很多奇怪的结构,这些结构没法用函数调用的语义来理解,这样就造成了程序员之间交流的障碍。在 Common Lisp 里面有多种很炫的 looping macro 就这样的例子,它们让 Common Lisp 的程序难以理解。
然而说一种语言是不够用的,并不是说世界上的那么多种语言都是必须存在的。实际上,它们大部分的功能都是重复的,很多设计糟糕的语言根本就没必要存在。所以我说的“不够用”不是从实用主义的角度出发,而是从原理性的角度出发的。我只是说,在特定的需求面前,你有可能需要为语言加入新的构造和语义。