47

大多数 Haskell 教程都教给 IO 使用 do-notation。

我也从 do-notation 开始,但这使我的代码看起来更像是一种命令式语言,而不是 FP 语言。

这周我看到一个使用 IO 的教程<$>

stringAnalyzer <$> readFile "testfile.txt"

而不是使用do

main = do
    strFile <- readFile "testfile.txt"
    let analysisResult = stringAnalyzer strFile
    return analysisResult

并且日志分析工具在没有do.

所以我的问题是“在任何情况下我们都应该避免做符号吗? ”。

我知道do在某些情况下可能会使代码更好。

另外,为什么大多数教程都用 IO 来教授 IO do

在我看来<$><*>并使代码比 IO 更 FP。

4

7 回答 7

47

doHaskell 中的符号以一种非常简单的方式去糖。

do
  x <- foo
  e1 
  e2
  ...

变成

 foo >>= \x ->
 do
   e1
   e2

do
  x
  e1
  e2
  ...

进入

x >>
do 
  e1
  e2
  ....

这意味着你真的可以用>>=和编写任何一元计算return。我们不这样做的唯一原因是因为它只是更痛苦的语法。单子对于模仿命令式代码很有用,do符号使它看起来像它。

C-ish 语法使初学者更容易理解它。你是对的,它看起来没有那么实用,但是要求某人在使用 IO 之前正确地理解 monad 是一个很大的威慑力。

另一方面,我们使用>>=and的原因是因为它对于 1 - 2 衬里紧凑。然而,对于任何太大的东西,它确实会变得更加难以理解。因此,要直接回答您的问题,请不要在适当的时候避免使用 do 表示法。return

最后,您看到的两个运算符<$><*>,实际上分别是 fmap 和 applicative,而不是 monadic。它们实际上不能用于表示许多 do 表示法所做的事情。可以肯定的是,它们更紧凑,但它们不允许您轻松命名中间值。就个人而言,我大约 80% 的时间都在使用它们,主要是因为我倾向于编写非常小的可组合函数,不管怎样,应用程序非常适合。

于 2013-05-24T02:17:37.350 回答
46

在我看来<$><*>并使代码比 IO 更 FP。

Haskell 不是一种纯粹的函数式语言,因为它“看起来更好”。有时会,通常不会。保持功能性的原因不是它的语法而是它的语义。它为我们提供了引用透明性,这使得证明不变量变得更加容易,允许非常高级的优化,使得编写通用代码等变得容易。

这些都与语法无关。一元计算仍然是纯函数式的——不管你是用do符号还是用<$>,<*>和来编写它们>>=,所以无论哪种方式我们都能得到 Haskell 的好处。

然而,尽管有前面提到的 FP 的好处,但从类似命令的角度考虑算法通常更直观——即使您习惯于通过 monad 实现它。在这些情况下,do符号可以让您快速了解“计算顺序”、“数据来源”、“修改点”,但在您的脑海中手动将其脱糖到>>=版本,以掌握功能上发生的事情是微不足道的。

应用风格在很多方面确实很棒,但它本质上是无点的。这通常是一件好事,但特别是在更复杂的问题中,给“临时”变量命名会很有帮助。当仅使用“FP”Haskell 语法时,这需要 lambdas 或显式命名的函数。两者都有很好的用例,但前者在您的代码中间引入了相当多的噪音,而后者则破坏了“流程”,因为它需要一个wherelet放置在您使用它的其他地方。do另一方面,它允许您在需要的地方引入命名变量,而不会引入任何噪音。

于 2013-05-24T11:12:03.250 回答
42

我经常发现自己首先用do符号写了一个单子动作,然后将它重构为一个简单的单子(或函子)表达式。这主要发生在do块比我预期的短时。有时我会朝相反的方向重构;这取决于有问题的代码。

我的一般规则是:如果do块只有几行长,它通常作为一个简短的表达式更整洁。长do块可能更具可读性,除非您能找到一种方法将其分解为更小、更可组合的函数。


作为一个工作示例,我们可以将您的详细代码片段转换为您的简单代码片段。

main = do
    strFile <- readFile "testfile.txt"
    let analysisResult = stringAnalyzer strFile
    return analysisResult

首先,请注意最后两行的格式为let x = y in return x。这当然可以简单地转化为return y.

main = do
    strFile <- readFile "testfile.txt"
    return (stringAnalyzer strFile)

这是一个非常短的do块:我们绑定readFile "testfile.txt"到一个名称,然后在下一行对该名称执行一些操作。让我们像编译器一样尝试“脱糖”:

main = readFile "testFile.txt" >>= \strFile -> return (stringAnalyser strFile)

查看右侧的 lambda 形式>>=。它乞求以无点风格重写:\x -> f $ g x成为\x -> (f . g) x哪个成为f . g.

main = readFile "testFile.txt" >>= (return . stringAnalyser)

这已经比原来的do块整洁了很多,但我们可以走得更远。

这是唯一需要稍加思考的步骤(尽管一旦您熟悉了 monad 和 functor 就应该很明显了)。上述函数暗示了单子定律之一:(m >>= return) == m. 唯一的区别是 右侧的函数>>=不仅仅是return- 我们在将 monad 中的对象重新包装在return. 但是“在不影响其包装器的情况下对包装值做某事”的模式正是Functor它的用途。所有的 monad 都是函子,所以我们可以重构它,这样我们甚至不需要Monad实例:

main = fmap stringAnalyser (readFile "testFile.txt")

最后,请注意,这<$>只是另一种写作方式fmap

