【问题标题】:Lazy evaluation and IO side effect confusion惰性求值与 IO 副作用混淆
【发布时间】:2012-01-03 18:50:00
【问题描述】:

此代码(取自Learn You A Haskell):

main = do   putStr "Hey, "  
            putStr "I'm "  
            putStrLn "Andy!"  

显然去糖了

main =        putStr "Hey, " >>=  
       (\_ -> putStr "I'm "  >>= 
       (\_ -> putStrLn "Andy!"))

据我所知,它可以解释为“为了 putStrLn "Andy!" 我首先需要 putStr "I'm ",为此我首先需要 putStr "Hey,";

我不同意这种解释,这很烦人,因为编译器显然不这样做,这让我感到困惑。我遇到的问题是 lambdas 忽略了他们的论点,在惰性评估期间,这种事情不应该被识别和短路吗?

另外,当然,绑定返回一个 IO 操作,当该 IO 操作落入 main 时,它会被执行。但是如何阻止它打印“嘿,安迪!我是”?我怀疑这是绑定在做什么。

此外,“IO ()”类型的 IO 操作如何携带足够的信息以允许运行时系统打印“嘿,我是安迪!”? IO () 与 IO () 相比打印“Hello World!”有何不同?还是写入文件?

考虑另一个,来自 wikipedia 页面的 monad:

加糖版:

do
  putStrLn "What is your name?"
  name <- getLine
  putStrLn ("Nice to meet you, " ++ name ++ "!")

脱糖版本:

putStrLn "What is your name?" >>= 
   (\_ ->
      getLine >>=
         (\name ->
            putStrLn ("Nice to meet you, " ++ name ++ "!")))

这里有类似的故事。

我想我只需要查看 IO 绑定的定义,然后就一切都清楚了。如果有人可以帮助我逐步了解程序的实际评估方式并确定副作用发生的确切时刻,那么其他会很有帮助的事情。

【问题讨论】:

  • “lambda 忽略了他们的论点,在惰性求值期间,这种事情不应该被识别和短路吗?”你打赌! (&gt;&gt;=) 的第二个参数在这里是一个特别惰性的函数,但 (&gt;&gt;=) 函数本身并不惰性。

标签: haskell functional-programming lazy-evaluation side-effects


【解决方案1】:

阅读 Simon Peyton Jones 的“Tackling the awkward squad”论文。

相关问题见

采取任何这样的解释,包括我的一粒盐 - 没有任何挥手可以代替严格的同行评审论文,而且解释必然过于简单化。

一个很粗略的观点是&gt;&gt;=可以看成是一个列表构造函数:

data IO = [Primitive] 

