跳转到内容

Haskell/Traversable

来自 Wikibooks,开放世界中的开放书籍

我们已经学习了 Prelude 中五个类型类中的四个,这些类型类可以用于数据结构操作:FunctorApplicativeMonadFoldable。第五个是 Traversable [1]。遍历意味着横跨,而这正是 Traversable 泛化的:横跨一个结构,在每个停靠点收集结果。

Functors 为行走而生

[编辑 | 编辑源代码]

如果遍历意味着横跨,那么我们已经很长时间一直在执行遍历了。考虑以下对列表的合理 FunctorFoldable 实例

instance Functor [] where
    fmap _ []     = []
    fmap f (x:xs) = f x : fmap f xs

instance Foldable [] where
    foldMap _ []     = mempty
    foldMap f (x:xs) = f x <> foldMap f xs

fmap f 横跨列表,将 f 应用于每个元素,并通过重建列表来收集结果。类似地,foldMap f 横跨列表,将 f 应用于每个元素,并通过使用 mappend 将结果组合起来。然而,FunctorFoldable 不足以表达所有有用的遍历方式。例如,假设我们有以下 Maybe 编码的负数测试...

deleteIfNegative :: (Num a, Ord a) => a -> Maybe a
deleteIfNegative x = if x < 0 then Nothing else Just x

... 我们想用它来实现...

rejectWithNegatives :: (Num a, Ord a) => [a] -> Maybe [a]

... 它如果在列表中没有负数元素,则返回用 Just 包装的原始列表,否则返回 NothingFoldableFunctor 本身无法提供帮助。使用 Foldable 会用我们选择的用于折叠的 Monoid 的结构替换原始列表的结构,并且无法将其扭曲成返回原始列表或 Nothing [2]。至于 Functorfmap 乍一看可能很有吸引力...

GHCi> let testList = [-5,3,2,-1,0]
GHCi> fmap deleteIfNegative testList
[Nothing,Just 3,Just 2,Nothing,Just 0]

... 但然后我们需要一种方法将 Maybe 的列表变成 Maybe 的列表。如果你眯起眼睛仔细看,这有点像折叠。但是,我们不需要仅仅合并值并销毁列表,而是需要合并值的 Maybe 上下文并在合并的上下文中重新创建列表结构。幸运的是,有一个类型类本质上是关于合并 Functor 上下文的:Applicative [3]Applicative 反过来又引导我们到我们需要的类:Traversable

instance Traversable [] where
    -- sequenceA :: Applicative f => [f a] -> f [a]
    sequenceA []     = pure []
    sequenceA (u:us) = (:) <$> u <*> sequenceA us

-- Or, equivalently:
instance Traversable [] where
    sequenceA us = foldr (\u v -> (:) <$> u <*> v) (pure []) us

Traversable 对于 Applicative 上下文就像 Foldable 对于 Monoid 值一样。从这个角度来看,sequenceAfold 相似 - 它创建了结构中上下文的应用性摘要,然后在新的上下文中重建结构。sequenceA 正是我们一直在寻找的函数

GHCi> let rejectWithNegatives = sequenceA . fmap deleteIfNegative
GHCi> :t rejectWithNegatives 
rejectWithNegatives
  :: (Num a, Ord a, Traversable t) => t a -> Maybe (t a)
GHCi> rejectWithNegatives testList
Nothing
GHCi> rejectWithNegatives [0..10]
Just [0,1,2,3,4,5,6,7,8,9,10]

这些是 Traversable 的方法

class (Functor t, Foldable t) => Traversable t where
    traverse  :: Applicative f => (a -> f b) -> t a -> f (t b)
    sequenceA :: Applicative f => t (f a) -> f (t a)

    -- These methods have default definitions.
    -- They are merely specialised versions of the other two.
    mapM      :: Monad m => (a -> m b) -> t a -> m (t b)
    sequence  :: Monad m => t (m a) -> m (t a)

如果 sequenceAfold 相似,traverse 就与 foldMap 相似。它们可以相互定义,因此 Traversable 的最小实现只需要提供其中一个

traverse f = sequenceA . fmap f
sequenceA = traverse id

使用 traverse 重写列表实例使与 FunctorFoldable 的平行关系变得显而易见

instance Traversable [] where
    traverse _ []     = pure []
    traverse f (x:xs) = (:) <$> f x <*> traverse f xs

-- Or, equivalently:
instance Traversable [] where
    traverse f xs = foldr (\x v -> (:) <$> f x <*> v) (pure []) xs

通常,在实现 Traversable 时最好写 traverse,因为 traverse 的默认定义原则上会对结构执行两次遍历(一次用于 fmap,另一次用于 sequenceA)。

我们可以用 traverse 清晰地直接定义 rejectWithNegatives

