多态递归的应用



通过单体化(仅单体化)在语言中实现多态性的一个限制是,您失去了支持多态递归的能力(例如,请参阅rust-lang#4287)。

在编程语言中支持多态递归的一些引人注目的用例是什么?我一直在努力寻找使用它的库/概念,到目前为止,我遇到了一个例子:

  1. 在"命名问题"中,我们希望(a)避免替换的快速捕获和(b)快速阿尔法等价性检查,有一个绑定库(这里有更详细的解释)。当为函数式编程语言编写编译器时,这两个特性都是可取的

为了防止这个问题过于宽泛,我正在寻找其他程序/库/研究论文,介绍多态递归在传统计算机科学问题中的应用,例如编写编译器时所涉及的问题。

我不想找的东西的例子:

  1. 显示如何使用多态递归从范畴论中对X进行编码的答案,除非它们展示了编码X如何有利于求解符合上述标准的Y。

  2. 小玩具的例子表明,你可以用多态递归做X,但你不能没有它。

有时您希望在类型中编码一些约束,以便在编译时强制执行这些约束。

例如,一个完整的二叉树可以定义为

data CTree a = Tree a | Dup (CTree (a,a))
example :: CTree Int
example = Dup . Dup . Tree $ ((1,2),(3,4))

该类型将阻止像((1,2),3)这样的非完整树存储在内部,从而强制执行不变量。

冈崎的书中展示了许多这样的例子。

如果想要对这样的树进行操作,就需要多态递归。编写一个函数来计算树的高度、对CTree Int中的所有数字求和,或者编写一个通用映射或折叠,都需要多态递归。

现在,需要/想要这样的多态递归类型并不常见。尽管如此,拥有它们还是很好的。

在我个人看来,单形态化有点不令人满意,不仅因为它阻止了多态递归,还因为它需要为使用的每种类型编译一次多态代码。在Haskell或Java中,使用Maybe Int, Maybe String, Maybe Bool不会导致Maybe相关函数被编译三次,并在最终的目标代码中出现三次。在C++中,会发生这种情况,使对象代码膨胀。然而,在C++中,这确实允许使用更有效的专业化(例如,std::vector<bool>可以用位向量实现)。这进一步启用了C++的SFINAE等。不过,我认为我更喜欢多态代码编译一次,并检查一次类型——之后,它保证对所有类型都是类型安全的。

我可以分享我在项目中使用的一个真实示例。

长话短说,我有一个数据结构TypeRepMap,我在其中存储类型作为键,并且该类型与相应值的类型匹配。

为了对我的库进行基准测试,我需要列出1000种类型,以检查该数据结构中lookup的工作速度。这里是多态递归。

为此,我引入了以下数据类型作为类型级自然数:

data Z
data S a

使用这些数据类型,我能够实现构建所需大小的TypeRepMap的功能。

buildBigMap :: forall a . Typeable a 
=> Int 
-> Proxy a 
-> TypeRepMap 
-> TypeRepMap
buildBigMap 1 x = insert x
buildBigMap n x = insert x . buildBigMap (n - 1) (Proxy @(S a))

所以当我运行大小为nProxy abuildBigMap时,它在每一步都用n - 1Proxy (S a)递归地调用自己,所以类型在每一个步骤上都在增长。

这里有一个与我的工作相近的例子,我认为它可以很好地概括:在串联语言中,也就是说,一种建立在组成在共享程序状态(如堆栈)上操作的函数的语言,所有函数相对于它们不接触的堆栈部分都是多态的,所有递归都是多态递归,而且所有的高阶函数也是更高阶的。例如,在这种语言中,map的类型可能是:

αβσ。σ×列表α×(τ.τ×α→τ×β)→σ×列表β

其中,×是左关联乘积类型,左边是堆栈类,右边是值类,σ和τ是堆栈类变量,α和β是值类变量。map可以在任何程序状态σ上调用,只要它上面有一个αs列表和一个从αs到βs的函数,比如:

"ignored" [ 1 2 3 ] { succ show } map
=
"ignored" [ "2" "3" "4" ]

这里有多态递归,因为map在σ的不同实例化(即不同类型的"堆栈的其余部分")上递归调用自己:

-- σ = Bottom × String
"ignored"           [ 1 2 3 ] { succ show } map
"ignored" 1 succ show [ 2 3 ] { succ show } map cons
-- σ = Bottom × String × String
"ignored" "2"           [ 2 3 ] { succ show } map cons
"ignored" "2" 2 succ show [ 3 ] { succ show } map cons cons
-- σ = Bottom × String × String × String
"ignored" "2" "3"           [ 3 ] { succ show } map cons cons
"ignored" "2" "3" 3 succ show [ ] { succ show } map cons cons cons
-- σ = Bottom × String × String × String × String
"ignored" "2" "3" "4" [ ] { succ show } map cons cons cons
"ignored" "2" "3" "4" [ ] cons cons cons
"ignored" "2" "3" [ "4" ] cons cons
"ignored" "2" [ "3" "4" ] cons
"ignored" [ "2" "3" "4" ]

map的函数自变量需要更高的秩,因为它也在不同的堆栈类型上调用(τ的不同实例化)。

为了在没有多态递归的情况下做到这一点,您需要一个额外的堆栈或局部变量来放置map的中间结果,以使它们"让开",从而使所有递归调用都发生在同一类型的堆栈上。这对如何将函数语言编译为类型组合子机具有启示意义:通过多态递归,您可以在保持虚拟机简单的同时保持安全。

这种情况的一般形式是,您有一个递归函数,它在数据结构的部分上是多态的,例如HList的初始元素或多态记录的子集。

正如@chi已经提到的,在Haskell中,在函数级别需要多态递归的主要例子是在类型级别有多态递归,例如:

data Nest a = Nest a (Nest [a]) | Nil
example = Nest 1 $ Nest [1, 2] $ Nest [[1, 2], [3, 4]] Nil

在这样的类型上的递归函数总是多态递归的,因为类型参数随着每次递归调用而变化。

Haskell需要这类函数的类型签名,但除了类型之外,递归和多态递归在机械上没有区别。如果您有一个隐藏多态性的辅助newtype,那么您可以编写一个多态定点运算符:

newtype Forall f = Abstract { instantiate :: forall a. f a }
fix' :: forall f. ((forall a. f a) -> (forall a. f a)) -> (forall a. f a)
fix' f = instantiate (fix (x -> Abstract (f (instantiate x))))

没有所有的包装&开箱仪式,这与fix' f = fix f相同。

这也是多态递归不需要导致函数实例化激增的原因——即使函数专门用于其值类型参数,它在递归参数中也是"完全多态的",因此它根本不操作它,因此只需要一个编译的表示。

最新更新