除了列表以外,其他类型中什么构成了折叠?

65

考虑一个单向链表。它看起来像这样:

data List x = Node x (List x) | End

定义一个折叠函数是很自然的,例如:

reduce :: (x -> y -> y) -> y -> List x -> y

从某种意义上说,reduce f x0将每个Node替换为f,每个End替换为x0。这就是 Prelude 中所称的fold

现在考虑一个简单的二叉树:

data Tree x = Leaf x | Branch (Tree x) (Tree x)

同样地,定义这样一个函数也是很自然的:

reduce :: (y -> y -> y) -> (x -> y) -> Tree x -> y

注意,这个简化方法有着截然不同的特点;而基于列表的方法天生就是顺序执行的,这个新的基于树的方法更像是分治思想。你甚至可以想象在其中插入一些par组合器。(在列表版本中,你会把这样的东西放在哪里呢?)

我的问题是:这个函数仍然被归类为“折叠”吗,还是其他什么类型的函数?如果是其他的话,它是什么类型的函数?

通常每当有人谈论折叠时,他们总是在谈论如何对列表进行折叠,这是天生的顺序执行方式。我想知道,“顺序执行”是否是折叠的定义的一部分,还是这只是最常用的折叠示例的巧合属性。


1
折叠的顺序似乎是一个重要的考虑因素(中序、前序、后序),我认为这两者是不同的。但仅当顺序对操作本身很重要时才如此。 - GeneralBecos
1
你可能会对Guy Steele在http://vimeo.com/6624203上关于为并行执行组织代码的内容感兴趣(包括折叠、树和列表)。 - Jeff Foster
13
折叠是范畴折叠,它们的形状取决于所讨论类型的数据构造器的形状。 - Tom Crockett
3
我最喜欢的一篇论文 Functional Programming with Overloading and Higher-Order Polymorphism ,作者是 Mark P. Jones,其中有一个非常好的讨论。 - Mark Dominus
我上传了一个折叠包(catamorphism package),它可以自动生成某些给定数据类型的折叠(folds)。 - Frerich Raabe
显示剩余2条评论
4个回答

65

应对各种场合的折叠

我们实际上可以提出一个通用的折叠概念,适用于各种不同类型。也就是说,我们可以系统地定义一个适用于列表、树等类型的fold函数。

这个通用的fold概念对应于@pelotom在评论中提到的“消解”。

递归类型

关键的洞见是,这些fold函数是针对递归类型定义的。特别是:

data List a = Cons a (List a) | Nil
data Tree a = Branch (Tree a) (Tree a) | Leaf a

这两种类型都明显是递归的——在Cons情况下是List,而在Branch情况下是Tree

不动点

与函数一样,我们可以使用不动点来重写这些类型。记得fix的定义:
fix f = f (fix f)

我们实际上可以为类型编写类似的代码,只需添加一个额外的构造函数包装器即可。
newtype Fix f = Roll (f (Fix f))

就像`fix`定义了一个函数的不动点一样,这个定义定义了一个函子的不动点。我们可以使用这个新的`Fix`类型来表示所有递归类型。
这使我们能够将`List`类型重写为以下形式:
data ListContainer a rest = Cons a rest | Nil
type List a = Fix (ListContainer a)

基本上,Fix 允许我们嵌套任意深度的 ListContainer。因此,我们可以有以下内容:
Roll Nil
Roll (Cons 1 (Roll Nil))
Roll (Cons 1 (Roll (Cons 2 (Roll Nil))))

这三个分别对应[], [1][1,2]

看到ListContainer是一个Functor很容易:

instance Functor (ListContainer a) where
  fmap f (Cons a rest) = Cons a (f rest)
  fmap f Nil           = Nil

我认为从ListContainerList的映射非常自然:我们将递归部分变成一个变量,而不是明确地进行递归。然后,我们只需使用Fix来填充相应的变量即可。
我们也可以为Tree编写类似的类型。
“展开”固定点
那么我们为什么要关心呢?我们可以为使用Fix编写的任意类型定义fold。特别地:
fold :: Functor f => (f a -> a) -> (Fix f -> a)
fold h = h . fmap (fold h) . unRoll
  where unRoll (Roll a) = a