rejectWithNegatives :: (Num a, Ord a, Traversable t) => t a -> Maybe (t a)
rejectWithNegatives = traverse deleteIfNegative
练习
  1. 其他数据结构 中给出 TreeTraversable 实例。Tree 的定义是
    data Tree a = Leaf a | Branch (Tree a) (Tree a)

Traversable 的解释

[编辑 | 编辑源代码]

Traversable 结构可以使用你选择的应用性函子来遍历。traverse 的类型...

traverse :: (Applicative f, Traversable t) => (a -> f b) -> t a -> f (t b)

... 与我们在其他类中看到的映射函数的类型相似。traverse 不会使用它的函数参数在原始结构下插入函子上下文(就像 fmap 所做的那样),也不会修改结构本身(就像 (>>=) 所做的那样),而是添加了一个额外的上下文层 结构的 顶部。换句话说,traverse 允许进行 有副作用的遍历 - 产生整体效果的遍历(即新的外部上下文层)。

如果新层下面的结构可以恢复,它将与原始结构匹配(当然,值可能已经改变)。这是一个涉及嵌套列表的示例

GHCi> traverse (\x -> [0..x]) [0..2]
[[0,0,0],[0,0,1],[0,0,2],[0,1,0],[0,1,1],[0,1,2]]

为了理解这里发生了什么,让我们一步一步地分解它。

traverse (\x -> [0..x]) [0..2]
sequenceA $ fmap (\x -> [0..x]) [0..2]
sequenceA [[0],[0,1],[0,1,2]]
(:) <$> [0] <*> ((:) <$> [0,1] <*> ((:) <$> [0,1,2] <*> pure []))
(:) <$> [0] <*> ((:) <$> [0,1] <*> ([[0],[1],[2]]))
(:) <$> [0] <*> ([[0,0],[0,1],[0,2],[1,0],[1,1],[1,2]])
[[0,0,0],[0,0,1],[0,0,2],[0,1,0],[0,1,1],[0,1,2]]

内部列表保留了原始列表的结构 - 它们都有三个元素。外部列表是新的层,对应于通过允许每个元素从零到其(原始)值变化来引入不确定性。

我们也可以通过关注 sequenceA 以及它如何 分配 上下文来理解 Traversable

GHCi> sequenceA [[1,2,3,4],[5,6,7]]
[[1,5],[1,6],[1,7],[2,5],[2,6],[2,7]
,[3,5],[3,6],[3,7],[4,5],[4,6],[4,7]
]

在这个例子中,sequenceA 可以看作将旧的外部结构分配到新的外部结构中,因此新的内部列表有两个元素,就像旧的外部列表一样。新的外部结构是一个包含十二个元素的列表,这正是你期望用 (<*>) 将一个包含四个元素的列表与另一个包含三个元素的列表组合起来的结果。从分配的角度来看,一个有趣的方面是它如何帮助理解为什么某些函子不可能拥有 Traversable 的实例(如何分配 IO 操作?或者函数?)。

练习

如果你还记得应用函子那一章的内容,可以帮助你完成以下练习。

  1. 考虑用嵌套列表表示矩阵,其中内层列表代表行。使用Traversable实现
    transpose :: [[a]] -> [[a]]
    该函数用于转置矩阵(即,将列变为行,反之亦然)。在本练习中,我们不关心如何处理行大小不同的“伪矩阵”。
  2. 解释traverse mappend的作用。
  3. 是时候进行一轮“找出应用函子”的游戏了。考虑
    mapAccumL :: Traversable t =>
    (a -> b -> (a, c)) -> a -> t b -> (a, t c)

    它的类型让你想起什么吗?使用合适的Applicative,并结合Traversable来实现它。为了提供进一步的指导,这里列出了Data.Traversable 文档中对mapAccumL的描述

    mapAccumL 函数的行为类似于 fmap 和 foldl 的组合;它将一个函数应用于结构中的每个元素,从左到右传递一个累积参数,并返回该累积器的最终值以及新的结构。

Traversable 法则

[edit | edit source]

合理的Traversable 实例需要遵循一些法则。有以下两个法则

traverse Identity = Identity -- identity
traverse (Compose . fmap g . f) = Compose . fmap (traverse g) . traverse f -- composition

还有一个额外的法则,它保证成立

-- If t is an applicative homomorphism, then
t . traverse f = traverse (t . f) -- naturality

这些法则不是自解释的,所以让我们仔细看看它们。从最后一个开始:应用函子同态是一个保持Applicative 操作的函数,因此

-- Given a choice of f and g, and for any a,
t :: (Applicative f, Applicative g) => f a -> g a

t (pure x) = pure x
t (x <*> y) = t x <*> t y

