何时应该使用Kleisli?

40

我最近接触到了Kleisli的概念,每篇教程/链接/参考文献都通过以下结构来推动使用Kleisli:

  1. 组合返回单子(monad)的函数:对于f: a -> m[b]g: b -> m[c]这种情况——我认为单子的定义已经涵盖了这种情况——do/bind/for/flatMap可以实现。不需要依赖Kleisli来完成。因此,在我看来,这不是Kleisli的"主要"用途。
  2. 插入配置信息:这一点说明,如果多个对象(类型、案例/数据类等)需要注入一个Config,则可以使用Kleisli构造来抽象掉可重复的注入过程。有许多实现方式(例如Scala中的implicit),可能并不需要调用Kleisli。同样地,我认为这也不是Kleisli的"主要"用途。
  3. Monad变换器: 我对此没有很深刻的理解,但以下是我的解释:如果你需要“组合monad”,你需要一种允许你参数化monad本身的构造。例如,M1[M2[M1[M2[a]]]]可以转换为[M1[M2[a]]],这个结果可能(我可能会错)通过跨monadic边界进行压缩以与a -> M3[b](比如)组合。为了实现这个目的,我们可以使用Kleisli三元组并调用构造函数,因为如果你从头开始做,你可能只是重新发明了Kleisli。这似乎是证明使用Kleisli的一个很好的候选案例。这是正确的吗?
  4. 我认为上述#1-#2是“次要用途”。也就是说,如果你确实使用Kleisli构造函数,你还可以获得返回monad的函数组合模式和配置注入。然而,它们不能成为支持Kleisli强大能力的激励问题

    在假定使用最不强大的抽象来解决手头的问题的前提下,有哪些激励性问题可以用来展示它们的使用?

    备选论点: 完全有可能我完全错了,我的 Kleisli 理解不正确。我缺乏必要的范畴论背景,但是它可以作为单子的替代品使用,它们(Kleisli)是我们在函数世界问题中看待问题的范畴论镜头(即,Klesli 只是包装了一个单子函数 a -> M[b],现在我们可以在更高的抽象层次上工作,在这个层次上,函数是“操作”的对象,而不是“使用”的对象)。因此,Kleisli 的使用可以简单地理解为“带 Kleisli 的函数式编程”。如果这是真的,那么就应该存在一种情况,Kleisli 可以比现有结构更好地解决问题,然后我们又回到了一个“激励问题”的问题。同样有可能的是,并没有这样的“激励问题”,如果它只是提供了对同一问题的“不同”解决方案的“镜头”。哪一个呢?

    能够获得一些输入以重构 Kleisli 的需求将非常有帮助。


3
Kleisli不是必须要强烈动机才能使用的重要事物。它只是一个当有单子(monad)时你可以倾斜头的方式。你可以把“单子值”看作主要内容,也可以把“带效应的转换”看作主要内容,而kliesli箭头的概念则是二者之间的关系。就像这个抽象世界中的许多东西一样,其强大之处在于组合。例如,“我需要的是ListKleisli箭头的Choice变换”,就像这样,你就有了适当的强大基础来表达你的思想。 - luqui
@luqui - 你可能是对的。我明白这不是一个“大事情”-我的困惑在于_这是什么东西_以及_何时/在哪里/如何_可以掌握它的用处。也许 Kleisli 提供了一个“镜头”,就像你建议的那样,您可以把“单子值”或“有影响的转换”视为_主要内容_。一旦您选择了 POV,您可以相应地表达您的想法。在我目前的 POV 中,Kleisli 看起来似乎不值得关注。但是,我很_好奇_,如果我给它注意力,从一个元 POV 可以学到什么。 - PhD
2
“Kleisli”本身并不是一个东西。Kleisli箭头的概念来自范畴论,但在Haskell中,您可以将其视为函数的一般化。给定一个函数f :: a -> b和一个单子m,相应的Kleisli箭头是一个新函数f' :: a -> m b。(我假设Scala也是如此,只是使用不同的语法。) - chepner
3个回答

