聊聊 DSL(旧)

DSL,也就是“Domain Specific Language”的简称,是指为某些特定领域(domain)设计的专用语言。举个例子,Linux 系统下有很多配置文件,每个配置文件格式都不大一样,它们可以被看成是多种 DSL。IP Tables 的规则是一种 DSL,FVWM 窗口管理器的配置文件是一种 DSL,VIM 和 Emacs 的配置文件,当然也是 DSL。JSON 是 DSL。SQL 也可以被看成是数据库领域的 DSL。也有很多人在自己的工作中创造 DSL,试图用它们来解决一些实际问题。

由于我自己的原则,我个人从来没有设计过 DSL,但我用过别人设计的 DSL,并且对此深有感受,这就是为什么我今天想分享一下,我对 DSL 的看法和亲身经历。

我对 DSL 的看法

先来说说我自己对 DSL 的看法吧。简言之,我觉得大部分 DSL 都是不需要(也不应该)存在的,我们应该尽量避免设计新的 DSL。我的这一论点,不但适用于只有少量用户的产品内部 DSL,也适用于像 SQL 这样具有大量从业者的 DSL。

我发现绝大部分 DSL 的存在,都是因为设计它的人没有想清楚要解决的问题的本质,没有意识到其实不需要设计新的语言就能解决问题。很多人设计 DSL,是因为看到类似的产品里面有 DSL,所以就抄袭照搬。或者因为听说 DSL 很酷,设计出 DSL 让大家用,会显得自己很牛,很有价值。同时,设计 DSL 还可以让同事和公司对自己产生依赖性,因为有人用我的 DSL,所以公司需要我,离不开我,那么 job security 就有所保证 ;)

然而如果你仔细分析手头的问题,就会发现它们绝大部分都可以用“库代码”(library),利用已有的语言来解决。就算类似的产品里面实现了 DSL,你会发现它们绝大部分也可以用库代码来代替。在自己的工作中,我一般都首先考虑写库代码来解决问题,实在解决不了才会考虑创造 DSL。因为遵循这一原则,我从来没有在自己的职业生涯中创造过 DSL。

最强大的 DSL 实现语言

有些人喜欢吹嘘自己懂 Haskell 或者 Scala,说这两个语言有着非常强大的“DSL 实现能力”,这其实是一种误解,或者叫做宗教性的自夸。如果你跟我一样看透了各种语言,就会发现世界上最强大的 DSL 实现语言,并不是 Haskell 或者 Scala,而是 Scheme。

2012 年我参加了 POPL,程序语言界的顶级会议。在那次会议上,Scala 的 paper 简直是铺天盖地,其中很多 Scala 的人宣讲的主题,都是在给它的 DSL 实现能力打广告。听了几个这样的宣讲之后,我发现 Scala 的 DSL 机制跟 Haskell 的挺像,这两个语言其实不过是实现了类似 C++ 操作符重载,利用特殊的操作符来表达对某些特殊对象的操作,然后把这些操作符美其名曰为“DSL”。

如果你还没看明白 Haskell 的把戏,我就提醒你一下。Haskell 的所谓 type class,其实跟 Java 或者 C++ 的函数重载(overloading)本质上是一回事。只不过因为 Haskell 采用了 Hindley-Milner 类型系统,这个重载问题被复杂化,模糊化了,所以一般人看不出来, type class 跟其他语言的重载其实是一回事。等你看透了就会发现,Haskell 实现 DSL 的方式,不过是通过 type class 重载一些特殊的操作符而已。

殊不知,操作符重载能够定义出来的 DSL,其实是非常有局限性的。我用过 Haskell 实现的一个用于 GPU 计算的 DSL,名叫 Accelerate。这个“语言”用起来相当的蹩脚,它要求用户在代码里的某些特殊位置写上一些特殊的符号,因为只有这样操作符重载才能起作用,不然编译器就不会认为这是合法的 Haskell 代码。可是写上这些莫名其妙的符号之后,你就发现代码的可读性变得很差。但由于操作符重载的局限性,你必须这样做。你必须记住在什么时候应该写这些毫无意义符号,在什么时候不需要写它们。这种要求对于程序员的头脑,是一个严重的负担,没有人愿意去记住这些东西。

由于操作符重载的局限性,Haskell 和 Scala 实现的 DSL,虽然吹得很厉害,却很少有人用。

