Scheme宏指南

元气小坏坏 提交于 2019-12-23 06:58:12

【推荐】2019 Java 开发者跳槽指南.pdf(吐血整理) >>>

摘要:Scheme是LisP系语言中十分重要的一个方言(Dialect),在元编程、符号计算、系统校验等领域有着举足轻重的作用。而作为LisP系语言中最强大的部分,宏在中文社区中缺少相关的介绍与入门指南。本文参考r6rs标准、Chez Scheme的实现和The Scheme Programming Language,给出一份简洁扼要的说明。本文指出,宏本质上是在描述宏转换器并支持展开器对代码进行脱糖。相信读者读完本文即可茅塞顿开,并且充分意识到一些知名人士早年相关论述的错误与辉煌。 关键词:Scheme,宏,展开器,宏转换器,估值期,脱糖

一、LisP系语言的运行 包括Scheme在内的LisP系语言的编译器以略微不同的体系重构了编程语言的运行过程。不同于略懂一些编译原理的读者所意识到的语法树(Syntax Tree)、中间表示(Intermediate Representation)、编译期和运行期,LisP系语言采用估值期(Evaluation)、编译期和运行期这样的划分方式。 首先,由于LisP系语言采用S-expression,或者说树的括号表示,从C、Java那样的代码转换到语法树的过程被省略了。然后,语法树上所有的标识符(Identifier)被估值(Evaluate)。一个或者数个(Nanopass框架意义下的)展开器(Expander)将会在相应的文法作用域(Lexical Scopse,或者说当前节点的父节点范围内),将标识符展开为一些核心形式(Core Form)的组合(也就是De-sugar,脱糖)。最后,这些核心形式都可以通过固定的方式转换为可执行机器语言。 本文要讨论的是如何理解Scheme r6rs范畴内的宏:第二节将讨论展开器和宏转换器的关系;第三节将讨论r6rs标准下如何定义宏转换器,以及各种定义方式的区别;第四节将介绍Nanopass框架如何大大加速了Chez Scheme的运行速度。 二、展开器与宏转换器 在LisP系语言中,展开器要确定自己是否遇到了一个宏,就要看当前处理的S-expression的头部标识符是否是一个宏关键字(Keyword)。这个关键字必须来源于词法上下文,也就是当前节点的父辈节点。一旦找到这个关键字,也就找到了对应的宏转换器——这句话有两层含义: 1、宏转换器来源于程序员编写的宏代码; 2、宏代码运行时返回的结果就是宏转换器。 有此可以得到一个有趣的对比:C为代表的编程语言中,宏的处理工作是由编译器在中间表示形成之前完成的;而LisP系语言中,即使代码已经开始运行,也可以根据宏自动的生成新代码。也就是说,宏转换器和展开器是代码生成-运行的两端,如同衔尾蛇。这也就部分解释了LisP语言社区某种神秘的图腾崇拜。 宏转换器的代码生成方式涉及如下几个概念: 1、模式(Pattern):就像调用函数的时候要传入对应的参过几代人的迭代。这个东西可以说是现代编译器的峰顶,可以快速设计出一个编译器,而且因为拆分成数一样,宏转换器也需要声明自己能够接受的特定的模式。同时,类似于函数重载,同一个宏转换器也可以声明多组模式。 2、模板(Template):如同多数读者所了解的那样,宏这个术语在计算机科学里指的是用一些特定代码替换掉匹配了特定模式的代码。在r6rs Scheme中,宏输出的代码通过模板指定,而且这种模板显著强于C的宏模板。 3、匹配(Matching):C或者Java中可以用同一个函数名声明有限数量的不同的参数形式(Parameter Form)。对于LisP来说,则完全可以根据参数内容的变化选择是否从模式中匹配某个模板,继而生成新代码。 4、卫生(Hygienic):对于r6rs标准下的Scheme来说,宏中所使用的identifier会被限定词法上下文——也就是说,宏中的标识符可以选择绑定范围。如果绑定于宏声明时的语法上下文,我们就说这个宏是卫生宏。 读完上述内容,读者应当思考:宏转换器以什么样的方式生存在运行期;展开器向转换器传递信息的数据结构如何定义;宏转换器如何将新生成的代码交给转换器继续执行。这些内容将在下个章节讨论。 三、基础宏定义 首先要明确,在r6rs Scheme下,所有的宏定义的返回值都是Syntax类型的,具体数据结构由编译器实现。因此,在由于Scheme本身是尾递归式调用,可以将程序切分为若干个简单的pass。这一方面 框架的Chez Scheme Chez Scheme下可以说:宏的声明实际上是在展开器上挂载关键字并关联宏转化器的过程。 r6rs Scheme下的宏定义有两种主要形式: 1、syntax-rule 语法:(syntax-rules (literal ...) clause ...) 用法:Nanopass和Chez Scheme

