在Haskell中理解纯函数和副作用 - putStrLn

12

最近,我开始学习Haskell,因为我想拓展自己在函数式编程方面的知识,目前我正在使用Pluralsight上的“Haskell Fundamentals Part 1”课程资源。不幸的是,我有一些困难理解讲师关于以下代码的特定引用,并希望你们能够解释一下这个主题。

附带代码

helloWorld :: IO ()
helloWorld = putStrLn "Hello World"

main :: IO ()
main = do
    helloWorld
    helloWorld
    helloWorld

引言

如果你在一个 do-block 中使用了相同的 IO 操作,它将会被运行多次。因此,这个程序会打印出字符串“Hello World”三次。这个例子有助于说明 putStrLn 不是一个具有副作用的函数。我们调用 putStrLn 函数一次来定义 helloWorld 变量。如果 putStrLn 具有打印字符串的副作用,它只会打印一次,而主要的 do-block 中重复出现的 helloWorld 变量则不会产生任何效果。

在大多数其他编程语言中,这样一个程序只会打印一次“Hello World”,因为打印会在调用 putStrLn 函数时发生。这种微妙的差别往往会让初学者犯迷糊,因此请认真思考一下,并确保你理解为什么这个程序会打印三次“Hello World”,以及如果 putStrLn 函数将打印作为副作用,则为什么只会打印一次。

我不明白的地方

对我来说,字符串“Hello World”被打印三次似乎是很自然的。我把 helloWorld 变量(或函数?)看作是一种稍后调用的回调。我不明白的是,如果 putStrLn 具有副作用,为什么它只会导致字符串被打印一次。或者为什么在其他编程语言中它只会被打印一次。

假设在 C# 代码中,我会认为它看起来像这样:

C# (Fiddle)


using System;

public class Program
{
    public static void HelloWorld()
    {
        Console.WriteLine("Hello World");
    }

    public static void Main()
    {
        HelloWorld();
        HelloWorld();
        HelloWorld();
    }
}

我确信我忽略了某些简单的东西或者误解了他的术语。非常感谢您的帮助。

编辑:

非常感谢大家的回答!你们的回答帮助我更好地理解了这些概念。我觉得还没有完全明白,但我将来会再次研究这个话题,谢谢!


2
想象一下 helloWorld 就像是 C# 中的常量,例如字段或变量。没有任何参数被应用于 helloWorld - Caramiriel
2
putStrLn 没有副作用;它只是返回一个 IO 动作,对于参数 "Hello World",无论你调用 putStrLn 多少次,它都返回相同的 IO 动作。 - chepner
1
如果是这样的话,“helloworld”就不会是一个打印“Hello world”的操作,而是在它打印“Hello World”之后由“putStrLn”返回的值(即“()”)。 - chepner
2
我认为要理解这个例子,你已经必须了解Haskell中的副作用是如何工作的。这不是一个好的例子。 - user253751
在您的C#片段中,您不会像helloWorld = Console.WriteLine("Hello World");那样做。您只需将Console.WriteLine("Hello World");包含在HelloWorld函数中,以便每次调用HelloWorld时执行。现在考虑一下helloWorld = putStrLn "Hello World"是如何使helloWorld成为一个IO单子的。它被分配给包含“()”的IO单子。一旦您通过>>=将其绑定,它才会执行它的活动(打印某些内容),并在绑定运算符的右侧给出() - Redu
显示剩余2条评论
4个回答

10

如果我们将helloWorld定义为局部变量,那么理解作者的意思可能会更容易:

main :: IO ()
main = do
  let helloWorld = putStrLn "Hello World!"
  helloWorld
  helloWorld
  helloWorld

您可以将其与此类似于C#的伪代码进行比较:

void Main() {
  var helloWorld = {
    WriteLine("Hello World!")
  }
  helloWorld;
  helloWorld;
  helloWorld;
}

也就是说,在C#中,WriteLine是一个打印其参数并返回空值的过程。而在Haskell中,putStrLn是一个接受字符串并给出一个操作的函数,该操作会在执行时打印该字符串。这意味着写下面两种代码完全没有区别:

do
  let hello = putStrLn "Hello World"
  hello
  hello

