【问题标题】:when to use CPS vs codensity vs reflection without remorse in Haskell何时在 Haskell 中无悔地使用 CPS、共密度和反射
【发布时间】:2018-01-02 06:00:31
【问题描述】:

在 Haskell 中创建 monad 时,何时使用 continuation-passing style vs codensity vs reflection without remorse 有什么经验法则吗?

作为一个例子,我将使用一个简单的协程 monad。如果您以前从未见过这个,您可能想查看Monad.Reader Issue 19pipes 库中的“协程管道”文章。以下示例的完整代码可以在this repository 中找到。

  1. 正常

    这只是一个定义为数据类型的普通 monad:

    data FooM i o a
      = Await (i -> FooM i o a)
      | Yield o (FooM i o a)
      | Done a
    

    这种风格在 Haskell 生态系统中被广泛使用。这种风格的一个例子是来自pipesProxy 数据类型。

  2. 持续传递风格 (CPS)

    这类似于普通风格,但每个数据构造函数都变成了一个延续的参数:

    newtype FooCPS i o a = FooCPS
      { runFooCPS
          :: forall r.
             ((i -> FooCPS i o a) -> r)
          -> (o -> FooCPS i o a -> r)
          -> (a -> r)
          -> r
      }
    

    attoparsecparsec 都使用这种样式。

  3. 共密度

    这种风格使用 codensity monad transformer 包裹在 normal 风格中定义的 monad。这给出了 O(1) 左关联绑定。

    codensity monad 转换器如下所示:

    newtype Codensity m a = Codensity
      { runCodensity :: forall b. (a -> m b) -> m b
      }
    

    我们的实际 monad 可以使用 Codensity 转换器定义为新类型。请注意FooCodensity 如何在内部使用FooM

    newtype FooCodensity i o a = FooCodensity
      { runFooCodensity :: Codensity (FooM i o) a
      }
    

    conduitConduitM 类型中使用此样式。

  4. 无悔的反思

    这是Reflection without Remorse论文中讨论的风格。

    这类似于 normal 样式,但递归调用已成为具有 O(1) append 和 amortized O(1) uncons 的数据结构。这为 FooRWR monad 提供了 O(1) 左关联绑定和 monadic-reflection:

    data FooRWR i o a
      = AwaitRWR (forall x. i -> FooExplicit i o x a)
      | YieldRWR  o (forall x. FooExplicit i o x a)
      | DoneRWR a
    

    FooExplicit 类型定义如下:

    type FooExplicit i o = FTCQueue (FooRWR i o)
    

    FTCQueue 是一个数据结构,具有 O(1) 附加和摊销 O(1) uncons。

    freer-effectsextensible 包使用此样式。它在monad-skeleton 中作为独立库提供。


什么时候应该使用normal vs CPS vs codensity vs reflection without remorse?我想一个硬而快速的答案需要对给定的 monad 和应用程序进行基准测试,但是是否有任何经验法则

根据我自己的研究,我遇到了以下想法/cmets:

  • CPS 可能比 正常 样式更快,因为您可能不需要进行案例分析。尽管实际加速可能会根据 GHC 编译代码的方式而有所不同。 Codensityreflection without remorse 有一些开销。

    Gabriel Gonzalez(pipes 的作者)在 Github 上的 this reddit threadissue 上写了他如何坚持 pipesnormal 风格。

    Bryan O'Sullivan(attoparsec 的作者)写道,将 attoparsecnormal 样式更改为 CPS 会产生 factor of 8 speedup。该帖子中的一些 cmets 还谈到了 normal 风格与 CPS

  • 如果您需要深度左关联绑定,normal 样式和 CPS 以二次运行时结束。

    这是“无悔的反思”论文中的一个例子,它展示了二次运行时间。

    data It i a = Get (i -> It i a) | Done a
    
    sumInput :: Int -> It Int Int
    sumInput n = Get (foldl (>=>) return (replicate (n - 1) f))
      where
        f x = get >>= return . (+ x)
    

    如果sumInputcodensityreflection without remorse 重写,它将运行得非常快。

    如果您的应用程序具有深度左关联绑定,您可能应该使用 codensityreflection without remorse

    Michael Snoyman(conduit 的作者)在一篇关于 speeding upconduit 的博文中谈到了这一点。

    pipesused to provide 一个共密度转换器。

  • CPScodensity 不支持 O(1) 反射。

    这是一个需要一元反射的函数。这个例子改编自“Reflection without Remorse”论文:

    data It i a = Get (i -> It i a) | Done a
    
    par :: It i a -> It i b -> It i (It i a, It i b)
    par l r
      | Done <- l = Done (l, r)
      | Done <- r = Done (l, r)
      | Get f <- l, Get g <- r = Get Done >>= \x -> par (f x) (g x)
    

    如果不先转换回普通样式,则无法以CPScodensity样式编写此方法。 无悔的反思风格就没有这个问题。

    如果您需要一元反射,您可能应该使用正常样式或无悔的反射

  • Reflection without remorse 增加了一些开销,但它是唯一同时提供 O(1) 左关联绑定和反射的样式。