世界上最强大的 DSL 实现语言,其实非 Scheme 莫属。Scheme 的宏系统,本来就是被设计来改变和扩展 Scheme 的语义的。你可以使用宏把 Scheme 改造成几乎任意你想要的语言。这种宏系统不但可以实现 Haskell 和 Scala 的“重载型 DSL”,还能实现那些不能用重载实现的语言特性(比如能绑定变量的语句)。miniKanren 就是一个用 Scheme 宏系统实现的语言,它是一个类似 Prolog 的逻辑式语言。如果你用 Haskell 或者 Scala 来实现 miniKanren,就会发现异常的困难,就算实现出来了,你的 DSL 语法也会很难用,完全不可能跟 miniKanren 一样优雅。

我并不是在这里鼓吹 Scheme,搞宣传。正好相反,对 Scheme 的宏系统有了深入理解之后,我发现了它带来的严重问题。内行人把这个问题称为“新语言问题”(The New Language Problem)。因为在 Scheme 里实现一个新语言如此的容易,几行代码就可以写出新的语言构造,完全改变 Scheme 的语义,所以这带来了严重的问题。这个问题就是,一旦你改变了语言的语义,或者设计出新的语法结构,人们之间的交流就增加了一道障碍。使用你改造后的 Scheme 的人,必须学习一种新的语言,才能跟你进行交流。由于这个原因,你很难看懂另一个人的 Scheme 代码,因为 Scheme 程序员喜欢设计奇怪的宏,扩展语言的能力,然后使用扩展后的,你完全不理解的语言来写他的代码!

Scheme 宏系统的这个问题,引发了我对 DSL 的思考。后来我发现所谓 DSL 跟 Scheme 宏系统,存在几乎一模一样的问题。下面我详细解释一下。

DSL 带来的“新语言问题”

现在我来讲一下,盲目创造 DSL 带来的问题。很多人不明白 DSL 跟库代码的区别,拿到一个问题也不想清楚,就一意孤行开始设计 DSL,后来却发现 DSL 带来了严重的问题。由于 DSL 是一种新的语言,而不只是用已有的语言写出来新函数,所以 DSL 必须经过一个学习和理解的过程,才能被其他人理解和使用。理解语言理论的人都明白这个问题,所以他们都会尽量避免设计新的语言。

举个例子。如果你看到 foo(x, y + z) 这样的库代码,很显然这是一个函数调用,所以你知道它会先计算 y+z,得到结果之后,把它传递给 foo 函数作为参数,最后得到 foo 函数算出来的结果。可是一个 DSL 就很不一样,同样看到 foo(x, y + z),它的含义也许根本不是一个函数调用。也许 foo 在这个 DSL 里就表示 foreach 循环语句,那么 foo(x, y + z) 表示的其实是类似其它语言里的 foreach (x : y + z),其中 y 和 z 都是链表,+ 号表示连接两个链表。

这样一来,为了理解 foo(x, y + z) 是什么意义,你不能直接通过已有的,关于函数的知识,而必须阅读 DSL 设计者给你的文档。如果 DSL 设计者是有素养的语言专家,那也许还好说。然而我发现绝大部分 DSL 设计者,都没有受到过专业的训练,所以他们设计出来的语言,从一开始就存在各种让人头痛的问题。

有些 DSL 表达力太弱,所以很多时候用户发现没法表达自己的意思。每当需要用这 DSL 写代码,他们就得去请教这个语言的设计者。很多时候你必须往这个 DSL 添加新的特性,才能解决自己的问题。到后来,你就发现有人设计了个 DSL,结果到头来他自己是唯一会用这 DSL 的人。每当有人需要用一个语言,就得去麻烦它的作者,那么这个语言的存在还有什么意义?

当然,很多的 DSL 都会设计犯下程序语言设计常见的问题。比如容易出错,产生歧义,语法丑陋繁琐,难学难用,等等。很多人把设计语言想得太容易,喜欢耍新花样,到后来就因此出现各种麻烦事,最后发现还不如不要设计一个语言,使用已有的语言来解决问题。

我的 DSL 经历

在我曾经工作过的某公司,有两个很喜欢捣鼓 PL,却没有受过正规 PL 教育的人。说得不好听一点,他们就是“PL 民科”。然而正是这种民科喜欢显示自己牛逼,喜欢显示自己有能力实现新的语言,以至于真正的专家只好在旁边默默无闻 :P

