Eval,Macro,Preprocessor,Homoiconicity(上)

(旧文,整理到博客这里。写这篇文章的时候,着重讲的是语言的编译运行层面,没有过多讲syntax和parser设计层面,未来补一个下篇来完善这个层面)

这次想跟大家聊聊语言设计里面的几个概念,分别是Eval,Macro,Preprocessor和Homoiconicity。

这次想跟大家聊聊语言设计里面的几个概念,分别是Eval,Macro,Preprocessor和Homoiconicity。

首先是Eval,我们在很多语言里都可以看到它的影子,比如PythonPerlRuby还有Javascript,等等。以Ruby为例,下面是在Ruby里面使用eval的例子:

irb(main):001:0> eval "1+1"
=> 2

从上面的例子我们可以看到,eval的用处就是接受一段字符串作为代码,并且执行这个代码。就像上面这样,我们的字符串是1+1,使用eval作用在这个字符串上面,它就作为了Ruby里面的代码来执行了,然后给出结果为2

Perl和Python还有Javascript都是差不多的使用方法。我们可以想一想,似乎Java语言,C语言里面并没有eval这种功能,为什么?

我们得想一想eval是怎么实现的,把字符串作为代码,等于就是在程序Runtime的时候,这个字符串可以「编译」成代码。因此,也就是说这门语言需要具备Compile-at-runtime的能力。

C语言或者Java语言这种,是明确区分Compile Phase和Runtime Phase的。我们的C语言代码,需要被编译器先编译成汇编代码,变成一个可执行文件的。而我们使用的,是编译后的可执行文件,因此我们不可能让编译后的代码,去解析一个字符串,再把它变成汇编代码。

Java的编译过程也是和运行时互相独立的,需要把Java文件编译成class文件,class文件里面有bytecode,在JVM上面执行。

当然,Java发展到现在,也支持Compile-at-runtime,这种技术有个名字,叫做JIT,也就是Just-In-Time Compiler。有了这个能力,就允许代码在运行时被实时地编译,然后虚拟机里面的class文件在运行时被实时地加载。

但是光有Compile-at-runtime还不够,我们还得看这门语言自身的语言特性是否支持runtime时代码自身的改变。Java在设计的时候,对class之间的依赖关系,package之间的关系等等,都有很多约定,可以说Java是一门设计的比较
”严格”的语言,所以实现eval是有难度的。

因此,现在有很多语言,是架构在JVM平台上,最终也是compile成bytecode,但是设计的比Java语言更灵活一些,因此就可以实现eval的功能,比如Groovy,还有后续要讲到的Clojure。我们可以看看Groovy的eval的代码例子:

$ groovysh
Groovy Shell (2.4.7, JVM: 1.8.0_112)
Type ':help' or ':h' for help.
-------------------------------------------------------------------------------
groovy:000> Eval.me('2 * 4 + 2')
===> 10

从上面的例子,我们可以看到Groovy的Eval的使用方法。接下来我们看Ruby的eval

$ irb
irb(main):001:0> eval "1+1"
=> 2

Ruby也有自己的虚拟机和virtual machine code叫做YARV。因为Ruby也支持Compile-at-runtime,所以实现eval是没问题的。

Python,Perl和Javascript也是一样,所以我们要明白的是,如果一门语言在语言设计和虚拟机设计上支持Compile-at-runtime,那么这门语言就可以在运行时,把一串字符编译成代码并且加载执行,也就是实现了eval的功能。

这种功能固然很方便很强大,但是它有什么问题没有?

其实语言设计里面,并不存在绝对的优势,有的功能,它的优点同时也就是它的缺点,比如eval这种功能就是一个典型的例子。因为我们可以把代码封装在字符串里,然后在运行时编译并执行,那么如果这个字符串里面的代码有问题,这个问题会在运行时才发现。

因此Compile-at-runtime虽然很灵活,但是compile和runtime混在一起的话,也就意味着很多代码的问题会在运行时才能发现。而C语言或者Java,很多问题会在明确的compile这一步被发现,而不是runtime时才被发现。

最后我们要说一下,以上的讨论都是基于这一门语言有compile这一步骤而讨论的,其实还有更简单的实现,就是Interpreter。所谓Interpreter,就是指直接解析文本,然后根据文本直接执行命令,并没有把源代码转化成目标代码的过程,也没有用来运行目标代码的虚拟机。比如早期的Ruby就是使用的这种形式,提供一堆用C语言编写的API接口,然后Ruby代码就直接解析,然后调用这些接口来执行代码。

Interpreter的执行效率和功能上面都有局限,所以Ruby从1.9开始就转为使用虚拟机加自己的虚拟机YARV code的形式来执行代码,这一点和JVM平台越来越像。可以说,VM加VM code是主流趋势。

