三种都有用。而且数据结构的本质区别,决定了这三种数据结构的适用范围。树相比起图,每个节点都至多有一个父节点。这导致,图可以很简单的表示sharing:当一个节点有多个父节点的时候,就被重复使用了。
一、程序为什么要编译为语法树而不是语法列表或语法图
三种都有用。而且数据结构的本质区别,决定了这三种数据结构的适用范围。树相比起图,每个节点都至多有一个父节点。这导致,图可以很简单的表示sharing:当一个节点有多个父节点的时候,就被重复使用了。
更严重的是,这是指数上的性能差异。想一想要把8个a加起来,树跟图差多少就知道了。
为了修复这个问题,我们可以给树引入变量跟let expression。但是要注意:这只是把图内在的性质(sharing)用一套糟糕的语言表达出来。对于图来说,简简单单的‘查找后续’,就变成了‘查找变量名’跟‘用变量名找后续’这个繁复的操作,而且这时候还额外引入了alpha equivalent,unbounded/shadowed variable等,本来不是问题的问题。这些问题可以通过诸如Fresh ML,Higher Order Abstract Syntax,Bound,De Bruijn Index等方法解决,也可以直接‘向Graph靠拢’:<A Graph-Based Higher-Order Intermediate Representation>里面就直接用指针来代表abstraction跟variable。
列表相比起树,每个节点都至多有一个子节点。
这导致,列表除了长度,没有任何结构,于是你无法表示任何‘数据跟操作间的关联’。
比如上面的程序,你用列表写,大概可以用一个stack based language,也就是说forth这样的。
这时候,程序是
a a + a a + +
当我们运行这个程序的时候,我们有一个stack。
当我们执行a的时候,我们把a的值push上stack。
当我们执行+的时候,我们在stack上pop两个值,相加,push回去。
要注意 – 你无法给这个‘程序’加任何括号:列表并不支持这种结构。
当然,结构的损失,也导致了一些优化的‘不可能性’:你无法并行执行这个程序,因为你无法在这个程序中找到一段子程序。
所以,你会在很多linear language(比如assembly, structured logging, skia DSL)里面看到,save/restore, call/return, begin/end等东东。归根到底,这些其实就是试图给列表加入嵌套结构。换句话说,这其实写作列表,读作树。
从图 -> 树 -> 列表这条路走下去,越右边复杂度越低,能表示的东东越小。
但是,反过来说,越左边的东东,越‘无序’,更难操控。
图最大的问题是没有scope,而这代表难以控制副作用范围。
lambda x. read() + x
vs
let a = read() in lambda x. a + x
这两个程序,一个副作用在外面,一个在里面,但是注意 – 这两个程序如果用图表示,后果是一样的。一个解决方法当然是重新引入let expression跟variable,defeating the purpose of the graph,而sea of nodes通过引入region跟control edge解决。如果你想一想,control edge其实就是一个unique backpointer,指定了一个parent,重新把scope变成树状的。
这也是为什么,在数据库跟深度学习里面,IR往往是一个图:这时候,所有操作都是(或者接近是)purely functional的,就没有副作用带来的副作用。
树的问题是,‘值’跟‘值的表式’含糊不分。
比如说,假设我们有以下这个program:
a = read()
a2 = a + a
a4 = a2 + a2
a8 = a4 + a4
我们可以试图把这个program stage下来,写下以下这个metaprogram:
a = Call(“read”, [])
a2 = Plus(a, a)
a4 = Plus(a2, a2)
a8 = Plus(a4, a4)
要注意,这时候,这个程序会执行read()8次,然后把八个结果相加!
这是因为,在本来的program,a2是一个‘值’,就是一个整数,而在metaprogram,a2是那个值的‘表示’,变成了一个ast,后续操作只是不停的引用这个ast而已。
解决方法,就是引入一个叫做letlist的辅助结构:对于所有操作,我都生成一个名字,然后把(名字,操作)放进letlist,并且后续只用这个名字引用这个值,而在退出当前scope的时候,利用letlist生成对应的let expression。这样,被引用的,永远只是name,而复制这些name不会造成任何问题。
同样的问题不止在staging中会出现,也会在编译器中出现。比如在strength reduction中,我们希望把2 * x优化成x + x,而这时候,x会被复制一次(考虑下x是read()的时候会怎么样)。解决方法也是一样的:我们还是对程序的所有操作赋予一个名字,而且只容许通过名字访问。(这可以通过对程序跑一遍staged definitional interpreter on letlist得到),而这,就是大名鼎鼎的ANF。已知接近的,CPS跟call by push value也是通过‘禁止构造复杂表达式,一切只能由名字访问’来控制evaluation跟effect的。
要注意,letlist是一个列表。再直白一点:在一个没有任何scope的程序上跑ANF,就会得到3地址码!归根到底,值跟表示的不匹配,就是因为一个简单的值,会由一个复杂的数据结构表示。换句话说,这是因为树可以嵌套。禁止掉嵌套以后,我们就得出一个更线性,更接近列表的表示法。
除了以上描述的光谱跟tradeoff,在现实中,编译器往往会采用多个IR。
比如说,你可以想象一个编译器,前端先parse上AST再进行一些desugar,中端用sea of nodes或者thorin等处理,然后后端会上一个peephole superoptimizer over ASM,这时候,树,列表,图都齐了。
延伸阅读:
二、什么是最小生成树
在给定一张无向图,如果在它的子图中,任意两个顶点都是互相连通,并且是一个树结构,那么这棵树叫做生成树。当连接顶点之间的图有权重时,权重之和最小的树结构为最小生成树!
在实际中,这种算法的应用非常广泛,比如我们需要在n个城市铺设电缆,则需要n-1条通信线路,那么我们如何铺设可以使得电缆最短呢?最小生成树就是为了解决这个问题而诞生的!