跟vczh看实例学编译原理——一:Tinymoe的设计哲学

自从《序》胡扯了快一个月以后,终于迎来了正片。之因此系列文章叫《看实例学编译原理》,是由于整个系列会经过带你们一步一步实现Tinymoe的过程,来介绍编译原理的一些知识点。git

 

可是第一个系列还没到开始处理Tinymoe源代码的时候,首先的跟你们讲一讲我设计Tinymoe的故事。为何这种东西要等到如今才讲呢,由于以前没有文档,将了也是白讲啊。Tinymoe在github的wiki分为两部分,一部分是介绍语法的,另外一部分是介绍一个最小的标准库是如何实现出来的,地址在 https://github.com/vczh/tinymoe/wiki 不带问号的那些都是写完了的。程序员

系列文章的目标

在介绍Tinymoe以前,先说一下这个系列文章的目标。Ideally,只要一我的看完了这个系列,他就能够在下面这些地方获得入门github

  • 词法分析
  • 歧义与不歧义的语法分析
  • 语义分析
  • 符号表
  • 全文CPS变换
  • 编译生成高效的其余语言的代码
  • 编译生成本身的指令集
  • 带GC的虚拟机
  • 类型推导(intersection type,union type,concept mapping)
  • 跨过程分析(inter-procedural analyzing)

 

固然,这并不能让你成为一个大牛,可是至少本身作作实验,搞一点高大上的东西骗师妹们是没有问题了。算法

Tinymoe设计的目标

虽然想法不少年前就已经有了,可是此次我想把它实现出来,是为了完成《如何设计一门语言》的后续。光讲大道理是没有意义的,至少得有一个例子,让你们知道这些事情究竟是什么样子的。所以Tinymoe有一点教学的意义,无论是使用它仍是实现它。spring

 

首先,处理Tinymoe须要的知识点多,用于编译原理教学。既然是为了展现编译原理的基础知识,所以语言自己不多是那种烂大街的C系列的东西。固然除了知识点之外,还会让你们深入的理解到,难实现和难用,是彻底没有关系的!Tinymoe用起来可爽了,啊哈哈哈哈哈。数组

 

其次,Tinymoe容易嵌入其余语言的程序,做为DSL使用,能够调用宿主程序提供的功能。这严格的来说不算语言自己的功能,而是实现自己的功能。就算是C++也能够设计为嵌入式,lua也能够被设计为编译成exe的。一个语言自己的设计并不会对如何使用它有多大的限制。为了让你们看了这个系列以后,能够写出至少可用的东西,而不只仅是写玩具,所以这也是设计的目标之一。数据结构

 

第三,Tinymoe语法优化于描述复杂的逻辑,而不是优化与复杂的数据结构和算法(虽然也能够)。Tinymoe自己是不存在任何细粒度控制内存的能力的,并且虽然能够实现复杂的数据结构和算法,可是自己描述这些东西最多也就跟JavaScript同样容易——其实就是不容易。可是Tinymoe设计的时候,是为了让你们把Tinymoe当成是一门能够设计DSL的语言,所以对复杂逻辑的描述能力特别强。惟一的前提就是,你懂得如何给Tinymoe写库。很好的使用和很好地实现一个东西是相辅相成的。我在设计Tinymoe之初,不少pattern我也不知道,只是由于设计Tinymoe遵循了科学的方法,所以最后我发现Tinymoe居然具备如此强大的描述能力。固然对于读者们自己,也会在阅读系列文章的有相似的感受。闭包

 

最后,Tinymoe是一个动态类型语言。这纯粹是个人我的爱好了。对一门动态类型语言作静态分析那该多有趣啊,啊哈哈哈哈哈哈。app

Tinymoe的设计哲学

固然我并不会为了写文章就无线提升Tinymoe的实现难度的。为了把他控制在一个正常水平,所以设计Tinymoe的第一条就是:数据结构和算法

 