20

Kleisli,也称为ReaderT,从实际角度来看是第2点(并且如我后面所示的那样,是第3点) - 将同一组件的依赖项注入到多个函数中。 如果我有:

val makeDB: Config => IO[Database]
val makeHttp: Config => IO[HttpClient]
val makeCache: Config => IO[RedisClient]

那么我可以这样将事物结合成一个单子:

def program(config: Config) = for {
  db <- makeDB(config)
  http <- makeHttp(config)
  cache <- makeCache(config)
  ...
} yield someResult

但是手动传递参数会很麻烦。所以,我们可以将Config =>部分作为类型的一部分,并在没有它的情况下进行单子组合。

val program: Kleisli[IO, Config, Result] = for {
  db <- Kleisli(makeDB)
  http <- Kleisli(makeHttp)
  cache <- Kliesli(makeCache)
  ...
} yield someResult

如果我的所有函数一开始都是 Kleisli 函数,那么我就可以跳过 for 推导式中的 Kleisli(...) 部分。

val program: Kleisli[IO, Config, Result] = for {
  db <- makeDB
  http <- makeHttp
  cache <- makeCache
  ...
} yield someResult

这就是为什么tagless final和MTL会变得流行的另一个原因:你可以定义你的函数使用Config来运行,并将其作为契约,但不需要指定你确切拥有怎样和什么类型的F[_]。
import cats.Monad
import cats.mtl.ApplicativeAsk

// implementations will summon implicit ApplicativeAsk[F, Config]
// and Monad[F] to extract Config and use it to build a result
// in a for comprehension
def makeDB[F[_]: Monad: ApplicativeAsk[*, Config]]: F[Database]
def makeHttp[F[_]: Monad: ApplicativeAsk[*, Config]]: F[HttpClient]
def makeCache[F[_]: Monad: ApplicativeAsk[*, Config]]: F[RedisClient]

def program[F[_]: Monad: ApplicativeAsk[*, Config]]: F[Result] = for {
  db <- makeDB
  http <- makeHttp
  cache <- makeCache
  ...
} yield result

如果你定义 type F[A] = Kleisli[IO, Cache, A] 并提供必要的 implicit(这里是 Monad[Kleisli[IO, Cache, *]]ApplicativeAsk[Kleisli[IO, Cache, *], Cache]),那么你将能够像使用 Kleisli 的前一个示例一样运行此程序。
但是,你可以将 cats.effect.IO 切换为 monix.eval.Task。你可以组合几个 monad transformer,例如 ReaderTStateTEitherT,或者 2 个不同的 Kleisli/ReaderT 来注入 2 种不同的依赖项。由于 Kleisli/ReaderT 只是可以与其他 monad 组合的简单类型,因此你可以将它们与其他东西堆叠在一起以满足自己的需求。使用 tagless final 和 MTL,你可以将程序的声明性要求与实际使用的类型分开,从而编写每个组件所需的内容(然后能够使用扩展方法),以及定义实际使用的类型,并且可以从更小、更简单的构建块中构建该类型。
据我所知,正是由于这种简单性和可组合性,许多人使用 Kleisli。
话虽如此,在这种情况下设计解决方案的替代方法也有很多(例如,ZIO 以这样一种方式定义自身,即不需要 monad transformer),而许多人只是编写不需要任何类似于 monad transformer 的代码。
至于你对范畴论的关注 Kleisli 是

从“每个 monad 是否都来自一个伴随对?”这个问题中得出两个极端解决方案之一。

然而,我不知道有多少程序员每天都使用它,并且会关心这个动机。至少我不认识任何将其视为“偶尔有用的实用程序”的人。

这很有启发性。我绝对欣赏ReaderT的视角,也了解需要ReaderMonad的动机。正如你所说,如果所有函数都使用Kleisli...似乎加强了我的概念,即将函数包装成Kleisli然后使用那个镜头来解决问题是一种替代的计算视图。但是,正如你所说,没有多少人关心这个,它可能是“程序员范畴论”工具箱中的“附加实用程序”。 - PhD