需要注意的是,不仅这个定义与我们之前见过的幺半群同态的定义类似,而且自然性法则完全反映了关于Foldable 那一章中关于foldMap 和幺半群同态的性质。

恒等式法则涉及Identity,它是哑函子

newtype Identity a = Identity { runIdentity :: a }

instance Functor Identity where
    fmap f (Identity x) = Identity (f x)

instance Applicative Identity where
    pure x = Identity x
    Identity f <*> Identity x = Identity (f x)

该法则指出,所有使用Identity 构造函数进行的遍历都只是用Identity 包装结构,这相当于什么也不做(因为可以使用runIdentity 轻松恢复原始结构)。因此,Identity 构造函数是恒等遍历,这确实非常合理。

反过来,组合法则是在Compose 函子的基础上陈述的

newtype Compose f g a = Compose { getCompose :: f (g a) }

instance (Functor f, Functor g) => Functor (Compose f g) where
    fmap f (Compose x) = Compose (fmap (fmap f) x)

instance (Applicative f, Applicative g) => Applicative (Compose f g) where
    pure x = Compose (pure (pure x))
    Compose f <*> Compose x = Compose ((<*>) <$> f <*> x)

Compose 执行函子的组合。组合两个Functor 会得到一个Functor,组合两个Applicative 会得到一个Applicative [4]。这些实例是显而易见的,将方法向下一层函子层级传递。

组合法则指出,我们是否分别执行两次遍历(等式右边)或将它们组合起来以仅遍历结构一次(等式左边)无关紧要。例如,它类似于第二个函子法则。fmap 是必需的,因为第二次遍历(或等式左边第一次(部分)遍历之后的第二次遍历)发生在第一次(部分)遍历添加的结构层之下。Compose 是必需的,以便组合后的遍历应用于正确的层级。

IdentityCompose 分别来自 Data.Functor.IdentityData.Functor.Compose

这些法则也可以用sequenceA 来表述

sequenceA . fmap Identity = Identity -- identity
sequenceA . fmap Compose = Compose . fmap sequenceA . sequenceA -- composition
-- For any applicative homomorphism t:
t . sequenceA = sequenceA . fmap t -- naturality

虽然并不明显,但遍历的几个理想特征都源于这些法则,包括 [5]

  • 遍历不会跳过元素。
  • 遍历不会访问元素多次。
  • traverse pure = pure
  • 遍历不会修改原始结构(它要么保留,要么完全销毁)。

恢复fmapfoldMap

[edit | edit source]

我们还没有证明TraversableFunctorFoldable 类约束。原因很简单:只要Traversable 实例遵循法则,traverse 就足以实现fmapfoldMap。对于fmap,我们只需要使用Identity 将任意函数转换为遍历即可

fmap f = runIdentity . traverse (Identity . f)

为了恢复foldMap,我们需要引入第三个辅助函子:Const,来自 Control-Applicative

newtype Const a b = Const { getConst :: a }

instance Functor (Const a) where
    fmap _ (Const x) = Const x

Const 是一个常量函子Const a b 类型的值实际上不包含b 值。相反,它包含一个a 值,该值不受fmap 的影响。对于我们当前的目的,真正有趣的实例是Applicative 实例

instance Monoid a => Applicative (Const a) where
    pure _ = Const mempty
    Const x <*> Const y = Const (x `mappend` y)

(<*>) 只是用mappend [6] 组合每个上下文中的值。我们可以利用这一点,将任何Monoid m => a -> m 函数转换为遍历,我们可以将这些函数传递给foldMap。由于上面的实例,遍历就变成了折叠

foldMap f = getConst . traverse (Const . f)

我们刚刚从traverse 中恢复了两个表面上看起来完全不同的函数,而我们所做的只是选择了两个不同的函子。这体现了函子作为抽象概念的强大之处 [7]

注释

  1. 严格地说,我们应该参考 GHC Prelude 中的五个类,因为根据 Haskell 报告ApplicativeFoldableTraversable 还没有正式成为 Prelude 的一部分。不过,它们迟早会加入。
  2. 一个尝试的方法是利用 Data.Monoid 中的Monoid a => Monoid (Maybe a) 实例。但是,如果你尝试这样做,你会发现它不可能得到预期的结果。
  3. 幺半群表示法 对此进行了非常清晰的说明。
  4. 然而,值得注意的是,组合两个Monad 并不一定会得到一个Monad
  5. 有关技术细节,请查看 Data.Traversable 文档中引用的论文。
  6. 这很好地说明了Applicative 如何以幺半群的方式组合上下文。如果我们移除上下文中的值,幺半群表示法 中的应用函子法则与幺半群法则完全一致。
  7. 一个主要的例子,而且是一个具有明显实际意义的例子,就是对函子的颂歌,lens 库。
华夏公益教科书