14

我查看了https://www.fpcomplete.com/blog/2017/06/tale-of-two-brackets,虽然略读了一些部分,但我仍然不太了解核心问题“StateT很糟糕,IO是好的”,除了模糊地感觉到 Haskell 允许人们编写糟糕的StateTmonad(或者在文章的最终示例中,我认为MonadBaseControl不是)。StateT

在黑线鳕中,必须满足以下定律:

askUnliftIO >>= (\u -> liftIO (unliftIO u m)) = m

所以这似乎是说m在使用askUnliftIO. 但在我看来,在 中IO,整个世界都可以是状态。例如,我可能正在读取和写入磁盘上的文本文件。

引用迈克尔的另一篇文章

虚假纯度 我们说 WriterT 和 StateT 是纯粹的,从技术上讲它们是纯粹的。但说实话:如果您有一个完全存在于 StateT 中的应用程序,那么您将无法从纯代码中获得您想要的受限突变的好处。不妨直言不讳,并接受您有一个可变变量。

这让我认为情况确实如此:对于 IO,我们是诚实的,对于StateT,我们对可变性并不诚实……但这似乎是另一个问题,而不是上面的法律试图表明的;毕竟MonadUnliftIO是假设IO。我很难从概念上理解如何IO比其他东西更具限制性。

更新 1

睡觉后(一些),我仍然很困惑,但随着时间的推移,我逐渐变得越来越少。我为IO. 我意识到idREADME 中的存在。尤其是,

instance MonadUnliftIO IO where
  askUnliftIO = return (UnliftIO id)

所以askUnliftIO似乎会返回IO (IO a)一个UnliftIO m.

Prelude> fooIO = print 5
Prelude> :t fooIO
fooIO :: IO ()
Prelude> let barIO :: IO(IO ()); barIO = return fooIO
Prelude> :t barIO
barIO :: IO (IO ())

m回到法律上,当在转换后的 monad ( ) 上进行往返时,状态似乎在 monad 中没有发生突变askUnliftIO,其中往返是unLiftIO-> liftIO

继续上面的例子barIO :: IO (),所以如果我们这样做barIO >>= (u -> liftIO (unliftIO u m)),那么u :: IO ()unliftIO u == IO (),那么liftIO (IO ()) == IO ()。**因此,由于一切基本上都是幕后的应用程序id,我们可以看到没有更改任何状态,即使我们正在使用IO. 至关重要的是,我认为,重要的是 in 的值a永远不会运行,也不会因为使用askUnliftIO. 如果确实如此,那么就像在 的情况下一样,如果randomIO :: IO a我们不运行它,我们将无法获得相同的值askUnliftIO。(下面的验证尝试1)

但是,看起来我们仍然可以对其他 Monad 做同样的事情,即使它们确实保持状态。但我也看到,对于某些 monad,我们可能无法做到这一点。考虑一个人为的例子:每次我们访问a包含在有状态 monad 中的类型的值时,都会改变一些内部状态。

验证尝试 1

> fooIO >> askUnliftIO
5
> fooIOunlift = fooIO >> askUnliftIO
> :t fooIOunlift
fooIOunlift :: IO (UnliftIO IO)
> fooIOunlift
5

到目前为止很好,但对为什么会发生以下情况感到困惑:

 > fooIOunlift >>= (\u -> unliftIO u)

<interactive>:50:24: error:
    * Couldn't match expected type `IO b'
                  with actual type `IO a0 -> IO a0'
    * Probable cause: `unliftIO' is applied to too few arguments
      In the expression: unliftIO u
      In the second argument of `(>>=)', namely `(\ u -> unliftIO u)'
      In the expression: fooIOunlift >>= (\ u -> unliftIO u)
    * Relevant bindings include
        it :: IO b (bound at <interactive>:50:1)
4

1 回答 1

8

“StateT 不好,IO 还可以”

这不是这篇文章的真正重点。这个想法是MonadBaseControl允许在存在并发和异常的情况下使用有状态的 monad 转换器进行一些令人困惑的(并且通常是不受欢迎的)行为。

finally :: StateT s IO a -> StateT s IO a -> StateT s IO a是一个很好的例子。如果您使用“StateT将类型的可变变量附加s到 monad ”的比喻,那么您可能期望终结器操作在引发异常时m访问最新值。s

forkState :: StateT s IO a -> StateT s IO ThreadId是另一个。您可能期望来自输入的状态修改将反映在原始线程中。

lol :: StateT Int IO [ThreadId]
lol = do
  for [1..10] $ \i -> do
    forkState $ modify (+i)

您可能期望lol可以将其重写(模性能)为modify (+ sum [1..10]). 但这是不对的。的实现forkState只是将初始状态传递给分叉线程,然后永远无法检索任何状态修改。简单/常见的理解在StateT这里失败了。

相反,您必须采用更细致的观点,将StateT s m a其视为“提供类型为线程局部不可变变量的转换器,该变量s通过计算隐式线程化,并且可以用相同类型的新值替换该局部变量用于未来的计算步骤。” (或多或少冗长的英语复述s -> m (a, s))有了这种理解, 的行为finally变得更加清晰:它是一个局部变量,因此它不会在异常中幸存下来。同样,forkState变得更加清楚:它是一个线程局部变量,因此显然对不同线程的更改不会影响任何其他线程。

有时是你想要的。但这通常不是人们编写代码 IRL 的方式,它经常使人们感到困惑。

长期以来,生态系统中执行这种“降低”操作的默认选择是MonadBaseControl,这有很多缺点:令人困惑的类型、难以实现的实例、无法派生实例、有时令人困惑的行为。不是一个很好的情况。

MonadUnliftIO将事物限制在一组更简单的 monad 转换器中,并且能够提供相对简单的类型、可派生的实例和始终可预测的行为。代价是ExceptT,StateT等变压器不能使用它。

基本原则是:通过限制可能发生的事情,我们更容易理解可能发生的事情。MonadBaseControl非常强大和通用,因此很难使用和混淆。MonadUnliftIO功能和通用性较差,但使用起来要容易得多。

所以这似乎是说在使用 askUnliftIO 时,monad m 中的状态没有发生变化。

这不是真的 - 法律规定不unliftIO应该对 monad 转换器做任何事情,除了将其降低到IO. 这是违反该法律的内容:

newtype WithInt a = WithInt (ReaderT Int IO a)
  deriving newtype (Functor, Applicative, Monad, MonadIO, MonadReader Int)

instance MonadUnliftIO WithInt where
  askUnliftIO = pure (UnliftIO (\(WithInt readerAction) -> runReaderT 0 readerAction))

让我们验证这是否违反了给定的法律:askUnliftIO >>= (\u -> liftIO (unliftIO u m)) = m.

test :: WithInt Int
test = do
  int <- ask
  print int
  pure int

checkLaw :: WithInt ()
checkLaw = do
  first <- test
  second <- askUnliftIO >>= (\u -> liftIO (unliftIO u test))
  when (first /= second) $
    putStrLn "Law violation!!!"

返回的值testaskUnliftIO ...下降/上升不同,所以违反了规律。此外,观察到的效果是不同的,这也不是很好。

于 2019-08-07T16:24:46.200 回答