接下来来讨论Clojure的macro1,我们看看这段Clojure代码:

$ java -jar clojure-1.8.0.jar
Clojure 1.8.0
user=> (defmacro foreach [[sym coll] & body]
	`(loop [coll# ~coll]
		(when-let [[~sym & xs#] (seq coll#)]
			~@body
			(recur xs#))))
#'user/foreach

(这段代码来自于Clojure Programming这本书)

如上所示,我们使用defmacro定义了一个foreach的新的语言结构,可以试着用用看这个foreach

user=> (foreach [x [1 2 3]]
	(println x))
1
2
3

可以看到,我们等于定义了一种新的遍历list的方法,叫做foreach,接下来我们使用macroexpand命令来展开上面这个macro看看:

user=> (macroexpand `(foreach [x [1 2 3]] (println x)))
(loop* [coll__1__auto__ [1 2 3]] (clojure.core/when-let [[user/x & xs__2__auto__] (clojure.core/seq coll__1__auto__)] (clojure.core/println user/x) (recur xs__2__auto__)))

可以看到这个foreach展开成了实际的代码定义。为什么Clojure能够允许我们定义这种macro?这是由于Clojure的Homoiconicity的特性给我们带来的语言能力。Homoiconicity这个词是由两个词根组成:Homo:统一的;icon:符号。所谓Homoiconicity,就是指语言本身的数据结构就是对应的语义结构。我们可以看一下这个macro的定义:

user=> (defmacro infix
  [infixed]
  (list (second infixed) (first infixed) (last infixed)))
#'user/infix

我们定义了一个macro叫做infix,它接受一个参数infixed,然后把这个参数作为列表,把里的第二个元素放在第一位,第一个元素放在第二位,最后一个元素还是在最后一位,然后生成一个新的列表:

 (list (second infixed) (first infixed) (last infixed))

我们使用上面的macro试试看:

user=>  (infix (1 + 1))
2

得到了运算结果2。我们把上面的代码用macroexpand展开:

user=> (macroexpand `(infix (1 + 1)))
(clojure.core/+ 1 1)

可以看到实际代码被展开成了(+ 1 1)。也就是说,我们把(1 + 1)传入到infix时,(1 + 1)不光是一个简单的字符串,而是一个列表结构,而列表结构本身也是代码,只不过这个代码不马上执行,而是放到macro里面一起解析,这叫做Lazy Evaluation。此外,我们还发现,(1 + 1)除了是代码,还是列表结构,因此我们发现macro里面可以直接修改传入的这段代码:

 (list (second infixed) (first infixed) (last infixed)))

通过macro的处理,(1 + 1)就变成了(+ 1 1)。因此,代码即数据结构这种特性,虽然可能导致语言的语法结构比较原始,就像大家使用Lisp和Clojure所感受到的那样,但是灵活性变得非常强。这种处理方式,与Ruby那种把一串”字符”直接传给eval命令的方式有很大不同,因为我们串入的内容不是简单的字符串,而是代码结构本身,因此可以做的事情也更多。我们可以在一门语言里面创造另一门语言,就像上面介绍的foreach那样,我们可以定义新的控制结构。这是Homoiconicity给我们带来的好处。

那么Clojure这种Macro和C语言里面#include这种macro有什么区别呢?C语言里面的macro实际上就是一种文本替换,比如#include foo.h在编译的时候就是会把foo.h里面的内容替换到这里。

因此C语言的Preprocessor处理它的macro的时候,就是简单的文本替换,这也是开源的preprocessor,m4的工作原理。当然m4这种macro expander也支持一些条件语句来进行一定限度上的根据字串里面的一些特征进行有条件替换,但这毕竟还是把输入作为字符串,而不是结构,来进行处理,这种替换方式必定有局限性。

我们可以想一下编译原理相关知识,在Parser处理代码之前,有Tokenizer(也叫Lexer)会把代码转化成token,这种转化,就是把文本转化成结构的基本单元token。这样的话,Parser才能更好地分析语法规则,把tokens联系在一起,变成树形结构AST。因此”结构”在这里面起了很大的作用。

而Clojure里面的macro则是有效的语法结构,因此在编译的时候,并不是文本替换,而是从合法的语法结构扩展成实际的代码,实际的代码还是同样的语法结构。

因此我们使用Clojure的Macro的时候,编译器给出的错误提示都是非常准确而靠谱的。而我们编译C语言的macro的时候,有经验的程序员都知道,跟macro相关的错误非常难排错,因为给出的错误信息是文本替换后代码的错误信息,很难定位分析。

最后我们说说Haskell。Haskell并不是Homoiconic的语言,而且Haskell也不需要Macro。为什么呢?因为Haskell有自己的类型系统的设计,而且Haskell的function都是Lazy-Evaluation的。

我们使用Clojure的macro,也等于是Lazy-Evaluation。这段代码是以列表数据的形式传给我们定义的macro,foreach的,因此并不是要马上执行的代码,而是要传到macro里面以后,再把macro给expand成实际的代码后执行。

我们在clojure里面定义了一个macro,叫做infix,这个macro的功能就是把prefix的function变成infix,我们可以用用看:

user=>  (infix (1 + 1))
2

正常情况下Clojure的+是prefix的:

user=> (+ 1 1)
2

我们用macroexpand来看看我们定义的infix扩展后的样子:

user=> (macroexpand `(infix (1 + 1)))
(clojure.core/+ 1 1)