基本上,fold 所做的就是一次只展开“卷起”的类型一层,每次对结果应用一个函数。这种“展开”让我们为任何递归类型定义折叠,并将概念整齐自然地推广。
对于列表示例,它的工作方式如下:
1. 在每个步骤中,我们展开 Roll 以获取 Cons 或 Nil。 2. 我们使用 fmap 递归到列表的其余部分。
在 Nil 情况下,fmap (fold h) Nil = Nil,因此我们只返回 Nil。
在 Cons 情况下,fmap 只会继续对列表的其余部分进行折叠。
最后,我们得到了一堆嵌套的 fold 调用,最终以 Nil 结尾——就像标准的 foldr 一样。
比较类型
现在让我们看一下两个 fold 函数的类型。首先是 foldr:
foldr :: (a -> b -> b) -> b -> [a] -> b

现在,fold专用于ListContainer:
fold :: (ListContainer a b -> b) -> (Fix (ListContainer a) -> b)

一开始,它们看起来完全不同。然而,稍加调整,我们可以证明它们是相同的。foldr 的前两个参数是 a -> b -> bb。我们有一个函数和一个常量。我们可以将 b 视为 () -> b。现在我们有两个函数 _ -> b,其中 _(),和 a -> b。为了简化生活,让我们对第二个函数进行柯里化,得到 (a, b) -> b。现在我们可以使用 Either 将它们写成一个 单独的 函数:

Either (a, b) () -> b

这是真的,因为给定 f :: a -> cg :: b -> c,我们总是可以写出以下内容:
h :: Either a b -> c
h (Left a) = f a
h (Right b) = g b

现在我们可以将foldr视为:

foldr :: (Either (a, b) () -> b) -> ([a] -> b)

(我们总是可以像这样在箭头周围添加括号,只要它们是右结合的。)
现在让我们看一下ListContainer。这种类型有两种情况:Nil,它不携带任何信息,和Cons,它既有a又有b。换句话说,Nil就像(),而Cons就像(a,b),因此我们可以写成:
type ListContainer a rest = Either (a, rest) ()

显然,这与我在上面使用的foldr相同。因此,现在我们有:

foldr :: (Either (a, b) () -> b) -> ([a] -> b)
fold  :: (Either (a, b) () -> b) -> (List a -> b)

实际上,这些类型是同构的——只是写同一件事情的不同方式!我觉得这很酷。

(顺便说一下,如果你想了解更多关于使用类型进行推理的内容,请查看代数数据类型的代数, 这是一系列关于此问题的漂亮博客文章。)

回到树

所以,我们已经看过如何为写成不动点形式的类型定义通用的fold函数。我们也看到了这直接对应于列表的foldr函数。现在让我们来看看你的第二个例子,二叉树。我们有以下类型:

data Tree a = Branch a (Tree a) (Tree a) | Leaf a

我们可以按照我上面的规则,使用Fix来重写这段内容:我们用一个类型变量替换递归部分。
data TreeContainer a rest = Branch rest rest | Leaf a
type Tree a = Fix (TreeContainer a)

现在我们有一棵树 fold:
fold :: (TreeContainer a b -> b) -> (Tree a -> b)

你原始的foldTree看起来像这样:

foldTree :: (b -> b -> b) -> (a -> b) -> Tree a -> b

foldTree接受两个函数;我们将先进行柯里化,然后使用Either将它们合并为一个函数:

foldTree :: (Either (b, b) a -> b) -> (Tree a -> b)

我们还可以看到 Either (b, b) a 如何同 TreeContainer a b 同构。 Tree container 有两种情况:包含两个 bBranch 和包含一个 aLeaf
所以这些折叠类型与列表示例的同构方式相同。
通用化
出现了明显的模式。给定一个普通的递归数据类型,我们可以系统地创建该类型的非递归版本,从而让我们将该类型表示为一个 functor 的不动点。这意味着我们可以机械地为所有这些不同类型想出 fold 函数 - 实际上,我们可能可以使用 GHC Generics 或类似工具自动化整个过程。
在某种意义上,这意味着我们实际上并没有不同类型的不同 fold 函数。相反,我们有一个非常多态的单一 fold 函数。
更多

我最初从Conal Elliott的演讲中完全理解了这些概念。该演讲更详细地介绍了unfold,它是fold的对偶。

如果你想更深入地研究这种东西,可以阅读"Functional Programming with Bananas, Lenses, Envelopes and Barbed Wire" paper。除其他外,这还介绍了“catamorphisms”和“anamorphisms”的概念,它们对应于folds和unfolds。

代数(和余代数)

此外,我不能抵制自己的诱惑 :P。你可以看到我们在这里使用Either的方式与我在另一个SO答案中谈论代数时使用它的方式之间存在一些有趣的相似之处。

