66

考虑一个单链表。它看起来像

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

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

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

从某种意义上说,reduce f x0替换every Nodewithf和every Endwith x0。这就是 Prelude 所说的折叠

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

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

同样自然地定义一个函数,例如

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

请注意,这种减少具有完全不同的特征。虽然基于列表的方法本质上是顺序的,但这种基于树的新方法给人一种分而治之的感觉。你甚至可以想象par在那里扔几个组合器。(你会把这样的东西放在列表版本的什么地方?)

我的问题:此功能是否仍归类为“折叠”,还是其他?(如果是这样,那是什么?)

基本上每当有人谈论折叠时,他们总是谈论折叠列表,这本质上是顺序的。我想知道“顺序”是否是折叠定义的一部分,或者这是否仅仅是最常用的折叠示例的巧合属性。

4

4 回答 4

66

适合各种场合的折叠

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

这个通用的概念fold对应于他的评论中提到的@pelotom 的catamorphisms。

递归类型

关键的见解是这些fold函数是在递归类型上定义的。尤其:

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

这两种类型显然都是递归List的——在这种Cons情况下和Tree在这种Branch情况下。

固定点

就像函数一样,我们可以使用固定点重写这些类型。记住 的定义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允许我们将ListContainers 嵌套到任意深度。所以我们可以有:

Roll Nil
Roll (Cons 1 (Roll Nil))
Roll (Cons 1 (Roll (Cons 2 (Roll Nil))))

分别对应于[]和。[1][1,2]

看到这ListContainerFunctor容易:

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

我认为从ListContainerto的映射List非常自然:我们没有显式递归,而是将递归部分设为变量。然后我们只需要Fix根据需要填写该变量。

我们也可以写一个类似的类型Tree

“展开”固定点

那么我们为什么要关心呢?我们可以定义fold使用. 尤其:Fix

fold :: Functor f => (f a -> a) -> (Fix f -> a)
fold h = h . fmap (fold h) . unRoll
  where unRoll (Roll a) = a

本质上,折叠所做的只是一次展开一层“滚动”类型,每次都对结果应用一个函数。这种“展开”让我们可以为任何递归类型定义折叠,并巧妙而自然地概括这个概念。

对于列表示例,它的工作方式如下:

  1. 在每一步,我们打开包装Roll以获得 aCons或 aNil
  2. 我们使用 对列表的其余部分进行递归fmap
    1. 在这种Nil情况下,fmap (fold h) Nil = Nil,所以我们只返回Nil
    2. 在这种Cons情况下,fmap只是继续折叠列表的其余部分。
  3. 最后,我们得到了一堆以 --fold结尾的嵌套调用,Nil就像标准一样foldr

比较类型

现在让我们看看两个折叠函数的类型。首先,foldr

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

现在,fold专门用于ListContainer

fold :: (ListContainer a b -> b) -> (Fix (ListContainer a) -> b)