可以看到就是扩展成了(+ 1 1)。注意这个macro的实现,实际上代码即列表这个特性,就是Clojure的Homoiconicity特性的体现。

那么我们用Haskell可以实现上面的功能吗?因为Haskell本身的function是Lazy-Evaulation的,同时Haskell支持把函数当作数据传递,所以它虽然不是Homoiconic的语言,但是它完全可以实现Clojure的Macro的功能。

我们可以试试看这段Haskell代码2

Prelude> doif x y = if x then (Just y) else Nothing

我们定义了一个函数叫做doif,这个doif的第一个参数x,需要是一个if接受的Boolean Data,而y则是最后会封装在Maybe里面的数据。我们用用看这个doif

Prelude> doif True 1
Just 1
Prelude> doif False "You won't see it"
Nothing

可以看到,Haskell的设计里面,Function is First-Class。

此外,Haskell的function都是Lazy-Evaluation的。

我们使用Clojure的macro,也等于是Lazy-Evaluation,因为我们传入macro的参数即便是代码,实际上也是做为list数据来对待,而parser不会马上做语法分析。只有macro完全展开以后,才会做语法分析。因此macro的展开这一步是本身是一次parsing,展开后的代码再进行一次parsing,这两次的parsing是一样的,并没有什么不同,这和C语言那种macro进行文本替换的方式是不同的。因此我们的macro在展开时的错误就是代码本身的错误,这就让错误信息很准确,分析错误也就变得更容易。

总结一下就是,Haskell的function全部是lazy evaluated的,其次Haskell的function是可以被当作参数来传递的。有了这两点,Haskell语言本身即可以实现lisp macro所起到的”模版”的功能,因为我们可以通过定义各种函数来实现很多抽象的概念。比如下面这段Haskell代码2

Prelude> doif x y = if x then (Just y) else Nothing

上面的代码定义了一个函数叫做doif,这个函数接受两个参数xy,其中x是判断条件,而y是根据条件可能的结果。JustNothing都是Maybe类型的monad,你如果不回Haskell语言可以先不管含义。我们看看doif函数的类型定义:

Prelude> :t doif
doif :: Bool -> a -> Maybe a

可以看到doif接受的第一个参数xBool类型的数据,这个是Haskell通过if判断出来的。if需要一个Bool类型的参数做为判断。然后第二个参数的类型是a,是个类型参数,也就是说y可以是任何东西,可以是数据,也可以是函数,等等。

最后,返回结果是Maybe a,就是把a封装在Maybe里面。你不需要知道Maybe是什么,但理解它是个盒子的种类就行了。这个盒子里可以是空的,用Nothing代表,也可以装着东西,用Just ...代表,我们这里参数是y,因此可能装的就是Just y。我们用用看这个doif

Prelude> doif (1 == 2) "Yes 1 == 2 :-)"
Nothing

我们使用doif判断x,也就是1==2是否成立,如果成立,那么我们的y,也就是Yes 1 == 2 :-)将被装进Just盒子里面返回。注意Haskell允许我们把(1 == 2)这个表达式做为参数传递,因为这个表达式的执行结果会返回True或者False。

我们再看doif判断为True的使用:

Prelude> doif (1 == 1) "Yes 1 == 1"
Just "Yes 1 == 1"

注意这是Haskell的第一个特点:表达式,函数都可以作为参数传递,这叫做First-Class Function

第二个特点:作为参数的表达式或者函数不会被执行后传入函数,而是传入后执行。也就是说,因为我们调用doif,导致了doif需要第一个参数,导致了第一个参数被解析。如果参数是表达式或者函数,此时表达式才被解析和运行,这叫做Lazy Evaluation

因此Haskell本身的设计免去了macro的需求,而是用First-Class FunctionLazy Evaluation使得我们可以定义所需功能。

  1. http://www.braveclojure.com/writing-macros/ 

  2. http://newartisans.com/2009/03/hello-haskell-goodbye-lisp/  2