闭包转换和单独编译高阶函数调用

时间:2010-02-19 22:46:07

标签: compiler-construction programming-languages functional-programming closures

在编译高阶函数调用时,是否有一种标准的方法来处理单独编译和不同类型的闭包转换之间的交互?

我知道在大多数编程语言中都有明显编译的三个类似函数的结构:闭包,(顶级)函数和C ++风格的函数对象。从语法上讲,它们的调用方式相同,但编译器可以最佳地生成形状清晰的调用站点:

Syntax:  | clo(args)                 |   func(args)     |   obj(args)
--------------------------------------------------------------------------------
Codegen: | clo.fnc(&clo.env, args)   |   func(args)     |   cls_call(&obj, args)
              ^      ^                      ^                   ^     ^
            fn ptr   |                      +--"top level" fn --+     |
                     +--- "extra" param, compared to source type -----+

(在C ++中,对于cls_call的类T::operator()obj将是T。C ++也允许虚拟仿函数,但这实际上是具有额外间接的闭包情况。)

此时,对map (x => x > 3) lstmap (x => x > y) lst的调用应调用不同的map函数,因为第一个是提升后的简单函数指针,第二个是闭包。

我可以想到处理这个问题的四种方法:

  1. C ++(98)方法,它强制被调用者选择一个调用站点形状(通过形式参数类型:虚拟函子,函数指针或非虚函数)或使用一个单独的编译模板,有效指定下面的解决方案#2。

  2. 重载:编译器可以对map和所有其他高阶函数进行多次实例化,并进行适当的名称修改。实际上,每个调用站点形状都有一个单独的内部函数类型,重载决策选择正确的函数类型。

  3. 指定全局统一的呼叫站点形状。这意味着所有顶级函数都采用显式env参数,即使它们不需要它,并且必须引入“额外”闭包来包装非闭包参数。

  4. 保留顶级函数的“自然”签名,但要求通过闭包完成所有高阶函数参数的处理。已关闭函数的“额外”闭包调用包装器trampoline函数来丢弃未使用的env参数。这似乎比选项3更优雅,但更难以有效实施。编译器生成大量的calling-convention-indepedent包装器,或者它使用少量的call-convention-sensitive thunks ......

  5. 具有优化的闭包转换/ lambda提升混合方案,每个函数选择是否在env或参数列表中粘贴给定的闭包参数,似乎会使问题更加严重。

    无论如何,问题:

    • 这个问题在文献中有明确的名称吗?
    • 除上述四种方法外还有其他方法吗?
    • 方法之间是否存在众所周知的权衡?

1 个答案:

答案 0 :(得分:17)

这是一个非常深刻的问题,有很多分歧,我不想在这里写一篇学术文章。我将抓住表面,并指出您在其他地方的更多信息。我的回答基于Glorious Glasgow Haskell CompilerStandard ML of New Jersey的个人经验,以及关于这些系统的学术论文。

雄心勃勃的编译器的主要区别在于已知调用和未知调用之间的区别。对于具有高阶函数的语言,次要但仍然重要的区别是呼叫是否完全饱和(我们只能在已知的呼叫站点处决定)。

  • 已知调用表示一个调用站点,编译器确切地知道调用了哪个函数,预期会有多少参数。

  • 未知调用表示编译器无法确定可能调用的函数。

  • 如果正在调用的函数正在获取所需的所有参数,则已知调用完全饱和,并且它将直接进入代码。如果函数的参数少于预期,则函数部分应用,并且调用仅在分配闭包时产生

例如,如果我编写Haskell函数

mapints :: (Integer -> a) -> [a]
mapints f = map f [1..]

然后对map的调用已知完全饱和
如果我写

inclist :: [Integer] -> [Integer]
inclist = map (1+)

然后,对map的调用已知已部分应用
最后,如果我写

compose :: (b -> c) -> (a -> c) -> (a -> c)
compose f g x = f (g x)

然后对fg的调用都是 unknown

成熟编译器的主要功能是优化已知调用。在您的分类中,此策略主要属于#2。

  • 如果函数的所有调用站点都是已知的,那么一个好的编译器将为该函数创建一个专用的调用约定,例如,在正确的寄存器中传递参数来制作事情很顺利。

  • 如果函数的某些但不是所有的调用站点都已知,编译器可能会认为为已知的调用创建一个特殊用途的调用约定是值得的,它将被内联或者将使用仅为编译器所知的特殊名称。在源代码中以名称导出的函数将使用标准调用约定,其实现通常是对专用版本进行优化尾部调用的薄层。

  • 如果已知调用未完全饱和,编译器只会生成代码以在调用者中分配闭包。

闭包的表示(或者是否通过一些其他技术(如lambda提升或去泛化)处理第一类函数)与已知与未知调用的处理大致正交。

(值得一提的是MLton使用的另一种方法:它是一个完整的程序编译器;它可以看到所有源代码;它使用一种技术将所有函数减少到第一顺序已经忘记了。仍然未知的调用,因为高阶语言中的一般控制流分析是难以处理的。)


关于你的最后问题:

  • 我认为这个问题只是凌乱问题的一个方面,称为“如何编译一流函数”。我从来没有听过这个问题的特殊名称。

  • 是的,还有其他方法。我勾画了一个并提到了另一个。

  • 我不确定是否有任何关于权衡的大而广泛的研究,但我所知道的最好的一项,我非常推荐,是Simon Marlow和Simon Peyton Jones的Making a Fast Curry: Push/Enter vs. Eval/Apply for Higher-Order Languages。本文的许多好处之一是它解释了为什么函数的类型告诉你对该函数的调用是否完全饱和。


包装您编号的替代方案:编号1是不可启动的。 流行的编译器使用与数字2和3相关的混合策略。 我从来没有听说过类似4号的东西;已知和未知调用之间的区别似乎比将顶级函数与函数类型的参数区分开来更有用。