gitbook/编译原理之美/docs/168124.md
2022-09-03 22:05:03 +08:00

198 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 38 | 元编程:一边写程序,一边写语言
今天,我再带你讨论一个很有趣的话题:元编程。把这个话题放在这一篇的压轴位置,也暗示了这个话题的重要性。
我估计很多同学会觉得元编程(Meta Programming)很神秘。编程,你不陌生,但什么是元编程呢?
**元编程是这样一种技术:**你可以让计算机程序来操纵程序,也就是说,用程序修改或生成程序。另一种说法是,具有元编程能力的语言,能够把程序当做数据来处理,从而让程序产生程序。
而元编程也有传统编程所不具备的好处:比如,可以用更简单的编码来实现某个功能,以及可以按需产生、完成某个功能的代码,从而让系统更有灵活性。
**某种意义上,**元编程让程序员拥有了语言设计者的一些权力。是不是很酷?你甚至可以说,普通程序员自己写程序,文艺程序员让程序写程序。
那么本节课,我会带你通过实际的例子,详细地来理解什么是元编程,然后探讨带有元编程能力的语言的特性,以及与编译技术的关系。通过这样的讨论,我希望你能理解元编程的思维,并利用编译技术和元编程的思维,提升自己的编程水平。
## 从Lisp语言了解元编程
说起元编程,追溯源头,应该追到Lisp语言。这门语言其实没有复杂的语法结构,仅有的语法结构就是一个个函数嵌套的调用,就像下面的表达式,其中“+”和“\*”也是函数,并不是其他语言中的操作符:
```
(+ 2 (* 3 5)) //对2和3求和,这里+是一个函数,并不是操作符
```
你会发现,如果解析Lisp语言形成AST,是特别简单的事情,基本上括号嵌套的结构,就是AST的树状结构(其实,你让Antlr打印输出AST的时候,它缺省就是按照Lisp的格式输出的,括号嵌套括号)。这也是Lisp容易支持元编程的根本原因,你实际上可以通过程序来生成,或修改AST。
我采用了Common Lisp的一个实现,叫做SBCL。在macOS下,你可以用“brew install sbcl”来安装它;而在Windows平台,你需要到sbcl.org去下载安装。在命令行输入sbcl,就可以进入它的REPL,你可以试着输入刚才的代码运行一下。
在Lisp中,你可以把(+ 2 (\* 3 5))看做一段代码,也可以看做是一个列表数据。所以,你可以生成这样一组数据,然后作为代码执行。**这就是Lisp的宏功能。**
我们通过一个例子来看一下,宏跟普通的函数有什么不同。下面两段代码分别是用Java和Common Lisp写的,都是求一组数据的最大值。
Java版本:
```
public static int max(int[] numbers) {
int rtn = numbers[0];
for (int i = 1;i < numbers.length; i++){
if (numbers[i] > rtn)
rtn = numbers[i];
}
return rtn;
}
```
Common Lisp版本:
```
(defun mymax1 (list)
(let ((rtn (first list))) ;让rtn等于list的第一个元素
(do ((i 1 (1+ i))) ;做一个循环,让i从1开始,每次加1
((>= i (length list)) rtn) ;循环终止条件:i>=list的长度
(when (> (nth i list) rtn) ;如果list的第i个元素 > rtn
(setf rtn (nth i list)))))) ;让rtn等于list的第i个元素
```
那么,如果写一个函数,去求一组数据的最小值,你该怎么做呢?采用普通的编程方法,你会重写一个函数,里面大部分代码都跟求最大值的代码一样,只是把其中的一个“>”改为"<"。
这样的话,代码佷冗余。那么,能不能实现代码复用呢?这一点,用普通的编程方法是做不到的,你需要利用元编程技术。我们用Lisp的宏来实现一下:
```
(defmacro maxmin(list pred)
`(let ((rtn (first ,list)))
(do ((i 1 (1+ i)))
((>= i (length ,list)) rtn)
(when (,pred (nth i ,list) rtn)
(setf rtn (nth i ,list))))))
(defun mymax2 (list)
(maxmin list >))
(defun mymin2 (list)
(maxmin list <))
```
在宏中,到底使用“>” 还是使用“<”,是可以作为参数传入的。你可以看一下函数mymax2和mymin2的定义。这样,宏展开后,就形成了不同的代码。你可以敲入下面的命令,显示一下宏展开后的效果(跟我们前面定义的mymax1函数是完全一样的)。
```
(macroexpand-1 '(maxmin list >))
```
在Lisp运行时,会先进行宏展开,然后再编译或解释执行所生成的代码。通过这个例子,你是否理解了“用程序写程序”的含义呢?这种元编程技术用好了以后,会让代码特别精简,产生很多神奇的效果。
初步了解了元编程的含义之后,你可能会问,我们毕竟不熟悉Lisp语言,目前那些常见的语言有没有元编程机制呢?我们又该如何加以利用呢?
## 不同语言的元编程机制
首先,我们回到元编程的定义上来。比较狭义的定义认为,一门语言要像Lisp那样,要能够把程序当做数据来操作,这样才算是具备元编程的能力。
但是,你学过编译原理就知道,在CPU眼里,程序本来就是数据。
我们在[34讲](https://time.geekbang.org/column/article/164017),曾经直接把二进制机器码放到内存,然后作为函数调用执行。有一位同学在评论区留言说,这看上去就是把程序当数据处理。在[32讲](https://time.geekbang.org/column/article/161944)中,我们也曾生成字节码,并动态加载进JVM中运行。这也是把程序当数据处理。
实际上,整个课程,都是在把程序当做数据来处理。你先把文本形式的代码变成Token,再变成AST,然后是IR,最后是汇编代码和机器代码。所以,有的研究者认为,编写编译器、汇编器、解释器、链接器、加载器、调试器,实际上都是在做元编程的工作,你可以参考一下[这篇文章](https://cs.lmu.edu/~ray/notes/metaprogramming/)。
**从这里,你应该得到一个启示:**学习汇编技术以后,你应该有更强的自信,去发掘你所采用的语言的元编程能力,从而去实现一些高级的功能。
当然了,通常我们说某个语言的元编程能力,要求并不高,没必要都去实现一个编译器(当然,如果必须要实现,你还是能做到的),而是利用语言本身的特性来操纵程序。**这又分为两个级别:**
* 如果一门语言写的程序,能够报告它自身的信息,这叫做自省(introspection)。
* 如果能够再进一步,操纵它自身,那就更高级一些,这叫做反射(reflection)。
那么你常见的语言,都具备哪些元编程能力呢?
**1\. JavaScript**
从代码的可操纵性来看,JavaScript是很灵活的,可以给高水平的程序员,留下充分发挥的空间。JavaScript的对象就跟一个字典差不多,你可以随时给它添加或修改某个属性,你也可以通过拼接字符串,形成一段JavaScript代码,然后再用eval()解释执行。JavaScript还提供了一个Reflect对象,帮你更方便地操纵对象。
实际上,JavaScript被认为是继承了Lisp衣钵的几门语言之一,因为JavaScript的对象确实就是个可以随意修改的数据结构。这也难怪有人用JavaScript,实现了很多优秀的框架,比如React和Vue。
**2\. Java**
从元编程的定义来看,Java的反射机制就算是一种元编程机制。你可以查询一个对象的属性和方法,你也可以用程序按需生成对象和方法来处理某些问题。
我们[32讲](https://time.geekbang.org/column/article/161944)中的字节码生成技术,也是Java可以采用的元编程技术。你再配合上注解机制或者配置文件,就能实现类似Spring的功能。可以说,Spring是采用了元编程技术的典范。
**3\. Clojure**
Clojure语言是在JVM上,运行的一个现代版本的Lisp语言,所以它也继承了Lisp的元编程机制。
**4\. Ruby**
喜欢Ruby语言的人很多,一个重要原因在于Ruby的元编程能力。而Ruby也声称自己继承了Lisp语言的精髓。其实,它的元编程能力表现在,能够在运行时,随时修改对象的属性和方法。虽然实现方式不一样,但原理和JavaScript其实是很像的。
元编程技术使Ruby语言能够以很简单的方式快速实现功能,但因为Ruby过于动态,所以编译优化比较困难,性能比较差。Twitter最早是基于Ruby写的,但后来由于性能原因改成了Java。同样是动态性很强的语言,JavaScript在浏览器里使用普遍,厂商们做了大量的投入进行优化,因此,JavaScript在大部分情况下的性能,比Ruby高很多,有的[测试用例](https://benchmarksgame-team.pages.debian.net/benchmarksgame/fastest/node-yarv.html)会高50倍以上。所以近几年,Ruby的流行度在下降。**这也侧面说明了编译器后端技术的重要性。**
**5\. C++语言**
C++语言也有元编程功能,最主要的就是模板(Template)技术。
C++标准库里的很多工具,都是用模板技术来写的,这部分功能叫做STL(Standard Template Library),其中常用的是vector、map、set这些集合类。**它们的特点是,**都能保存各种类型的数据。
看上去像是Java的泛型,如vector< T >,但C++和Java的实现机制是非常不同的。我们在[35讲](https://time.geekbang.org/column/article/165078)中曾经提到Java的泛型,指出Java的泛型只是做了类型检查,实际上保存的都是Object对象的引用,List< Integer >和List< String >对应的字节码是相同的。
C++的模板则不一样。它像Lisp的宏一样,能够在编译期展开,生成C++代码再编译。vector< double >和vector< long >所生成的源代码是不同的,编译后的目标代码,当然也是不同的。
常见语言的元编程特性,你现在已经有所了解了。但是,关于是否应该用元编程的方法写程序,以及如何利用元编程方法,却存在一些争议。
## 是否该使用元编程技术?
我们看到,很多支持元编程技术的语言,都声称继承了Lisp的设计思想。Lisp语言也一致被认为是编程高手应该去使用的语言。可有一个悖论是,Lisp语言至今也还很小众。
Lisp语言的倡导者之一,Paul Graham,在互联网发展的早期,曾经用Lisp编写了一个互联网软件Viaweb,后来被Yahoo收购。但Yahoo收购以后,就用C++重新改写了。**问题是:**如果Lisp这么优秀,为什么会被替换掉呢?
**所以,一方面,**Lisp受到很多极客的推崇,比如自由软件的领袖Richard Stallman就是Lisp的倡导者,他写的Emacs编辑器就采用了Lisp来自动实现很多功能。
**另一方面,**Lisp却没有成为被大多数程序员所接受的语言。这该怎么解释呢?难道普通程序员不聪明,以至于没有办法掌握宏?进一步说,我们应该怎样看待元编程这种酷炫的技术呢?该不该用Lisp的宏那样的机制来编程呢?
程序员的圈子里,争论这个问题,争论了很多年。**我比较赞同的一个看法是这样的:**首先,像Lisp宏这样的元编程是很有用的,你可以用宏写出非常巧妙的库和框架,来给普通的程序员来用。但一个人写的宏对另外的人来说,确实是比较难懂、难维护的。从软件开发管理的角度看,难以维护的宏不是好事情。
**所以,我的结论是:**
首先,元编程还是比较高级的程序员的工作,就像比较高级的程序员才能写编译器一样。元编程其实比写编译器简单,但还是比一般的编程要难。
第二,如果你要用到元编程技术,最好所提供的软件是容易学习、维护良好的,就像React、Vue和Spring那样。这样,其他程序员只需要使用就行了,不必承担维护的职责。
**其实,我们学编译技术也是一样的。**你不能指望公司或者项目组的每个人,都用编译技术写一个DSL或者写一个工具。毕竟维护这样的代码有一定的门槛,使用这些工具的人也有一定的学习成本。我曾经看到社区里有工程师抱怨,某国外大的互联网公司里面DSL泛滥,新加入的成员学习成本很高。所以,一个DSL也好、一套类库也好,必须提供的价值远远大于学习成本,才能被广泛接受。
为了降低使用者的学习成本,框架、工具的接口设计应该非常友好。**怎样才算是友好呢?**我们可以借鉴特定领域语言(DSL)的思路。
## 发明自己的特定领域语言(DSL)
框架和工具的设计者,为了解决某一个特定领域的问题,需要仔细设计自己的接口。好的接口设计是对领域问题的抽象,并通过这种抽象屏蔽了底层的技术细节。这跟上一讲我们提到语言设计的抽象原则是一样的。这样的面向领域的、设计良好的接口,很多情况下都表现为DSL,例如React的JSX、Spring的配置文件或注解。
DSL既然叫做语言,那么就应该具备语言设计的特征:通过简单的上层语义,屏蔽下层的技术细节,降低认知成本。
我很早以前就在BPM领域工作。像JBPM这样的开源软件,都提供了一个定义流程的模板,也就是DSL。**这种DSL的优点是:**你只需要了解与业务流程这个领域有关的知识,就可以定义一个流程,不需要知道流程实现的细节,学习成本很低。
[15讲](https://time.geekbang.org/column/article/136557)的报表工具的例子,也提供了一个报表模板的参考设计,这也是一个DSL。使用这个DSL的人也不需要了解报表实现的细节,也是符合抽象原则的。
**我们在日常工作中,还会发现很多这样的需求。**你会想,如果有一门专门干这个事情的DSL就好了。比如,前两年我参与过一个儿童教育项目,教师需要一些带有动画的课件。如果要让一个卡通人物动起来,动画设计人员需要做很多繁琐的工作。当时就想,如果有一个语言,能够驱动这些卡通人物,让它做什么动作就做什么动作,屏蔽底层的技术复杂性,那么那些老师们就可以自己做动画了,充分发挥自己的创造力,而不需要求助于专门的技术人员。
当然,要实现这种DSL,有时候可以借助语言自带的元编程能力,就像React用JavaScript就能实现自己的DSL。但如果DSL的难度比较高,那还是要实现一个编译器,这可能就是终极的元编程技能了吧!
## 课程小结
本节课,我带你了解了元编程这个话题,并把它跟编译原理联系在一起,做了一些讨论。学习编译原理的人,某种意义上都是语言的设计者。而元编程,也是让程序员具有语言设计者的能力。所以,你可以利用自己关于编译的知识,来深入掌握自己所采用的语言的元编程能力。
**我希望你能记住几个要点:**
* 元编程是指用程序操纵程序的能力,也就是用程序修改或者生成程序。也有人用另外的表述方式,认为具有元编程能力的语言,能够把程序当做数据来处理,典型的代表是Lisp语言。
* 编译技术的本质就是把程序当做数据处理,所以你可以用编译技术的视角考察各种语言是如何实现元编程的。
* 采用元编程技术,要保证所实现的软件是容易学习、维护良好的。
* 好的DSL能够抽象出领域的特点,不需要使用者关心下层的技术细节。DSL可以用元编程技术实现,也可以用我们本课程的编译技术实现。
## 一课一思
你之前了解过元编程技术吗?你曾经用元编程技术解决过什么问题呢?欢迎在留言区分享。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。