他们其中一个人知道我是 PL 科班出生,开头觉得我是同类,所以总喜欢走到桌前对我说:“咱们一起设计一个通用程序语言吧!然后用它来解决我们公司现在遇到的难题!” 每当他这样说,我都安静的摇摇头:“公司真的需要一个新的语言吗?你有多少时间来设计和实现这个语言?”

当时这两个人在公司里,总是喜欢试用各种新语言,Go 语言,Scala,Rust,…… 他们都试过了。每当拿到一个新的项目,他们总是想方设法要用以前从没用过的新语言来做。于是乎,这样的历史就在我眼前反复的上演:

  1. 为一种新语言兴奋,开始用它来做新的项目
  2. 两个月之后,开始骂这语言,各种不爽
  3. 最后项目不了了之,代码全部丢进垃圾堆
  4. Goto 1

这两个家伙每天就为这些事情忙得不亦乐乎,真正留下来的产出却很少。之前他们还设计了一种 DSL,专门用于对 HTML 进行匹配和转换。这个 DSL 被他们起了一个很有科学味道的名字,叫做 NaCl(氯化钠,食盐的分子式)。

我进公司的时候,NaCl 已经存在了挺长一段时间,然而很少有人真正理解它的用法,大部分人(包括我)对它的态度都是“能不碰就不碰”。可是终于有一天,我遇到了需要修改 NaCl 代码的时候。看了一会儿 NaCl 的“官方文档”,却不知道如何才能用它提供的语法,来表达我所需要的改动。其实我需要的不过是一个很容易的匹配替换,完全可以用正则表达式来完成,可是已有的代码是 NaCl 语言写的,再加上好几层的框架,所以我不知道怎么办了。

问了挺多人,包括公司里除创造者外最顶级的“NaCl 专家”,都没能得到结果。最后,我不得不硬着头皮去打扰两位日理万机的 NaCl 创造者。叽里呱啦跟我解释说教了一通之后,眨眼之间噼里啪啦帮我改了代码,搞定了!其实我根本没听明白他在说什么,为什么那样改,也不知道背后的原理。总之,我一个字都没打目的就达到了,所以我就开开心心回去做自己的事情了。

后来跟其他同事聊,发现我的直觉是很准的。他们告诉我,所有 NaCl 代码可以表达的东西,都可以很容易的用正则表达式替换来解决,甚至可以用硬邦邦的,不带 regexp 的字符串替换来解决。同事们都很不理解,为什么非得设计个 DSL 来做这么简单的事情,本来调用 Java 的 String.replace 就可以很快的完成。

后来,那位“顶级NaCl 专家”告诉我,在设计 NaCl 的时候,他就强烈地反对制造一个 DSL 来干这件事,可是领导根本没听他在说什么。在领导的支持下,这两个家伙一意孤行创造了 NaCl,然后在全公司推广。到后来,每次需要用 NaCl 写点什么,他就发现需要给这语言增加新的功能,就得去求那两个家伙帮忙。所以我能用上今天的 NaCl,基本能表达我想要的东西,还多亏了这位“NaCl 专家”之前的奋斗和努力 ;)

我有一句格言:如果一个语言,每当用户需要用它表达任何东西,都得去麻烦它的设计者,甚至需要给这个语言增加新的功能,那这个语言就没有必要存在。NaCl 这个 DSL 正好符合了我的断言 :)

结论

所以,我对于 DSL 的结论是什么呢?

  1. 尽一切可能避免设计 DSL,因为它会带来严重的交流和学习曲线问题,可能会严重的降低团队的工作效率。
  2. 大部分时候写一些库代码,把需要的功能做成函数,或者利用已有的 DSL(比如正则表达式),其实就可以解决问题。所以请务必首先考虑使用库代码的解决方案。
  3. 如果真的到了必须设计 DSL 的时候,非 DSL 不能解决问题,才可以动手设计 DSL。但 DSL 必须由理解程序语言设计的人来完成,否则它还是可能给产品和团队带来严重的后果。
  4. 绝大部分 DSL 都可以通过采用已有语言里面的少数构造(比如数学表达式,逻辑表达式,条件语句)来实现,所以请尽量避免设计自己独特的语法。任何试图设计独特语法的 DSL,都会给团队和客户带来不必要的麻烦和困扰。