【问题标题】:Advice on writing monadic signatures关于写单子签名的建议
【发布时间】:2017-04-07 14:04:46
【问题描述】:

考虑以下示例函数,它们都向纯输入添加随机值:

addRand1 :: (MonadRandom m) => m (Int -> Int)

addRand2 :: (MonadRandom m) => Int -> m Int -- *can* be written as m (Int -> Int)

很容易将addRand1转换成与addRand2but not vice versa签名相同的函数。

对我来说,这提供了强有力的证据,证明我应该写 addRand1 而不是 addRand2。在此示例中,addRand1 具有更真实/通用的类型,通常捕获 Haskell 中的重要抽象。

虽然拥有“正确的”签名似乎是函数式编程的一个重要方面,但我也有很多实际原因说明 addRand2 可能是一个更好的签名,即使它可以写成addRand1s 签名。

  1. 带接口:

    class FakeMonadRandom m where
      getRandom :: (Random a, Num a) => m a
      getRandomR1 :: (Random a, Num a) => (a,a) -> m a
      getRandomR2 :: (Random a, Num a) => m ((a,a) -> a)
    

    getRandomR2 相比,getRandomR1 突然允许更多实例(例如,重复调用getRandom 直到结果在范围内)似乎“更通用”,这似乎需要某种排序还原技术。

  2. addRand2 更容易写/读:

    addRand1 :: (MonadRandom m) => m (Int -> Int)
    addRand1 = do
      x <- getRandom
      return (+x) -- in general requires `return $ \a -> ...`
    
    addRand2 :: (MonadRandom m) => Int -> m Int
    addRand2 a = (a+) <$> getRandom
    
  3. addRand2 更容易使用:

    foo :: (MonadRandom m) => m Int
    foo = do
      x <- addRand1 <*> (pure 3) -- ugly syntax
      f <- addRand1              -- or a two step process: sequence the function, then apply it
      x' <- addRand2 3           -- easy!
      return $ (f 3) + x + x'
    
  4. addRand2 更难使用:考虑getRandomR :: (MonadRandom m, Random a) =&gt; (a,a) -&gt; m a。对于给定的范围,我们可以重复采样,得到不同的结果,这可能是我们想要的。但是,如果我们改为使用getRandomR :: (MonadRandom m, Random a) =&gt; m ((a,a) -&gt; a),我们可能会想写

    do
      f <- getRandomR
      return $ replicate 20 $ f (-10,10)
    

    但会对结果感到非常惊讶!

我对如何编写一元代码感到非常矛盾。在许多情况下,“版本 2”似乎更好,但我最近遇到了一个需要“版本 1”签名的示例。*

什么样的因素会影响我的设计决策 w.r.t.一元签名?有没有办法调和“通用签名”和“自然、干净、易于使用、难以误用的语法”这两个明显冲突的目标?

*:我写了一个函数foo :: a -&gt; m b,它(字面上)工作了很多年。当我尝试将其合并到一个新的应用程序(带有 HOAS 的 DSL)中时,我发现我做不到,直到我意识到 foo 可以重写为具有签名 m (a -&gt; b)。突然间我的新应用成为可能。

【问题讨论】:

  • 这两个签名在语义上确实不同。您是根据输入选择要执行的操作,还是在执行诸如读取翻译文件并返回执行翻译的函数之类的操作? a -&gt; m b 的使用肯定比另一个多得多。
  • @Lazersmoke 我给出了两者的定义。可以看到一元动作不依赖于addRand2中的输入;它的行为就像addRand1。我的感觉是a -&gt; m b的使用频率也更高;我试图理解为什么会这样。
  • 你的函数 addRand1 基本上只是使用 Functor 实例,它是每个 Monad 的约束。您可以将其重写为:addRand1 = fmap (+) getRandom。由于 getRandom 无论如何都需要在 Monad 上下文中,而不仅仅是 Functor,我认为以第二种方式编写函数并没有那么强大。如果你想使用addRand2 来实现无点风格,你可以这样做:addRand2 = liftM2 (+) getRandom . return 但你的版本更具可读性:-)
  • @Boomerang “因为 getRandom 无论如何都需要在 Monad 上下文中,而不仅仅是 Functor,我认为以第二种方式编写你的函数并没有那么强大。”你能再解释一下吗?我在注释中举了一个例子,解释了为什么我需要“第一种方式”签名。
  • FWIW、(&lt;*&gt;) :: Applicative m =&gt; m (a -&gt; b) -&gt; m a -&gt; m b(=&lt;&lt;) :: Monad m =&gt; (a -&gt; m b) -&gt; m a -&gt; m b,所以你可以说一个是“应用方式”,另一个是“一元方式”。