do
  putStrLn "Hello World"
  putStrLn "Hello World"

话虽如此,在这个例子中,差异并不是特别显著,因此如果您暂时无法理解作者在本节中要表达的意思,并且只是继续前进也没关系。

如果将其与Python进行比较,则效果会略有改善。

hello_world = print('hello world')
hello_world
hello_world
hello_world

这里的重点是,Haskell中的IO操作是“真实”的值,不需要进一步包装或使用任何类似于回调的东西来防止它们执行 - 相反,唯一的方法是将它们放在特定的位置(即在main或从main生成的线程中的某个地方)才能使它们执行。

这不仅仅是一个花招,它确实对代码编写方式产生了一些有趣的影响(例如,这是Haskell不需要您熟悉的命令式语言的常见控制结构之一的原因,并且可以通过函数完成所有操作),但我不会过多担心此事(这些类比并不总是立即清晰明了)。


5

如果您使用一些实际执行操作的函数而不是 helloWorld,那么按照描述来看差异可能更容易理解。请考虑以下内容:

add :: Int -> Int -> IO Int
add x y = do
  putStrLn ("I am adding " ++ show x ++ " and " ++ show y)
  return (x + y)

plus23 :: IO Int
plus23 = add 2 3

main :: IO ()
main = do
  _ <- plus23
  _ <- plus23
  _ <- plus23
  return ()

这将会打印出"I am adding 2 and 3"这句话,打印三次。
在C#中,你可以这样写:
using System;

public class Program
{
    public static int add(int x, int y)
    {
        Console.WriteLine("I am adding {0} and {1}", x, y);
        return x + y;
    }

    public static void Main()
    {
        int x;
        int plus23 = add(2, 3);
        x = plus23;
        x = plus23;
        x = plus23;
        return;
    }
}

这将只打印一次。


3
如果putStrLn“Hello World”的评估具有副作用,那么消息将仅打印一次。 我们可以通过以下代码近似该场景:
import System.IO.Unsafe (unsafePerformIO)
import Control.Exception (evaluate)

helloWorld :: ()
helloWorld = unsafePerformIO $ putStrLn "Hello World"

main :: IO ()
main = do
    evaluate helloWorld
    evaluate helloWorld
    evaluate helloWorld

unsafePerformIO函数接受一个IO操作,并使其“忘记”自己是一个IO操作,从而解除了它通常受到IO操作组合所强加的顺序限制,根据惰性求值的变幻莫测让效果发生(或不发生)。

evaluate函数接受一个纯值并确保在评估由该函数生成的IO操作时对其进行评估——对我们来说,它将评价在main路径上。我们在此处使用它来将某些值的评估连接到程序的执行。

此代码仅打印一次“Hello World”。我们将helloWorld视为一个纯值。但这意味着它将在所有evaluate helloWorld调用之间共享。为什么不呢?毕竟它是一个纯值,为什么需要无谓地重新计算它?第一个evaluate操作“弹出”了“隐藏”的效果,后续操作仅评估结果(),这不会导致任何进一步的效果。


2
值得注意的是,在学习 Haskell 的这个阶段,绝对不应该使用 unsafePerformIO。它的名称中有"unsafe"一词是有原因的,除非你能够(并且已经)仔细考虑其在上下文中使用的影响,否则不应该使用它。danidiaz 在答案中放置的代码完美地捕捉了由 unsafePerformIO 可能导致的不直观行为。 - Andrew Ray

1
有一个细节需要注意:在定义helloWorld时,您只调用了一次putStrLn函数。在main函数中,您只是三次使用putStrLn "Hello, World"的返回值。
讲师说putStrLn调用没有副作用,这是正确的。但是看看helloWorld的类型-它是一个IO操作。 putStrLn只是为您创建它。稍后,您使用do块将其链接为3个以创建另一个IO操作-main。稍后,当您执行程序时,该操作将运行,副作用就在其中。
这背后的机制是monads。这个强大的概念允许您在不直接支持副作用的语言中使用一些副作用,如打印。您只需链接一些操作,该链将在程序启动时运行。如果您想认真使用Haskell,则需要深入了解该概念。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接