main = stringAnalyser <$> readFile "testFile.txt"

我认为这个版本比原始代码清晰得多。它可以读成一句话:“应用于阅读的结果main” 。原始版本使您陷入其操作的程序细节中。stringAnalyser"testFile.txt"


附录:我关于“所有单子都是函子”的评论实际上可以通过观察m >>= (return . f)(又名标准库的liftM)与fmap f m. 如果您有 的实例Monad,您将获得Functor“免费”的实例 - 只需定义fmap = liftM!如果有人Monad为他们的类型定义了一个实例,但没有为Functorand定义实例Applicative,我会称之为错误。客户希望能够在没有太多麻烦Functor的情况下使用方法。Monad

于 2013-05-24T11:07:12.983 回答
10

在任何情况下我们都应该避免 do-notation 吗?

肯定会说不。对我来说,在这种情况下最重要的标准是使代码尽可能地可读和易于理解。引入-notationdo是为了使一元代码更易于理解,这很重要。当然,在许多情况下,使用Applicative无点表示法非常好,例如,而不是

do
    f <- [(+1), (*7)]
    i <- [1..5]
    return $ f i

我们只写[(+1), (*7)] <*> [1..5].

但是有很多例子表明不使用do-notation 会使代码变得非常不可读。考虑这个例子

nameDo :: IO ()
nameDo = do putStr "What is your first name? "
            first <- getLine
            putStr "And your last name? "
            last <- getLine
            let full = first++" "++last
            putStrLn ("Pleased to meet you, "++full++"!")

这里很清楚发生了什么以及IO动作是如何排序的。do-free 符号看起来像

name :: IO ()
name = putStr "What is your first name? " >>
       getLine >>= f
       where
       f first = putStr "And your last name? " >>
                 getLine >>= g
                 where
                 g last = putStrLn ("Pleased to meet you, "++full++"!")
                          where
                          full = first++" "++last

或喜欢

nameLambda :: IO ()
nameLambda = putStr "What is your first name? " >>
             getLine >>=
             \first -> putStr "And your last name? " >>
             getLine >>=
             \last -> let full = first++" "++last
                          in  putStrLn ("Pleased to meet you, "++full++"!")

这两者的可读性都要低得多。当然,这里的do- 表示法更可取。

如果您想避免使用do,请尝试将您的代码结构化为许多小函数。无论如何,这是一个好习惯,您可以将do块减少到仅包含 2-3 行,然后可以很好地替换为>>=, <$>,<*>` 等。例如,上面可以重写为

name = getName >>= welcome
  where
    ask :: String -> IO String
    ask s = putStr s >> getLine

    join :: [String] -> String
    join  = concat . intersperse " "

    getName :: IO String
    getName  = join <$> traverse ask ["What is your first name? ",
                                      "And your last name? "]

    welcome :: String -> IO ()
    welcome full = putStrLn ("Pleased to meet you, "++full++"!")

它有点长,对于 Haskell 初学者来说可能有点难以理解(由于intersperse,concattraverse),但在许多情况下,这些新的小函数可以在代码的其他地方重用,这将使其更具结构化和可组合性。


我想说这种情况与是否使用无点表示法非常相似。在许多情况下(例如最上面的示例[(+1), (*7)] <*> [1..5]),无点表示法很棒,但是如果您尝试转换复杂的表达式,您将得到如下结果

f = ((ite . (<= 1)) `flip` 1) <*>
     (((+) . (f . (subtract 1))) <*> (f . (subtract 2)))
  where
    ite e x y = if e then x else y

如果不运行代码,我需要很长时间才能理解它。[以下剧透:]

f x = if (x <= 1) then 1 else f (x-1) + f (x-2)


另外,为什么大多数教程都用 do 来教授 IO?

因为IO被设计用来模仿带有副作用的命令式计算,所以使用它们的顺序do是非常自然的。

于 2013-05-25T10:12:17.697 回答
10

应该鼓励应用风格,因为它组合(而且更漂亮)。在某些情况下,一元风格是必要的。有关深入解释,请参阅https://stackoverflow.com/a/7042674/1019205 。

于 2013-05-24T02:29:56.287 回答
6

do表示法只是一种语法糖。在所有情况下可以避免。但是,在某些情况下,替换do>>=andreturn会降低代码的可读性。

因此,对于您的问题:

“无论如何我们都应该避免 Do 语句吗?”。

专注于使您的代码清晰易读。有帮助时使用do,否则避免使用。

我还有一个问题,为什么大多数教程都会教 IO 与 do?

因为do在许多情况下使 IO 代码更具可读性。

此外,大多数开始学习 Haskell 的人都有命令式编程经验。教程适用于初学者。他们应该使用新人容易理解的风格。

于 2013-05-24T02:35:10.143 回答
2

使用函数和和表达式将do符号扩展为表达式。所以它不是语言核心的一部分。(>>=)(>>)let

(>>=)(>>)用于顺序组合动作,当动作的结果改变后续动作的结构时,它们是必不可少的。

在问题给出的示例中,这并不明显,因为只有一个IO动作,因此不需要排序。

例如考虑表达式

do x <- getLine
   print (length x)
   y <- getLine
   return (x ++ y)

这被翻译成

getLine >>= \x ->
print (length x) >>
getLine >>= \y ->
return (x ++ y)

在此示例中,需要do符号(或(>>=)and(>>)函数)来对IO动作进行排序。

所以迟早程序员会需要它。

于 2013-05-24T10:37:41.533 回答