实际上,fold和代数之间存在着深刻的联系;此外,unfold--上述fold的对偶--与余代数相关联,它是代数的对偶。重要的思想是:代数数据类型对应于“初始代数”,这也定义了我回答中其余部分所概述的折叠。
您可以在fold的一般类型中看到这种联系:
fold :: Functor f => (f a -> a) -> (Fix f -> a)

f a -> a 这个术语看起来非常熟悉!请记住,f-代数的定义类似于:

class Functor f => Algebra f a where
  op :: f a -> a

所以我们可以把fold简单地理解为:

fold :: Algebra f a => Fix f -> a

基本上,fold 只是让我们“总结”使用代数定义的结构。

2
读了大约8次之后,我意识到 ListContainer a btype ListContainer a rest 并不是用作列表,而是作为一对 ("列表头", "剩余部分"),也就是 fmap f (Cons "列表头" "剩余部分"),即 Cons "列表头" (f "剩余部分"),其中 ffold h,这当然正是你所写的。 - misterbee

64
Tikhon已经掌握了技术方面的内容。我想我会尝试从他所说的内容中简化出来。
不幸的是,“folding”一词多年来变得模糊,意味着以下两种情况之一:
1.以某种顺序逐步减少集合。在Haskell中,“折叠”是指larsmans提到的“Foldable”类别。 2.您所要求的概念:“解构”(相对于“构造”),根据其结构观察或消除代数数据类型。也被称为“范畴论”。
可以通用地定义这两个概念,使一个参数化函数能够为各种类型执行它们。 Tikhon展示了如何处理第二种情况。
但是,大多数情况下,使用Fix和代数等方式进行全面处理是过度的。让我们找出一种更简单的编写任何代数数据类型的折叠方法。 我们将使用Maybe、pairs、lists和trees作为示例:
data Maybe a = Nothing | Just a
data Pair a b = Pair a b
data List a = Nil | Cons a (List a)
data Tree x = Leaf x | Branch (Tree x) (Tree x)
data BTree a = Empty | Node a (BTree a) (BTree a)

请注意,Pair 不是递归的;我要展示的过程并不假设“fold”类型是递归的。人们通常不称这种情况为“fold”,但它实际上是相同概念的非递归情况。
第一步:给定类型的 fold 将消耗折叠类型,并产生某些参数类型作为其结果。我喜欢称后者为 r(表示“结果”)。所以:
foldMaybe :: ... -> Maybe a -> r
foldPair  :: ... -> Pair a b -> r
foldList  :: ... -> List a -> r
foldTree  :: ... -> Tree a -> r
foldBTree :: ... -> BTree a -> r

第二步:除了最后一个参数(结构体参数),fold 还需要与类型的构造函数数量相同的参数。 Pair 只有一个构造函数,而我们的其他示例有两个,所以:
foldMaybe :: nothing -> just -> Maybe a -> r
foldPair  :: pair -> Pair a b -> r 
foldList  :: nil -> cons -> List a -> r
foldTree  :: leaf -> branch -> Tree a -> r
foldBTree :: empty -> node -> BTree a -> r

第三步:每个参数的元数与其对应的构造函数相同。让我们将构造函数视为函数,并编写它们的类型(确保类型变量与我们正在编写的签名中的变量匹配):
Nothing :: Maybe a
Just    :: a -> Maybe a

Pair    :: a -> b -> Pair a b

Nil     :: List a
Cons    :: a -> List a -> List a

Leaf    :: a -> Tree a
Branch  :: Tree a -> Tree a -> Tree a

Empty   :: BTree a
Node    :: a -> BTree a -> BTree a -> BTree a

步骤4:在每个构造函数的签名中,我们将所有构造数据类型的出现替换为我们正在折叠签名中使用的类型变量r
nothing := r
just    := a -> r

pair    := a -> b -> r

nil     := r
cons    := a -> r -> r

leaf    := a -> r
branch  := r -> r -> r

empty   := r
node    := a -> r -> r -> r

如您所见,我已经将第二步中得到的签名“分配”给了我的哑类型变量。现在是第五步:将它们填入之前的草图折叠签名中:
foldMaybe :: r -> (a -> r) -> Maybe a -> r
foldPair  :: (a -> b -> r) -> Pair a b -> r 
foldList  :: r -> (a -> r -> r) -> List a -> r
foldTree  :: (a -> r) -> (r -> r -> r) -> Tree a -> r
foldBTree :: r -> (a -> r -> r -> r) -> BTree a -> r