和 IO 子系统解构 main 的值并使用该列表。 IE。 ``mainis just a list. So you may want to take a look at the definition of Haskell entry point abovemain,bind` 比较无趣。

您还可以阅读有关 haskell 历史的论文并查看早期版本的 IO 子系统以了解正在发生的事情。

另请参阅 Conal Elliott 的 C language is purely functional 讽刺帖子。

功能纯度的定义很重要,我记得有一篇论文详细说明了这个定义,但我不记得标题了。

【讨论】:

  • 有趣的是大家总是以类比的方式解释>>=关于IO值。它在前奏中的某个地方没有定义吗?为什么从来没有人引用它?魔术发生的地方是通过 IO 绑定吗?我现在正在阅读尴尬的小队文件,甚至 SPJ 都回避实际定义绑定
  • IO 类型是抽象的,所以在 Haskell 标准中没有一个 &gt;&gt;= 的定义。根据您实现IO 的方式,您将有不同的&gt;&gt;= 实现。如果您深入研究 ghc,您会发现 IO 是一个状态单子,而 &gt;&gt;= 只是为一个状态单子绑定。 (编译器内部有更多的魔法来提高效率。)
  • @ThelronKnuckle 这不是“价值观”的类比。这是对使用旧的main :: [Request] -&gt; [Response] 思想的另一种纯 IO 实现的类比。此外,最有趣的部分不是绑定,而是 IO monad 的 runIO。由于 IO monad 是抽象的,我们要么像 SPJ 那样提供抽象的解释,要么像我一样使用一些实现策略。
【解决方案2】:

在真正的 Haskell 实现中查看 IO 可能会让人困惑,而不是启发。但是将IO 视为这样定义(假设您知道 GADT):

data IO a where
    Return a :: IO a
    Bind :: IO a -> (a -> IO b) -> IO b
    PutStr :: String -> IO ()
    GetLine :: IO String

instance Monad IO where
    return = Return
    (>>=) = Bind

putStr :: String -> IO ()
putStr = PutStr

getLine :: IO String
getLine = GetLine

因此,当您评估一个程序(类型为IO ())时,它所做的只是构建一个类型为IO () 的数据结构,该结构描述了一旦您执行它与世界的交互将如何发生。然后,您可以想象执行引擎是用例如 C 语言编写的,并且所有效果都发生在这里。

所以

main = do   putStr "Hey, "  
            putStr "I'm "  
            putStrLn "Andy!"  

一样
main = Bind (PutStr "Hey, ") (\ _ -> Bind (PutStr "I'm ") (\ _ -> PutStr "Andy!"))

这些排序来自执行引擎的工作方式。

也就是说,我知道没有一个 Haskell 实现实际上是这样做的。真正的实现倾向于将IO 实现为一个状态单子,带有一个表示正在传递的真实世界的令牌(这是保证排序的原因),而像putStr 这样的原语只是对C 函数的调用。

【讨论】:

  • +1 表示 GADT 方法和“在真正的 Haskell 实现中查看 IO 可能会让人感到困惑而不是启发”。
【解决方案3】:

我想我只需要看看 IO 绑定的定义,然后就可以了 一切都会清楚的。

是的,你应该这样做。其实很简单,如果我没记错的话是这样的

newtype IO = IO (RealWorld -> (a, RealWorld))

(IO f) >>= g = ioBind f g
    where
       ioBind :: (RealWorld -> (a, RealWorld)) -> (a -> IO b) -> RealWorld -> (b, RealWorld)
       ioBind f g rw = case f rw of
            (a, rw@RealWorld) -> case g a of
                IO b -> b rw

“诀窍”是每个 IO 值实际上基本上都是一个函数,但要评估它,您需要一个 RealWorld 类型的令牌。只有一个实例可以提供这样的值——运​​行 main 的运行时系统(当然还有不能命名的函数)。

【讨论】:

    【解决方案4】:

    我认为如果您再次将操作视为函数,这将更容易理解。您的绑定示例 (do { foo &lt;- getLine ; putStrLn foo ; }) 在直观上类似于以下函数:

    apply arg func = func (arg)
    

    除了函数是事务。所以我们的调用func(arg) 被评估,如果有的话,只有当(arg) 成功完成。否则我们fail 在我们的行动中。

    这与普通函数不同,因为 Haskell 真的不在乎 (arg) 是否完全计算或根本不计算,直到它需要一点 func(arg) 才能继续程序。

    【讨论】:

      【解决方案5】:

      我想我只需要看看bindIO 的定义就知道了。

       -- ghc-8.6.5/libraries/base/GHC/Base.hs; line 1381
      bindIO :: IO a -> (a -> IO b) -> IO b
      bindIO (IO m) k = IO (\ s -> case m s of (# new_s, a #) -> unIO (k a) new_s)
      
       -- ghc-8.6.5/libraries/base/GHC/Base.hs; line 1387
      unIO :: IO a -> (State# RealWorld -> (# State# RealWorld, a #))
      unIO (IO a) = a
      

      IO 类型在单独的模块中声明:

       -- ghc-8.6.5/libraries/ghc-prim/GHC/Types.hs; line 169
      newtype IO a = IO (State# RealWorld -> (# State# RealWorld, a #))
      

      IObind 运算符的更多示例可以在 Philip Wadler 的 How to Declare an Imperative 中找到。)


      如果有人可以帮助我逐步了解程序的实际评估方式并确定副作用发生的确切时刻,那么其他会很有帮助的事情。

      让我们重写bindIO

      • 使用let-expressions 和bang-patterns 代替case

      • 使用unIO从其第一个参数中提取动作:

        bindIO :: IO a -> (a -> IO b) -> IO b
        bindIO m k = IO (\ s -> let !(# new_s, a #) = unIO m s in unIO (k a) new_s)
        

      现在为您的那个Learn You A Haskell示例的扩展版本:

      main =        putStr "Hey, " >>=  
             (\_ -> putStr "I'm "  >>= 
             (\_ -> putStrLn "Andy!"))
      
      1. (&gt;&gt;=) 替换为bindIO,同时切换到前缀表示法:

        main = bindIO (putStr "Hey, ")
                      (\_ -> bindIO (putStr "I'm ")
                                    (\_ -> putStrLn "Andy!"))
        
      2. 现在是挑剔的部分 - 将所有调用扩展到 bindIO一切顺利,程序最终将类似于:

        main = IO (\s0 -> let !(# s1, _ #) = unIO (putStr "Hey, ") s0 in
                          let !(# s2, _ #) = unIO (putStr "I'm ") s1 in
                          unIO (putStrLn "Andy!") s2)
        
      3. 另一个变化 - 它是可选的,但它有助于澄清这里发生了什么:

        main = IO (\s0 -> let !(# s1, _ #)  = unIO (putStr "Hey, ") s0 in
                          let !(# s2, _ #)  = unIO (putStr "I'm ") s1 in
                          let !(# s3, a3 #) = unIO (putStrLn "Andy!") s2) in
                          (# s3, a3 #))
        
        • 据我所知,它可以解释为:为了putStrLn "Andy!",我首先需要putStr "I'm ",为了做到这一点,我首先需要putStr "Hey, "

        正确:因为s0s1s2s3 在程序中的使用方式(一次),它建立了评估顺序。 该排序允许putStrputStrLn 直接使用效果 打印出他们各自的论点。

      所以,不像例如Standard ML(使用句法排序),Haskell 依赖数据依赖来确保 I/O 以所需的顺序发生 - do-notation 只是为了方便。


      我遇到的问题是 lambda 在惰性求值期间会忽略它们的参数 - 这种事情不应该被识别和短路吗?

      如果我们也“过度扩展”您的另一个示例:

        IO (\ s0 -> let !(# s1, _ #)    = unIO (putStrLn "What is your name?") s0 in
                    let !(# s2, name #) = unIO getLine s1 in
                    let !(# s3, a3 #)   = unIO (putStrLn ("Nice to meet you, " ++ name ++ "!")) s2 in
                    (# s3, a3 #))
      

      我们可以清楚地看到,实际上被忽略的是 输出 - 即使用 putStrputStrLn() :: () - 但不是 states,它维持秩序。


      IO () 类型的 IO 操作如何携带足够的信息以允许运行时系统打印 "Hey, I'm Andy!" - IO () 与打印 "Hello World!" 或写入文件的 IO () 有何不同?

      (+)(-)(*) 的处理方式相同,即使它们具有相同的类型 (Num a =&gt; a -&gt; a -&gt; a),但名称不同。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 2012-08-31
        • 1970-01-01
        • 1970-01-01
        • 2016-04-26
        • 2023-04-03
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多