标签: haskell monads


【解决方案1】:

这取决于多种因素:

  • 哪些签名实际上是可能的(这里都是)。
  • 什么签名好用。
  • 或者更一般地说,如果您想拥有最通用的接口或双重最通用的实现。

理解Int -&gt; m Intm (Int -&gt; Int)之间区别的关键在于,在前一种情况下,效果(m ...)可以取决于输入参数。例如,如果mIO,您可以有一个发射n 导弹的函数,其中n 是函数参数。另一方面,m (Int -&gt; Int) 中的效果不依赖于任何东西 - 效果不会“看到”它返回的函数的参数。

回到你的案例:你接受一个纯输入,生成一个随机数并将其添加到输入中。我们可以看到效果(生成随机数)不依赖于输入。这就是为什么我们可以有签名m (Int -&gt; Int)。例如,如果任务是生成n 随机数,签名Int -&gt; m [Int] 会起作用,但m (Int -&gt; [Int]) 不会。

关于可用性,Int -&gt; m Int 在 monadic 上下文中更常见,因为大多数 monadic 组合器都需要 a -&gt; b -&gt; ... -&gt; m r 形式的签名。例如,你通常会写

getRandom >>= addRand2

addRand2 =<< getRandom

将一个随机数添加到另一个随机数。

m (Int -&gt; Int) 这样的签名在 monad 中不太常见,但经常与 applicative functors 一起使用(每个 monad 也是一个应用函子),其中效果不能依赖于参数。特别是,运算符&lt;*&gt; 在这里可以很好地工作:

addRand1 <*> getRandom

关于一般性,签名影响使用或实现它的难度。正如您所观察到的,从调用者的角度来看,addRand1 更为笼统——如果需要,它总是可以将其转换为addRand2。另一方面,addRand2 不太通用,因此更容易实现。在您的情况下,它并不真正适用,但在某些情况下,可能会实现像m (Int -&gt; Int) 这样的签名,但不是Int -&gt; m Int。这反映在类型层次结构中 - monad 比 applicative functors 更具体,这意味着它们为用户提供更多权力,但实现起来“更难” - 每个 monad 都是一个 applicative,但不是每个 applicative 都可以变成一个 monad .