现在,这些是那些类型的折叠签名。它们有一个有趣的参数顺序,因为我通过读取数据声明和构造函数类型来机械地完成了这个过程,但由于某种原因,在函数式编程中,通常将基本情况放在数据定义的前面,而将递归情况处理程序放在fold定义的前面。没问题!让我们重新排列它们,使它们更符合惯例:
foldMaybe :: (a -> r) -> r -> Maybe a -> r
foldPair  :: (a -> b -> r) -> Pair a b -> r 
foldList  :: (a -> r -> r) -> r -> List a -> r
foldTree  :: (r -> r -> r) -> (a -> r) -> Tree a -> r
foldBTree :: (a -> r -> r -> r) -> r -> BTree a -> r

定义也可以通过机械方式填写。让我们选择foldBTree并逐步实现它。给定类型的折叠是该类型的一个函数,我们已经确定了符合以下条件的函数:使用类型的构造函数折叠是该类型上的恒等函数(得到的结果与您开始的值相同)。
我们将从这里开始:
foldBTree :: (a -> r -> r -> r) -> r -> BTree a -> r
foldBTree = ???

我们知道它需要三个参数,因此我们可以添加变量来反映它们。我会使用长而描述性的名称:
foldBTree :: (a -> r -> r -> r) -> r -> BTree a -> r
foldBTree branch empty tree = ???

查看数据声明,我们知道BTree有两种可能的构造函数。我们可以将定义拆分为每个情况,并填写它们各自元素的变量:
foldBTree :: (a -> r -> r -> r) -> r -> BTree a -> r
foldBTree branch empty Empty = ???
foldBTree branch empty (Branch a l r) = ???
    -- Let's use comments to keep track of the types:
    -- a :: a
    -- l, r :: BTree a

现在,除了像undefined这样的东西之外,填写第一个等式的唯一方法是使用empty
foldBTree :: (a -> r -> r -> r) -> r -> BTree a -> r
foldBTree branch empty Empty = empty
foldBTree branch empty (Branch a l r) = ???
    -- a :: a
    -- l, r :: BTree a

我们如何填写第二个方程?除了 undefined,我们有以下内容:
branch :: a -> r -> r -> r
a      :: a
l, r   :: BTree a

如果我们有 subfold :: BTree a -> r,我们就可以执行 branch a (subfold l) (subfold r) :: r。但是,当然,我们可以轻松地编写 'subfold':
foldBTree :: (a -> r -> r -> r) -> r -> BTree a -> r
foldBTree branch empty Empty = empty
foldBTree branch empty (Branch a l r) = branch a (subfold l) (subfold r)
    where subfold = foldBTree branch empty

这是关于BTree的折叠,因为foldBTree Branch Empty anyTree == anyTree。请注意,foldBTree并不是这种类型的唯一函数;还有这个:
mangleBTree :: (a -> r -> r -> r) -> r -> BTree a -> r
mangleBTree branch empty Empty = empty
mangleBTree branch empty (Branch a l r) = branch a (submangle r) (submangle l)
    where submangle = mangleBTree branch empty