literal均为标识符,作用是在模式中占位并匹配同名标识符。类似于C语言中的保留字如else、break等。 clause必须符合如下这样的形式:(pattern template) ,这两个也就是前文所说的模式和模板。具体的模板的模式本文不做过多介绍,仅仅罗列一些要点和例子。 (define-syntax or (syntax-rules () (() #f) (( e) e) ((_ e1 e2 e3 ...) (let ((t e1)) (if t t (or e2 e3 ...)))))) 在这个例子中,声明了一个宏关键字or到对应的展开器,并为这个关键字关联了一个自定义的宏转换器。其中,第三行到最后是三个clause。下划线_是一个占位符,默认匹配。...为三个英文句号,用于匹配list中剩余的标识符。其中要注意的是第三个clause中出现了宏的尾递归(Tail Recursion)。 (define-syntax cond (syntax-rules (else) ((_ (else e1 e2 ...)) (begin e1 e2 ...)) ((_ (e0 e1 e2 ...)) (if e0 (begin e1 e2 ...))) ((_ (e0 e1 e2 ...) c1 c2 ...) (if e0 (begin e1 e2 ...) (cond c1 c2 ...))))) 这个例子用来说明literal的用法:else并不匹配任何值,只是作为模式中的一种定位符,起到辅助匹配模式的作用。Nanopass和Chez Scheme

2、syntax-case 语法:(syntax-case exp (literal ...) clause ...) 用法: literal仍然为标识符,作用同syntax-rules。 exp的作用指导转换器对展开器传过来的Syntax做预处理。具体理解看下文例子。 clause和syntax-rules中的定义有所不同,包括两种形式:(pattern output-expression)和(pattern fender output-expression)。具体理解看下文例子。 (define-syntax syntax-rules (lambda (x) (syntax-case x () ((_ (i ...) ((keyword . pattern) template) ...) (syntax (lambda (x) (syntax-case x (i ...) ((dummy . pattern) (syntax template)) ...))))))) 这是syntax-rules在rnrs库中的实现代码。可以看到 1)syntax-case被用一个lambda包裹(Wrap)起来,这从侧面说明了宏转换器实际上是作为函数(LisP语境下被称作过程)活在运行期。 2)lambda的传入参数x,作为展开器和转换器交换信息的数据结构,如果用(display x)输出,会发现是一个Syntax对象。这就解释了展开器向转换器移交的数据结构。 3)对于syntax-case后方的x,可以定义一个类似于(car x)之类的list用于修改展开器移交的Syntax对象,方便匹配后面的clause。 4)(syntax template)组成了output-expression,这里的template和上文syntax-rules中定义的template有共同的形式。作为宏转换器的返回值,这里说明宏转换器向展开器移交的数据结构同样是Syntax对象。 5)dummy仅仅用来占位,因为所在位置往往被忽略。 最后关于fender,在模式匹配后将用于估值,如果该fender返回#t则返回相应的clause执行结构。 此外,在一些特别的情况中我们可能想要让宏变得不那么“卫生”。这个时候就需要注意两个过程syntax->datum、datum->syntax和syntax->datum: 1、syntax->datum 语法:(syntax->datum syntaxObj) 用法: 将Syntax对象转换为Datum对象。这也展示了LisP系语言“代码就是数据”“数据就是代码”的本质。例如: (define-syntax symbolic-identifier=? (lambda (x) (let ((syn (syntax->datum x))) (if (eq? (cadr syn) (caddr syn)) (syntax #t) (syntax #f))))) 这段代码可以判断两个变量的名字是否相同——这也契合了本文上一个章节关于展开器和转换器交互运行的事实。 2、datum->syntax 语法:(datum->syntax template-identifier obj) 用法:将Datum对象转换为Syntax对象,这个对象跟template-identifier具有同样的上下文。在宏中这很有意义,例如 (define-syntax loop (lambda (x) (syntax-case x () ((k e ...) (with-syntax ((break (datum->syntax (syntax k) 'break))) (syntax (call/cc (lambda (break) (let f () e ... (f)))))))))) 如果不用datum-syntax,break“卫生地”绑定在当前的上下文中,而这里的call/cc显然要求break绑定在展开器所提供的上下文中。 3、with-syntax 语法:(with-syntax ((pattern expr) ...) body1 body2 ...) 用法: 其实with-syntax是实现了一个递归的syntax-case调用,具体定义代码如下: (define-syntax with-syntax (lambda (x) (syntax-case x () ((_ ((p e0) ...) e1 e2 ...) (syntax (syntax-case (list e0 ...) () ((p ...) (begin e1 e2 ...)))))))) 结合loop的例子可以知道,这里我们在用从原来的输入中提取出来的(list e0 ...)去匹配(p ...),with-syntax宏转化器会得到: (syntax-case (list (datum->syntax (syntax k) 'break) ...) () ((break ...) (begin e1 e2 ...))) 也就是一个不断递归调用的匹配过程。 四、Nanopass和Chez Scheme 对编译器性能有敏感性的读者也许会意识到,LisP系语言这种展开器和宏转换器结合的运行方式存在着性能问题。这一点和弱类型一起造成了该语言在互联网等简单应用领域极其小众的现实,以至于在历史上某个时期有些公司试图通过硬件手段如LisP Machine加速计算。当然,自从Chez Scheme改变了这种情况:该编译器的封闭版本基本上是当今最快、质量最高的Scheme编译器。据说它编译出来的Scheme代码运行速度堪比未经优化的C语言。 这一切可以归因于其采用的Nanopass:传统编译器如GCC中,前端到后端一共用了三种中间表示,GCC用遍管理器将其连成一个整体。在编译和优化中,对编译对象(一般以函数或文件为处理对象)的一次编译处理,称为pass(遍)。GCC的整个编译处理过程组成了pass_list,这个pass_list包含的所有遍就是整个GCC编译时所经过的过程。传统上学术界认为这个pass_list长度4-5即可,这在以前内存较小的情况下可以节约硬盘读写的开销。

Nanopass则不信这个邪,采用该框架的编译器动辄使用50多个pass。虽然内存占用增加了,但是传统上高度耦合的编译器也被拆解为多个简单的组成部分,工程难度大大降低,完全可以改成学生作业,每一次完成一小部分。事实上,印第安纳大学的R.Kent Dybvig教授就是这样做的。到他的学生Andrew W. Keep这一代,Nanopass框架成功应用于LLVM等编译器方案中。 当前,一些人在Scheme的研究中正在讨论如何利用Scheme的尾递归式语法更搞笑的组合多个pass,进一步优化效率。 五、结论与展望 可以发现,Chez Scheme的思路总体上是一个展开器不断将代码还原为核心形式,而宏不断生成新代码的过程。了解了这个过程,也就能够懂得一些人为什么宣称自己的40行代码是智慧的结晶。 当然了,对于工程而言,这篇文章一点用也没有。对于学术而言,emmm中国好像没几个做PL的。Over。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!