1、小规模的语言核心+大规模的标准库

 

其实这跟C++差很少。可是C++因为想作的事情实在是太多了,譬如说视图包涵全部范式等等,所以就算这么作,仍然让C++自己包含的东西过于巨大(其实我仍是以为C++不难怎么办)。

 

语言核心小,实现起来固然容易。可是你并不能为了让语言核心小就牺牲什么功能。所以精心设计一个核心是必须的,由于全部你想要可是不想加入语言的功能,今后就能够用库来实现了。

 

譬如说,Tinymoe经过有条件地暴露continuation,要求编译器在编译Tinymoe的时候作一次全文CPS变换。这个东西说容易也不是那么容易,可是至少比你作分支循环异常处理什么的所有加起来要简单多了吧。因此我只提供continuation,剩下的控制流所有用库来作。这样有三个好处:

  1. 语言简单,实现难度下降
  2. 为了让库能够发挥应有的做用,语言的功能的选择十分的正交化。不过这仍然在必定的程度上提升了学习的难度。可是并非全部人都须要写库对吧,不少人只须要会用库就够了。经过一点点的牺牲,正交化能够充分发挥程序员的想象能力。这对于以DSL为目的的语言来讲是不可或缺的。
  3. 标准库自己能够做为编译器的测试用例。你只须要准备足够多的测试用例来运行标准库,那么你只要用C++(假设你用C++来实现Tinymoe)来跑他们,那全部的标准库都会获得运行。运行结果若是对,那你对编译器的实现也就有信心了。为何呢,由于标准库大量的使用了语言的各类功能,并且是无节操的使用。若是这样都能过,那普通的程序就更能过了。

 

说了这么多,那到底什么是小规模的语言核心呢?这在Tinymoe上有两点体现。

 

第一点,就是Tinymoe的语法元素少。一个Tinymoe表达式无非就只有三类:函数调用、字面量和变量、操做符。字面量就是那些数字字符串什么的。当Tinymoe的函数的某一个参数指定为不定个数的时候你还得提供一个tuple。委托(在这里是函数指针和闭包的统称)和数组虽然也是Tinymoe的原生功能之一,可是对他们的操做都是经过函数调用来实现的,没有特殊的语法。

 

简单地讲,就是除了下面这些东西之外你不会见到别的种类的表达式了:

1

"text"

sum from 1 to 100

sum of (1, 2, 3, 4, 5)

(1+2)*(3+4)

true

 

一个Tinymoe语句的种类就更少了,要么是一个函数调用,要么是block,要么是连在一块儿的几个block:

do something bad

 

repeat with x from 1 to 100

    do something bad with x

end

 

try

    do something bad

catch exception

    do something worse

end

 

有人可能会说,那repeat和try-catch就不是语法元素吗?这个真不是,他们是标准库定义好的函数,跟你本身声明的函数没有任何特殊的地方。

 

这里其实还有一个有意思的地方:"repeat with x from 1 to 100"的x实际上是循环体的参数。Tinymoe是如何给你自定义的block开洞的呢?不只如此,Tinymoe的函数还能够声明"引用参数",也就是说调用这个函数的时候你只能把一个变量放进去,函数里面能够读写这个变量。这些都是怎么实现的呢?学下去就知道了,啊哈哈哈哈。

 

Tinymoe的声明也只有两种,第一种是函数,第二种是符号。函数的声明可能会略微复杂一点,不过除了函数头之外,其余的都是相似配置同样的东西,几乎都是用来定义"catch函数在使用的时候必须是连在try函数后面"啊,"break只能在repeat里面用"啊,诸如此类的信息。

 

Tinymoe的符号十分简单,譬如说你要定义一年四季的符号,只须要这么写:

symbol spring

symbol summer

symbol autumn

symbol winter

 