但总的来说,mangleBTree 没有所需的属性;例如,如果我们有 foo = Branch 1 (Branch 2 Empty Empty) Empty,则得出 mangleBTree Branch Empty foo /= foo。因此,尽管 mangleBTree 具有正确的类型,但它不是 fold 函数。
现在,让我们暂且不考虑细节,集中关注最后一个点,即 mangleTree 示例。在结构意义上,折叠(答案顶部的#2)仅是一种最简单、非平凡函数的代数类型,当您将类型的构造函数作为其参数传递时,它成为该类型的恒等函数。(非平凡指像foo f z xs = xs这样的事情是不允许的。)
这非常重要。我喜欢思考两种方式如下:
  1. 对于给定类型,fold 函数可以“看到”该类型任何值所包含的所有信息。(这就是为什么它能够使用类型的构造函数从头开始完美地“重建”该类型的任何值。)
  2. 对于该类型,fold 函数是最通用的“消费者”函数。任何消费该类型值的函数都可以被编写为仅使用该类型的 fold 函数和构造函数。(尽管某些函数的仅使用 fold 函数版本很难编写并且性能不佳;试着使用 foldr、(:) 和 [] 写出 tail :: [a] -> [a] 是一项痛苦的练习。)

第二点甚至更进一步,因为你甚至不需要构造函数。你可以使用仅有的 fold 函数来实现任何代数类型,而不使用 data 声明或构造函数:

{-# LANGUAGE RankNTypes #-}

-- | A Church-encoded list is a function that takes the two 'foldr' arguments
-- and produces a result from them.
newtype ChurchList a = 
    ChurchList { runList :: forall r. 
                            (a -> r -> r)  -- ^ first arg of 'foldr'
                         -> r              -- ^ second arg of 'foldr'
                         -> r              -- ^ 'foldr' result
               }

-- | Convenience function: make a ChurchList out of a regular list
toChurchList :: [a] -> ChurchList a
toChurchList xs = ChurchList (\kons knil -> foldr kons knil xs)

-- | 'toChurchList' isn't actually needed, however, we can make do without '[]'
-- completely.
cons :: a -> ChurchList a -> ChurchList a
cons x xs = ChurchList (\f z -> f x (runlist xs f z))

nil :: ChurchList a
nil = ChurchList (\f z -> z)

foldr' :: (a -> r -> r) -> r -> ChurchList a -> r
foldr' f z xs = runList xs f z

head :: ChurchList a -> Maybe a
head = foldr' ((Just .) . const) Nothing

append :: ChurchList a -> ChurchList a -> ChurchList a
append xs ys = foldr' cons ys xs

-- | Convert a 'ChurchList' to a regular list.
fromChurchList :: ChurchList a -> [a]
fromChurchList xs = runList xs (:) []

作为一种练习,您可以尝试使用这种方式编写其他类型(使用RankNTypes扩展——阅读此处以获取入门指南)。这种技术称为Church编码,有时在实际编程中很有用——例如,GHC使用一些称为foldr/build融合的东西来优化列表代码以去除中间列表;请参见这个Haskell Wiki页面,注意build的类型。
build :: (forall b. (a -> b -> b) -> b -> b) -> [a]
build g = g (:) []

除了`newtype`之外,这与我上面的`fromChurchList`相同。基本上,GHC用于优化列表处理代码的规则之一是:
-- Don't materialize the list if all we're going to do with it is
-- fold it right away:
foldr kons knil (fromChurchList xs) ==> runChurchList xs kons knil

通过将基本的列表功能实现为在内部使用 Church 编码,积极地内联它们的定义,并将此规则应用于内联代码,函数如 map 的嵌套使用可以融合成一个紧凑的循环。

这里有许多有趣的答案,但是这个答案最接近我实际提出的问题(即术语)的回答。 - MathematicalOrchid
对于 Church 编码的实际应用,我曾经试图证明它的学习是有意义的,但我的研究结果表明它只是一种无用的抽象,仅用于计算机科学中某些特殊的证明。现在我看到了它的实际用途。 - Hi-Angel
在这些示例中,从未有一个具有非多态组件的构造函数,即 data BTreeWithId a = Empty | Node a (BTreeWithId a) (BTreeWithId a) Int。如果有一个这样的例子会很好。 - YoTengoUnLCD
根据Gabriel Gonzalez的回答,@YoTengoUnLCD应该是foldBTreeWithId :: b -> (a -> b -> b -> Int -> b) -> BTreeWithId a -> b - Will Ness

39

一个折叠函数能够替换每个构造器(constructor)成为一个函数。

例如,foldr cons nil 可以将每个 (:) 替换成 cons ,将每个 [] 替换成 nil

foldr cons nil ((:) 1 ((:) 2 [])) = cons 1 (cons 2 nil)

对于一棵树,foldTree branch leaf 将每个 Branch 替换为 branch,每个 Leaf 替换为 leaf

foldTree branch leaf (Branch (Branch (Leaf 1) (Leaf 2)) (Leaf 3))
    = branch (branch (leaf 1) (leaf 2)) (leaf 2)

这就是为什么每个折叠都接受与构造函数完全相同类型的参数的原因:

foldr :: (a -> list -> list) -> list -> [a] -> list

foldTree :: (tree -> tree -> tree) -> (a -> tree) -> Tree a -> tree

1
在这种方式下,能否谈论如何防止Luis Casillas回答中提到的“名称修饰”问题? - Will Ness
3
证明你没有改变树的方法很简单。只需将原始构造函数传递给fold并证明你会得到相同的树。以列表为例:foldr (:) [] l = l。以树为例:foldTree Branch Leaf t = t。如果你将构造函数作为参数传递,Luis提供的改变示例不会返回相同的树。 - Gabriella Gonzalez

7

3
虽然使用Monoid而不是适当的Tree代数并不理想。 - user824425

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接