额外问题:可以从free 包。什么时候应该使用Free?什么时候应该使用F

【问题讨论】:

  • 我把这个问题发到了reddit的/r/haskell:reddit.com/r/haskell/comments/6qn4y0/…
  • 需要注意的是,在 CPS 中,您通常将所有出现的FooCPS i o a 替换为r(取决于您想要的性能)。

标签: haskell reflection monads continuations free-monad


【解决方案1】:

这个问题可以分为两部分,如何表示数据类型以及如何将它们组合在一起。

数据类型

您列出的样式仅使用 2 种数据类型样式,即“普通”样式和延续传递样式。它们的不同之处在于选择哪些对象作为语言的原语。

在普通样式中,数据类型及其构造函数被选为原始类型。数据类型是产品(包含多个值)的总和(具有多个构造函数)

data Sum a b = Left a | Right b
data Product a b = Product a b

语言的主要对象是这些数据类型和函数;这些函数解构数据以查看其中的内容并查看其作用。

either :: (a -> c) -> (b -> c) -> Sum a b -> c
either l _ (Left a)  = l a
either _ r (Right b) = r b

uncurry :: (a -> b -> c) -> Product a b -> c
uncurry f (Product a b) = f a b

您可以创建一种等效语言,将通用量化类型视为原始类型而不是数据类型。在这种情况下,您可以根据通用量化定义数据类型。总和由它们的either 函数表示,在返回类型上普遍量化。产品由它们的uncurry 函数表示,在返回类型上普遍量化。需要语言扩展 (RankNTypes) 来以这种方式表示数据类型,这暗示了为什么您将第一种样式称为“正常”。