起初,这些看起来完全不同。但是,通过一点按摩,我们可以证明它们是相同的。的前两个参数foldra -> b -> bb。我们有一个函数和一个常数。我们可以认为b() -> b。现在我们有两个函数_ -> bwhere _is()a -> b. 为了让生活更简单,让我们 curry 给我们的第二个函数(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,同时具有 ana和 a b。换句话说,Nilis like()Consis like (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。树容器有两种情况:Branch,包含两个bs 和Leaf,包含一个a

所以这些折叠类型是同构的,与列表示例相同。

概括

有一个清晰的模式正在出现。给定一个正常的递归数据类型,我们可以系统地创建该类型的非递归版本,这让我们可以将类型表示为函子的不动点。这意味着我们可以机械地提出fold所有这些不同类型的函数——事实上,我们可能可以使用 GHC 泛型或类似的东西来自动化整个过程。

从某种意义上说,这意味着我们并没有真正fold为不同类型提供不同的功能。相反,我们有一个非常fold多态的函数。

更多的

我首先从Conal Elliott的一次演讲中完全理解了这些想法。这更详细,也谈到了unfold,这是对偶fold

如果您想更深入地研究这类事情,请阅读精彩的“使用香蕉、镜头、信封和铁丝网进行函数式编程”论文。除其他外,这引入了与折叠和展开相对应的“变形”和“变形”的概念。

代数(和代数)

此外,我无法抗拒为自己添加一个插头:P。您可以看到我们在此处使用的方式与我在另一个 SO 答案中Either谈论代数时使用它的方式之间存在一些有趣的相似之处。

fold代数和代数之间其实有很深的联系;此外,unfold--前面提到的--的对偶fold连接到coalgebras,它是代数的对偶。重要的想法是代数数据类型对应于“初始代数”,它也定义了我在其余答案中概述的折叠。

您可以在以下一般类型中看到此连接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只是让我们“总结”使用代数定义的结构。

于 2013-05-07T19:25:26.613 回答
65

Tikhon 已经掌握了技术资料。我想我会尽量简化他所说的。

不幸的是,多年来,“折叠”一词变得模棱两可,意味着以下两种情况之一:

  1. 以某种顺序依次减少集合。在 Haskell 中,这就是Foldablelarsmans 提出的类中“折叠”的含义。
  2. 您要求的概念:根据其结构“破坏”(与构造相反),“观察”或“消除”代数数据类型。也称为变质

可以通用地定义这两个概念,以便一个参数化函数能够为各种类型执行此操作。Tikhon 展示了在第二种情况下如何做。

但大多数情况下,一直使用Fix代数等是矫枉过正的。让我们找出一种更简单的方法来为任何代数数据类型编写折叠。我们将使用Maybe、对、列表和树作为示例:

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不是递归的;我将要展示的过程并不假定“折叠”类型是递归的。人们通常不将这种情况称为“折叠”,但它实际上是同一概念的非递归情况。

第一步:给定类型的折叠将消耗折叠类型并产生一些参数类型作为其结果。我喜欢称后者r(代表“结果”)。所以:

foldMaybe :: ... -> Maybe a -> r
foldPair  :: ... -> Pair a b -> r
foldList  :: ... -> List a -> r
foldTree  :: ... -> Tree a -> r
foldBTree :: ... -> BTree a -> r

第二步:除了最后一个参数(结构的参数)之外,折叠的参数与类型具有构造函数一样多。 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

如您所见,我已将生成的签名“分配”到第二步中的虚拟类型变量。现在第 5 步:将它们填写到早期的草图折叠签名中:

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

现在,这些是这些类型的折叠的签名。他们有一个有趣的参数顺序,因为我通过从data声明和构造函数类型中读取折叠类型来机械地做到这一点,但由于某种原因,在函数式编程中,通常将基本案例放在data定义中,而递归案例处理程序放在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 = ???

查看data声明,我们知道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。但当然,我们可以轻松地编写“子折叠”:

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,尽管它具有正确的类型,但不是折叠。


现在,让我们从细节上退后一步,并通过mangleTree示例专注于最后一点。折叠(在结构意义上,我的答案顶部的#2)只不过是代数类型的最简单,非平凡的函数,这样,当您将类型的构造函数作为其参数传递时,它成为该类型的标识函数。foo f z xs = xs(我所说的不平凡是指不允许这样的事情。)

这是非常重要的。我喜欢思考的两种方式如下:

  1. 给定类型的折叠可以“看到”该类型的任何值包含的所有信息。(这就是为什么它能够使用类型的构造函数从头开始完美地“重​​构”该类型的任何值。)
  2. 折叠是该类型最通用的“消费者”功能。可以编写使用相关类型值的任何函数,以便它使用该类型的唯一操作是折叠和构造函数。(尽管某些函数的仅折叠版本很难编写并且性能很差;尝试tail :: [a] -> [a]使用foldr,(:)[]作为一项痛苦的练习来编写。)

第二点更进一步,因为您甚至不需要构造函数。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扩展名—<a href="https://stackoverflow.com/questions/12031878/what-is-the- purpose -of-rank2types/12033549#12033549 ">阅读此书作为入门读物)。这种技术称为Church encoding,有时在实际编程中很有用——例如,GHC 使用一种叫做foldr/ buildfusion 的东西来优化列表代码以去除中间列表;请参阅此 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可以将函数的嵌套使用融合到一个紧密的循环中。

于 2013-05-07T23:37:56.813 回答
39

fold 用函数替换了每个构造函数。

例如,用和替换foldr cons nilevery :(:)cons[]nil

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

对于一棵树,用foldTree branch leaf替换every和every Branchwith :branchLeafleaf

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
于 2013-05-08T04:34:20.253 回答
7

我称之为折叠,并声明Tree一个Foldable. 请参阅FoldableGHC 文档中的示例

于 2013-05-07T19:15:13.810 回答