symbol是一个"不同凡响的值",也就是说你在两个module下面定义同名的symbol他们也是不同的。全部symbol之间都是不同的,能够用=和<>来判断。symbol就是靠"不同"来定义其自身的。

 

至于说,那为何不用enum呢?由于Tinymoe是动态类型语言,enum的类型自己是根本没有用武之地的,因此干脆就设计成了symbol。

 

第二点,Tinymoe除了continuation和select-case之外,没有其余原生的控制流支持

 

这基本上归功于先辈发明continuation passing style transformation的功劳,细节在之后的系列里面会讲。心急的人能够先看 https://github.com/vczh/tinymoe/blob/master/Development/Library/StandardLibrary.txt 。这个文件暂时包含了Tinymoe的整个标准库,里面定义了不少if-else/repeat/try-catch-finally等控制流,甚至连coroutine均可以用continuation、select-case和递归来作。

 

这也是小规模的语言核心+大规模的标准库所要表达的意思。若是能够提供一个feature A,经过他来完成其余必要的feature B0, B1, B2…的同时,未来说不定还有人能够出于本身的需求,开发DSL的时候定义feature C,那么只有A须要保留下来,全部的B和C都将使用库的方法来实现。

 

这么作并非彻底有益无害的,只是坏处很小,在"Tinymoe的实现难点"里面会详细说明。

 

2、扩展后的东西跟原生的东西外观一致

 

这是很重要的。若是扩展出来的东西跟原生的东西长得不同,用起来就以为很傻逼。Java的string不能用==来判断内容就是这样的一个例子。虽然他们有的是理由证实==的反直觉设计是对的——可是反直觉就是反直觉,就是一个大坑。

 

这种例子还有不少,譬如说go的数组和表的类型啦,go自己若是不要数组和表的话,是写不出长得跟原生数组和表同样的数组和表的。其实这也不是一个大问题,问题是go给数组和表的样子搞特殊化,还有那个反直觉的slice的赋值问题(会合法溢出!),相似的东西实在是太多了。一个东西特例太多,坑就没法避免。因此其实在我看来,go还不如给C语言加上erlang的actor功能了事。

 

反而C++在这件事情上就作得很好。若是你对C++不熟悉的话,有时候根本分不清什么是编译器干的,什么是标准库干的。譬如说static_cast和dynamic_cast长得像一个模板函数,所以boost就能够用相似的手法加入lexical_cast和针对shared_ptr的static_pointer_cast和dynamic_pointer_cast,整个标准库和语言自己浑然一体。这样子作的好处是,当你在培养对语言自己的直觉的时候,你也在培养对标准库的直觉,培养直觉这件事情你不用作两次。你对一个东西的直觉越准,学习新东西的速度就越快。因此C++的设计恰好可让你在熬过第一个阶段的学习以后,后面都以为无比的轻松。

 

不过具体到Tinymoe,由于Tinymoe自己的语法元素太少了,因此这个作法在Tinymoe身上体现得不明显。

Tinymoe的实现难点

首先,语法分析须要对Tinymoe程序处理三遍。Tinymoe对于语句设计使得对一个Tinymoe程序作语法分析不是那么直接(虽然比C++什么的仍是容易多了)。举个例子:

module hello world

 

phrase sum from (lower bound) to (upper bound)

end

 

sentence print (message)

end

 

phrase main

    print sum from 1 to 100

end

 

第一遍分析是词法分析,这个时候得把每个token的行号记住。第二遍分析是不带歧义的语法分析,目标是把全部的函数头抽取出来,而后组成一个全局符号表。第三遍分析就是对函数体里面的语句作带歧义的语法分析了。由于Tinymoe容许你定义变量,因此符号表确定是一边分析一边修改的。因而对于"print sum from 1 to 100"这一句,若是你没有发现"phrase sum from (lower bound) to (upper bound)"和"sentence print (message)",那根本无从下手。

 

还有另外一个例子:

module exception handling

 

 