10

初步说明:这是一个非常Haskell中心的答案。

关于第一点,luqui的评论表达得非常好:

Kleisli并不是需要强烈动机使用的重要事物。它只是一种在有单子时你可以倾斜头的方式。

如果你有一些链接的绑定...

m >>= f >>= g >>= h

...结合律单子法则允许您将它们重写为{{...}}。

m >>= \a -> f a >>= \b -> g b >>= \c -> h c

...或等价于...

m >>= (f >=> g >=> h)

...其中(>=>)是执行Kleisli组合的运算符:

(>=>)       :: Monad m => (a -> m b) -> (b -> m c) -> (a -> m c)
f >=> g     = \x -> f x >>= g

除了使用bind的单子法则外,(>=>)提供了更好的展示方式,有时候它也是编写单子计算的人体工程学方法。我能想到的一个例子是xml-conduit库;例如,下面的代码片段摘自Yesod book的一章:
main :: IO ()
main = do
    doc <- readFile def "test2.xml"
    let cursor = fromDocument doc
    print $ T.concat $
        cursor $// element "h2"
               >=> attributeIs "class" "bar"
               >=> precedingSibling
               >=> element "h1"
               &// content 

在这里,XML轴实现为列表单子Kleisli箭头。 在这种情况下,使用(>=>)来组合它们,而不明确提到它们应用的内容,感觉非常自然。
在您的问题和Mateusz Kubuszok's answer之间,我刚刚了解到一些相关的Scala-centric文档基于Monad m => a -> m bReaderTKleisli进行了标识。尽管如此,我认为通过将ReaderTKleisli视为表达不同概念来获得更清晰的图像,即使它们的实现在某种意义上重合。特别是,通过(>=>)KleisliCategory实例进行的组合方式与ReaderT通常用于表达对固定环境的依赖的方式不同。

在第三点上,我认为这只是与Kleisli有些间接关系。当涉及到组合单子是否会产生单子的问题以及单子变换器相关问题时,并不是通过使用Kleisli箭头来解决的。虽然在处理这些问题时考虑Kleisli箭头和Kleisli类别有时很有用,但我认为这主要是因为Kleisli箭头和类别通常是考虑单子的一种有用方式,而不是因为某种更具体的联系。


太好了!你指出 ReaderTKleisli 在实现上可能恰巧重合,这或许增加了我的困惑并导致我在问题中提到了 #2 和 #3。我开始认为 Kleisli 更像是一种观点(就像你和@luqui提到的那样),当你有一个单子时可以使用它。问题:你为什么要从 m >>= f >>= g >>= h 转到 m >>= (f >==> g >==>h)?我不确定我理解这个需要... - PhD
@PhD 对于你的问题:没有紧迫的需求;那只是一个不具体的例子,用来说明视角的变化。最终它们是写同一件事情的两种方式,尽管在特定情况下使用其中一种可能会更好。 - duplode

0
有时候,我们可能希望以比完整的Monad接口更少表达力、更“严格”的方式来构造计算,但也可能更易于检查。Kleisli可以用来将单调效应嵌入这些计算中。
例如,想象一下,我们正在构建计算管道,每个步骤都有某种注释附加在上面。注释可以表示完成步骤所需的时间估计,或者与步骤相关的其他资源的估计。我们希望能够在实际“运行”其效果之前检查整个管道累积的注释:
import Prelude hiding (id,(.))
import Control.Category (Category,(.),id)
import Control.Arrow (Kleisli (..))

data Step w m i o = Step w (Kleisli m i o) 

instance (Monoid w, Monad m) => Category (Step w m) where
    id = Step mempty (Kleisli pure)
    (.) (Step wf f) (Step wg g) = Step (wg <> wf) (f . g)

将其投入使用:
main :: IO ()
main = do
    let Step w (Kleisli _) = 
              Step "b" (Kleisli putStrLn) 
            . Step "a" (Kleisli (\() -> getLine))
    putStrLn w
    -- result: ab

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