{-# LANGUAGE RankNTypes #-}

newtype Product a b = Product (forall r. (a -> b -> r) -> r)

product :: a -> b -> Product a b
product a b = Product (\f -> f a b)

uncurry :: (a -> b -> c) -> Product a b -> c
uncurry both (Product f) = f both

newtype Sum a b = Sum (forall r. (a -> r) -> (b -> r) -> r)

left :: a -> Sum a b
left a = Sum (\l r -> l a)

right :: b -> Sum a b
right b = Sum (\l r -> r b)

either :: (a -> c) -> (b -> c) -> Sum a b -> c
either l r (Sum f) = f l r

这导致了两种样式之间的主要区别之一。在普遍量化的风格中,没有任何构造函数。数据的所有结构都必须存储在函数的闭包中,这正是构造函数 leftrightproduct 的替换位置。在普遍量化的风格中,你不能构造任何不必要的中间对象;不存在供您构造的对象。您仍然可以构造不必要的中间闭包。至少你会欺骗分析器告诉你周围没有一堆物体。

您的FooM 数据类型,在这里重复,也可以用延续传递样式表示。

data FooM i o a
  = Await (i -> FooM i o a)
  | Yield o (FooM i o a)
  | Done a

它将由我定义的matchFoo 函数表示。

matchFoo :: ((i -> FooM i o a) -> r) -> (o -> FooM i o a -> r) -> (a -> r) -> r
matchFoo a _ _ (Await f) = a f
matchFoo _ y _ (Yield o next) = y o next
matchFoo _ _ d (Done a) = d a

通用量化的FooM 用它的matchFoo 函数标识一个FooM,通用限定其返回类型。

newtype FooCPS i o a = FooCPS
  { runFooCPS
      :: forall r.
         ((i -> FooCPS i o a) -> r)
      -> (o -> FooCPS i o a -> r)
      -> (a -> r)
      -> r
  }

await :: (i -> FooCPS i o a) -> FooCPS i o a
await f = FooCPS (\a _ _ -> a f)

yield :: o -> FooCPS i o a -> FooCPS i o a
yield o next = FooCPS (\_ y _ -> y o next)

done :: a -> FooCPS i o a
done a = FooCPS (\_ _ d -> d a)

解决2中的问题

为了将相同的数据类型用于将它们组合在一起的所有方式,我们将用它的基本函子替换FooM。基本函子是普通数据类型,其中递归被类型变量替换。

data FooF i o a next
  = Await (i -> next)
  | Yield o next
  | Done a
    deriving (Functor)

您可以等效地以延续传递样式定义一个基本函子。

newtype FooFCPS i o a next = FooFCPS
  { runFooCPS
      :: forall r.
         ((i -> next) -> r)
      -> (o -> next -> r)
      -> (a -> r)
      -> r
  }
  deriving (Functor)

将它们重新组合在一起

  1. 正常

我们可以通过定义立即恢复FooM

newtype FooM i o a = FooM (FooF i o a (FooM i o a))

如果您已经定义了fixed point of a functor

newtype Fix f = Fix (f (Fix f))

那么FooM就可以通过

newtype FooM i o a = FooM (Fix (FooF i o a))
  1. 继续传递风格

续传风格可以立即从普遍量化的FooFCPS中恢复

newtype FooCPS i o a = FooCPS (Fix (FooFCPS i o a))
  1. 共密度

共密度转换器可与FooMFooCPS 一起使用。

  1. 无悔反思

我们可以根据基本函子定义反射,而无需在FooRWR 中复制数据类型FooM

newtype RWR f a = RWR { runRWR :: f (RWRExplicit f a) }

newtype RWRExplicit f a = RWRExplicit (forall x. FTCQueue (RWR f) x a)

然后用

恢复FooRWR
newtype FooRWR i o a = FooRWR {runFooRWR :: RWR (FooF i o a) a}

额外观察

免费

FreeF 都可以与基本函子 FooFFooFCPS 一起使用。

Monad 变形金刚

基础函子也可以用来构建一个单子转换器。有一个detailed discussion of building the MachineT transformer (which is closely related to FooM) in this question and answer


par 不能在 CPS 中编写而不首先转换回正常样式的说法需要一些限定,因为所有数据类型都可以替换为普遍量化的连续传递样式类型。

【讨论】:

  • 这是一个非常好的答案。它显示了正常样式和 CPS 的相似程度。我也喜欢使用FixFooFFooFCPS 进行抽象。但是,我不确定它是否能回答我的问题:何时 使用 CPS、共密度和反射,而不用后悔。我想我主要是在问一个性能问题,如果没有具体细节很难回答,但如果有任何经验法则来说明何时使用哪种风格会很有趣。
  • 我对@9​​87654367@ 的说法有误吗?我刚刚回顾了“无悔的反思”论文,他们在第 6.1 节中谈到了par。他们特别提到par 不能在没有先转换回正常样式的情况下以共密度样式编写,但他们没有说任何关于 CPS 的内容。我的印象是,在编写 CPS 代码时,monadic-reflection 是不可能的,除非您转换回正常样式。
  • 你也可以通过newtype Fix f = Product (forall r. (f r -&gt; r) -&gt; r)定义Fix。那么你甚至不需要使用递归。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2020-10-22
  • 1970-01-01
  • 2013-11-21
  • 1970-01-01
  • 2023-03-18
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多