【讨论】:

    【解决方案2】:

    很容易将addRand1转换成与addRand2签名相同的函数,但是not vice versa

    咳咳

    -- | Adds a random value to its input
    addRand2 :: MonadRandom m => Int -> m Int
    addRand2 x = fmap (+x) getRand
    
    -- | Returns a function which adds a (randomly chosen) fixed value to its input
    addRand1 :: MonadRandom m => m (Int -> Int)
    addRand1 = fmap (+) (addRand2 0)
    

    这是怎么工作的?好吧,addRand1 的工作是随机选择一个值并将+ 部分应用于它。将随机数添加到虚拟值是产生随机数的绝佳方法!

    我认为您可能对@chi's statement 中的量词感到困惑。他没说

    对于所有单子 m 和类型 ab,您不能将 a -&gt; m b 转换为 m (a -&gt; b)

    ∀ m a b. ¬ ∃ f. f :: Monad m => (a -> m b) -> m (a -> b)
    

    他说

    您不能将所有单子 m 和类型 aba -&gt; m b 转换为 m (a -&gt; b)

    ¬ ∃ f. f :: ∀ m a b. Monad m => (a -> m b) -> m (a -> b)
    

    没有什么能阻止您为 特定 monad m 和一对类型 ab 编写 (a -&gt; m b) -&gt; m (a -&gt; b)

    【讨论】:

    • “咳咳。”好吧,但你作弊了。您正在使用函数的定义来提出执行等效操作的操作。我说的正是chi 所说的。我对addRand* 一点也不关心;它们只是演示问题的示例。
    • 我不会那么迟钝。任何时候你都可以写一个x :: a -&gt; m by :: m (a -&gt; b)(对于一些mab),这样x = liftAp y每个你的问题(liftAp :: Functor f =&gt; f (a -&gt; b) -&gt; a -&gt; f b),你就会这样做至少以特定于类型的方式,通常以特定于含义的方式,因为没有通用的方式。我的意思是说:对于x/y 的大多数单子和规范,你不能同时写xy。如果您的问题与问题中的代码无关,那么您获得错误答案的可能性就会增加
    • 给出具体代码的目的是为了证明addRand1 是如何更难编写和使用的。问题只是关于可以以任何一种方式编写的函数的两种签名样式之间的权衡。
    【解决方案3】:

    或者,用英语:为什么不两者兼而有之?两种签名都可能出现的情况很少见,但是当它们都出现时,每个版本都可以在不同的上下文中使用。

    【讨论】:

    • 公平,但如果m (a-&gt;b) 版本如第 (4) 点那样被滥用,问题仍然存在。
    • @crockeea 我想大多数人都非常熟悉replicatereplicateM 之间的区别(以及其他foofooM 函数)。我一点也不担心第 4 点。
    【解决方案4】:

    addRand1 转换为与addRand2 具有相同签名的函数很容易,反之则不然。

    这是真的,但不要忘记这种“转换”不必保留预期的语义。

    我的意思是,如果我们有 foo :: IO (Int -&gt; ()) 我们可以写

    bogusPrint :: Int -> IO ()
    bogusPrint x = ($ x) <$> foo
    

    但这将对所有x 执行相同的IO 操作!几乎没用。

    您的论点似乎是“我可以定义一个对象x :: A 或另一个y :: B。好吧,我也知道我可以写f :: A-&gt;B,所以x :: A 更通用,因为你可以让y = f x :: B” .就个人而言,我认为这是推理代码的好方法!但是,必须检查以f x 获得的y 是否是预期的。仅仅因为类型匹配,并不意味着该值是正确的。

    所以,在一般情况下,我认为这取决于手头的单子。我会同时写 xy(正如 Daniel Wagner 建议的那样),然后检查一个是否真的比另一个更通用——不仅仅是因为类型更通用,而是因为值 y 可以从x 中(有效地)恢复。

    【讨论】:

    • "这将对所有 x 执行相同的 IO 操作!" foo 也一样吗?不确定我是否遵循您的论点
    • @BenjaminHodgson 我的意思是,如果你想要一个有意义的realPrint :: Int -&gt; IO (),你不能说“让我们定义一个更通用的foo :: IO (Int -&gt; ()),然后从中导出realPrint”。仅仅因为你可以派生出相同类型的东西,并不意味着它就是预期的值。或者,更通俗地说,“更一般的类型并不意味着更普遍的价值”
    • 我想我明白你的意思了。说得更直白一点,id :: a -&gt; a 并没有概括 succ :: Int -&gt; Int,即使它有更通用的类型
    • 我确实理解类型之间存在差异,不幸的是我缺乏清楚表达这一点的语言。我的函数foo :: m (a -&gt; b) 与我的bar :: a -&gt; m b 函数“做同样的事情”,即因为我的特定 函数的随机性不依赖于输入a 的值。换句话说,我可以定义bar a = ($ a) &lt;$&gt; foo。这个问题不应该是关于我的特定代码,而是关于当我有一个 foo 类型的函数时为什么我应该选择一个签名而不是另一个签名的一般问题。
    猜你喜欢
    • 2012-08-12
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-12-04
    • 1970-01-01
    • 1970-01-01
    • 2014-05-29
    相关资源
    最近更新 更多