如何使用可扩展效果获得“单子转换器的不灵活语义”?
考虑以下示例。
newtype TooBig = TooBig Int deriving Show
choose :: MonadPlus m => [a] -> m a
choose = msum . map return
ex1 :: (MonadPlus m, MonadError TooBig m) => m Int
ex1 = do
x <- choose [5,7,1]
if x > 5
then throwError (TooBig x)
else return x
ex2 :: (MonadPlus m, MonadError TooBig m) => m Int
ex2 = ex1 `catchError` handler
where
handler (TooBig x) = if x > 7
then throwError (TooBig x)
else return x
ex3 :: Either TooBig [Int]
ex3 = runIdentity . runExceptT . runListT $ ex2
的价值应该ex3是多少?如果我们使用MTL那么答案是Right [7]这是有意义的,因为ex1被终止,因为它抛出一个错误,并且handler直接返回纯值return 7是Right [7]。
然而,在Oleg Kiselyov 等人的论文“可扩展效应:Monad Transformers 的替代方案”中。作者说这是“一个令人惊讶且不受欢迎的结果”。他们期望结果是Right [5,7,1]因为handler通过不重新抛出异常从异常中恢复。从本质上讲,他们预计将按如下catchError方式移动ex1。
newtype TooBig = TooBig Int deriving Show
choose :: MonadPlus m => [a] -> m a
choose = msum . map return
ex1 :: (MonadPlus m, MonadError TooBig m) => m Int
ex1 = do
x <- choose [5,7,1]
if x > 5
then throwError (TooBig x) `catchError` handler
else return x
where
handler (TooBig x) = if x > 7
then throwError (TooBig x)
else return x
ex3 :: Either TooBig [Int]
ex3 = runIdentity . runExceptT . runListT $ ex1
事实上,这就是可扩展效应的作用。它们通过将效果处理程序移近效果源来更改程序的语义。例如,local移近ask和catchError移近throwError。该论文的作者将此称为可扩展效果优于 monad 转换器的优势之一,声称 monad 转换器具有“不灵活的语义”。
但是,如果我想要结果Right [7]而不是Right [5,7,1]出于任何原因呢?如上面的示例所示,可以使用 monad 转换器来获得这两种结果。然而,由于可扩展的效果似乎总是将效果处理程序移近效果源,因此似乎不可能获得结果Right [7]。
那么,问题是如何使用可扩展的效果来获得“单子转换器的不灵活语义”?在使用可扩展效果时,是否可以防止单个效果处理程序靠近效果源?如果不是,那么这是需要解决的可扩展效果的限制吗?
回答
我也对那篇特定论文的摘录中的细微差别感到有些困惑。我认为退后几步并解释该论文所属的代数效应企业背后的动机更有用。
MTL 方法在某种意义上是最明显和最通用的:你有一个接口(或“效果”),把它放在一个类型类中并称之为一天。这种通用性的代价是它是无原则的:您不知道将接口组合在一起时会发生什么。当您实现一个接口时,这个问题最明显:您必须同时实现所有这些。我们倾向于认为每个接口都可以在一个专用转换器中单独实现,但是如果您有两个接口,比如MonadPlus和MonadError,由转换器ListT和实现ExceptT,为了组合它们,您还必须实现MonadErrorforListT或MonadPlusforExceptT. 这个 O(n^2) 实例问题通常被理解为“只是样板”,但更深层次的问题是,如果我们允许接口具有任何形状,那么不知道在该“样板”中可能隐藏什么危险,如果它甚至可以实施。
我们必须在这些接口上放置更多结构。对于“提升”(liftfrom MonadTrans)的某些定义,我们可以通过变压器均匀提升的效果正是代数效果。(另请参阅Monad Transformers 和 Modular Algebraic Effects,是什么将它们结合在一起。)
这并不是真正的限制。虽然有些接口在技术意义上不是代数的,例如MonadError(因为catch),但它们通常仍然可以在代数效应的框架内表达,只是不太像字面意思。在限制“接口”定义的同时,我们也获得了更丰富的使用方式。
所以我认为代数效应首先是一种不同的、更精确的界面思考方式。作为一种思维方式,因此可以在不更改代码的任何内容的情况下采用它,这就是为什么比较往往会查看相同的代码两次,并且如果不了解周围的上下文和动机就很难看出重点。如果您认为 O(n^2) 实例问题是一个微不足道的“样板”问题,那么您已经相信接口应该是可组合的原则;代数效应是围绕该原则明确设计库和语言的一种方式。
“代数效应”是一个没有固定定义的模糊概念。如今,它们最容易通过具有 acall和 ahandle构造(或op/ perform/ throw/raise和catch/ match)的语法来识别。call是使用接口的一种构造,handle也是我们实现它们的方式。这些语言的共同想法是,存在方程(因此是“代数”),它们以独立于界面的方式提供有关如何call和handle行为的基本直觉,特别是通过handle与顺序组合的交互(>>=)。
从语义上讲,程序的含义可以用一棵calls的树来表示,而 ahandle是这些树的变换。这就是为什么 Haskell 中许多“代数效应”的化身都是从自由 monad 开始的,树的类型由节点类型参数化f:
data Free f a
= Pure a
| Free (f (Free f a))
从这个角度来看,该程序ex2是一棵具有三个分支的树,分支标记为7以异常结尾:
ex2 :: Free ([] :+: Const Int) Int -- The functor "Const e" models exceptions (the equivalent of "MonadError e")
ex2 = Free [Pure 5, Free (Const 7), Pure 1]
-- You can write this with do notation to look like the original ex2, I'd say "it's just notation".
-- NB: constructors for (:+:) omitted
并且每个效果[]和Const Int对应于转换树的某种方式,从树中消除该效果(可能引入其他效果,包括其自身)。
-
“捕获”异常对应于
Const通过将Free (Const x)节点转换为一些新树来处理效果h x。 -
为了处理这种
[]效果,一种方法是使用 组合一个Free [...]节点的所有子节点(>>=),将它们的结果收集在一个最终列表中。这可以看作是深度优先搜索的推广。
您得到结果[7]或[5,7,1]取决于这些转换的排序方式。
当然,在 MTL 方法中,monad 变换器的两个阶是对应的,但是程序作为树的直觉,通常适用于所有代数效应,当你正在实现一个例如MonadError efor ListT。这种直觉可能在后验上有意义,但它是先验混淆的,因为类型类实例不是像处理程序那样的一流值,并且 monad 转换器通常根据最终解释(隐藏在m它们转换的 monad 中)而不是初始语法。