phrase main

    try

        do something bad

    catch

        print "bad thing happened"

    end

end

 

当语法分析作到"try"的时候,由于发现存在try函数的定义,因此Tinymoe知道接下来的"do something bad"属于调用try这个块函数所需提供的代码块里面的代码。接下来是"catch",Tinymoe怎么知道catch是接在try后面,而不是放在try里面的呢?这仍然是因为catch函数的定义告诉咱们的。关于这方面的语法知识能够点击这里查看

 

正由于如此,咱们须要首先知道函数的定义,而后才能分析函数体里面的代码。虽然这在必定程度上形成了Tinymoe的语法分析复杂度的提高,可是其复杂度自己并不高。比C++简单就不说了,就算是C、C#和Java,因为其语法元素太多,致使不须要屡次分析所下降的复杂度被彻底的抵消,结果跟实现Tinymoe的语法分析器的难度不相上下。

 

其次,CPS变换后的代码须要特殊处理,不然直接执行容易致使call stack积累的没用的东西过多。由于Tinymoe能够自定义操做符,因此操做符跟C++同样在编译的时候被转换成了函数调用。每个函数调用都是会被CPS变换的。尽管每一行的函数调用次数很少,可是若是你的程序油循环,循环是经过递归来描述(而不是实现,因为CPS变换后Tinymoe作了优化,因此不存在实际上的递归)的,若是直接执行CPS变换后的代码,算一个1加到1000都会致使stack overflow。可见其call stack里面堆积的closure数量之巨大。

 

我在作Tinymoe代码生成的实验的时候,为了简单我在单元测试里面直接产生了对应的C#代码。一开始没有处理CPS而直接调用,程序不只慢,并且容易stack overflow。可是咱们知道(其实大家之后才会知道),CPS变换后的代码里面几乎全部的call stack项都是浪费的,所以我把整个在生成C#代码的时候修改为,若是须要调用continuation,就返回调用continuation的语句组成的lambda表达式,在最外层用一个循环去驱动他直到返回null为止。这样作了以后,就算Tinymoe的代码有递归,call stack里面也不会由于递归而积累call stack item了。因而生成的C#代码执行飞快,并且不管你怎么递归也永远不会形成stack overflow了。这个美妙的特性几乎全部语言都作不到,啊哈哈哈哈哈。

 

固然这也是有代价的,由于本质上我只是把保存在stack上的context转移到heap上。不过多亏了.net 4.0的强大的background GC,这样作丝毫没有多余的性能上的损耗。固然这也意味着,一个高性能的Tinymoe虚拟机,须要一个牛逼的垃圾收集器做为靠山。context产生的closure在函数体真的被执行完以后就会被很快地收集,因此CPS加上这种作法并不会对GC产生额外的压力,全部的压力仍然来源于你本身所建立的数据结构。

 

第三,Tinymoe须要动态类型语言的类型推导。固然你不这么作而把Tinymoe的程序当JavaScript那样的程序处理也没有问题。可是咱们知道,正是由于V8对JavaScript的代码进行了类型推导,才产生了那么优异的性能。所以这算是一个优化上的措施。

 

最后,Tinymoe还须要跨过程分析和对程序的控制流的化简(譬如continuation转状态机等)。目前具体怎么作我还在学习当中。不过咱们想,既然repeat函数是经过递归来描述的,那咱们能不能经过对全部代码进行inter-procedural analyzing,从而发现诸如

repeat 3 times

    do something good

end

就是一个循环,从而生成用真正的循环指令(譬如说goto)呢?这个问题是个颇有意思的问题,我以为我若是能够经过学习静态分析从而解决它,不进个人能力会获得提高,我对大家的科普也会作得更好。

后记

虽然还不到五千字,可是总以为写了好多的样子。总之我但愿读者在看完《零》和《一》以后,对接下来须要学习的东西有一个较为清晰的认识。

相关文章
相关标签/搜索