如何读取Haskell字符串

4

我完全是Haskell的初学者,虽然熟悉Python、F#、Java、C#和C++等语言中的函数式编程范式(在有限的程度上)。

有一件事情总是让我困扰,那就是Haskell中的IO。我尝试了多次,甚至在尝试解决这个问题期间学习了C#和F#。

更具体地说,我指的是在没有使用do符号时进行IO操作,在使用do符号后,IO变得微不足道。这可能是不好的实践方式,但在我的业余时间里,我喜欢看看是否可以在一个连续的表达式中完成任务。尽管这样做是不好的实践方式,但很有趣。

这样的表达式通常是这样的(伪Haskell代码):

main = getStdinContentsAsString 
           >>= ParseStringToDataStructureNeeded
           >>= DoSomeComputations 
           >>= ConvertToString 
           >>= putStrLn

我对后面四部分没有问题。我学习F#的原因之一就是想看看除了IO外是否还有什么东西我没有理解,但是一旦我使用方便的Console.ReadLine()函数返回一个普通的字符串,基本上就很顺利了。
这让我重新尝试了Haskell,但再次被IO机制卡住了。
我已经成功(使用这里的另一个问题)从控制台读取一个整数,并打印出“Hello World!”这么多次。
main = (readLn :: IO Int) >>= \n -> mapM_ putStrLn $ replicate n "Hello World!"

我希望至少能够以一种“通用”的方式读取stdin的全部内容(可能包括多行),所以getContents函数是我的首选,然后我可以使用其他函数如unlines和map来处理字符串。

我已经尝试了一些方法:

正如我所说,除非有等价的方法,否则我需要getContents函数。

使用逻辑,因为

getContents :: IO String

然后我需要一个将IO字符串转换为普通字符串的函数。据我所知,这就是它的功能。

unsafePerformIO :: IO a -> a

然而由于某些原因,ghc并不满意:

* Couldn't match type `[Char]' with `IO (IO b)'
  Expected type: String -> IO b
    Actual type: IO (IO b) -> IO b
* In the second argument of `(>>=)', namely `unsafePerformIO'
  In the expression: getContents >>= unsafePerformIO

我尝试了另一件事情:这个没有问题地工作;

main = getContents >>= putStrLn

尽管getContents返回的类型是IO动作,而不是putStrLn需要的字符串本身。
getContents :: IO String
putStrLn    :: String -> IO ()

不知何故,操作会自动执行,并将生成的字符串传递到put函数中。

但是当我尝试添加一些内容时,例如仅在打印之前附加“ hello”:

main = getContents >>= (++ " hello") >>= putStrLn

我突然遇到了类型不匹配的问题:

Couldn't match type `[]' with `IO'
  Expected type: String -> IO Char
    Actual type: [Char] -> [Char]
* In the second argument of `(>>=)', namely `(++ " hello")'
  In the first argument of `(>>=)', namely
    `getContents >>= (++ " hello")'
  In the expression: getContents >>= (++ " hello") >>= putStrLn

一些IO操作不再执行了(或者我只是不理解)。
我也尝试了很多事情,使用了getLinereadLngetContentsunsafePerformIOreadfmap的组合,但都没有成功。
这只是一个非常基本的例子,但它完美地说明了让我放弃Haskell几次的问题(可能不只是我一个人),尽管想要理解这个几乎是“函数式编程语言之王”的倔强使我一直回来。
总之:
1. 我有什么没搞懂的吗?(99%是)
2. 如果是的话,那是什么?
3. 我应该如何阅读整个stdin并在一个连续的表达式中处理它?(如果我只需要一行,我猜无论解决方案是什么,它也会与getLine一起工作,因为它基本上是getContents的姊妹)
提前感谢!

2
是的。您混淆了包装在IO单子中的值和简单值。如果您执行m >>= f,则传递给f的是包装在IO中的内容f具有签名f :: a-> IO b,因此它会生成另一个(可能是相同的)包装在IO单子中的值。 因此,getContents >>= (++ " hello")没有意义,因为(++ "hello")不返回IO b。 但是,您可以使用getContents >>= putStrLn . (++ " hello") - Willem Van Onsem
@WillemVanOnsem 对不起,但是我仍然不清楚。如果只将包含在 IO 中的内容作为参数传递,那么在 getContents >>= (++ " hello") 中,应该将 String(或 [Char]) 作为参数传递到 (++ " hello") 中,对吗? (++ " hello") 的类型为 :: [Char] -> [Char],因此如果将一个字符串传递给 (++ " hello"),问题在哪里? - Rares Dima
1
我认为你关于do记法的结论是错误的,它只是一种语法扩展。如果你不知道如何不使用do记法编写程序,那么你就没有真正理解它。 - freestyle
你似乎在寻找interact函数。main = interact (++ " hello") - 4castle
1
未来可能有帮助的一件事是使用do符号写出您的函数,然后手动展开它,或者思考一下您手动使用>>=编写的表达式在do符号中会是什么样子。例如,您错误的表达式getContents >>= (++ " hello") >>= putStrLn对应于do符号do { x <- getContents; y <- x ++ " hello"; putStrLn y },这清楚地说明了错误所在:您有一个y <-,因此右侧必须是一个IO操作,但实际上它只是一个String。解决方案之一是将其包装在pure(或return)中,使其成为一个操作。 - Jon Purdy
3个回答

11

您没有考虑的主要问题似乎是 >>= 的类型:

(>>=) :: IO a -> (a -> IO b) -> IO b

在其他话中,你不需要将 IO String 解开成 String>>= 操作符已经将一个纯的 String 传递给它的右操作数(函数):
getContents >>= (\s -> ...)
--                ^ s :: String

getContents >>= (++ " hello") 失败的原因是 >>= 要求函数返回一个IO... 值,但 (++ "hello") :: String -> String

你可以通过添加 return :: a -> IO a 来解决此问题:

getContents >>= (return . (++ "hello"))

这整个表达式的类型为IO String。当执行时,它将从stdin读取数据,将"hello"附加到数据中,然后返回结果字符串。
因此,
getContents >>= (return . (++ "hello")) >>= putStrLn

应该能正常工作。但是这比必要的复杂。从概念上讲,returnIO包装一个值,>>=再次解除其包装(有点像)。

我们可以在右侧合并return / >>=部分:

getContents >>= (\s -> putStrLn (s ++ "hello"))

即,不是采用getContents :: IO String,将"hello"添加到其中形成新的IO String动作,然后再将putStrLn :: String -> IO ()附加到其中,而是将putStrLn封装起来创建一个新的String -> IO ()函数(在将其传递给putStrLn之前将"hello"附加到其参数中)。

现在,如果我们想要,可以通过标准的无点技巧来消除s

getContents >>= (putStrLn . (++ "hello"))

关于IO的说明:需要记住的是,IO ... 是一个正常的 Haskell 类型。这里没有任何魔法。 >>= 不执行任何操作,它只是将类型为IO something的值和一个构造新类型IO somethingelse值的函数组合在一起。

可以将Haskell视为一个纯元语言,将命令式程序(即要执行的指令列表)作为内存中的数据结构构造出来。实际执行的唯一内容是绑定到Main.main的值。也就是说,类似于一种命令式运行时运行您的Haskell代码以生成main :: IO ()中的纯值。然后以命令式指令的形式执行该值的内容。

main :: IO ()
main =
    putChar 'H' >>
    putChar 'i' >>
    putChar '\n'

main绑定到代表命令式程序的数据结构print 'H'; print 'i'; print newline。运行Haskell程序将构建这个数据结构,然后运行时执行它。

然而这个模型并不完整:命令式运行时可以调用回Haskell代码。 >>= 可以用于在命令式代码中“嵌入”Haskell函数,这些函数可以(在运行时)检查值、决定下一步该做什么等等。但所有这些都以纯Haskell代码的形式发生;只有从x >>= f 中返回的IO值才是重要的(f 本身没有副作用)。


2
赞赏你使用非常必要(对于新手来说)的括号,即使它们是多余的和不太酷。 :) - Will Ness
f本身没有副作用”,因为它纯粹地构建了一个IO操作描述,然后由命令式运行时运行,并且在那个时候可能会产生一些副作用。 - Will Ness

4
  1. Is there something I'm not getting?(99% yes)

    Yes.

  2. If yes, then what?

    An IO String is something conceptually utterly different from a String. The former is like a cooking recipe, the latter like a meal.
    Until you feel like an expert Haskeller, you'd better forget that there's such a thing as unsafePerformIO. That is something you should never need in normal Haskell code, only for FFI bindings to impure C code or for last-resort optimisations.

  3. How should I go about reading the whole stdin and processing it all in one continuous expression?

    main = getContents >>= putStrLn . (++ " hello")
    

    Note that there are only two IO actions here: getContents and putStrLn. So you only need one bind operator to get information from one action to the other.
    In between, you have the pure (++ " hello"). That does not need any monadic bind, just function composition, to channel through information.
    If you find the mixed direction of information-flow ugly, you can also use the flipped bind:

    main = putStrLn . (++ " hello") =<< getContents
    

    Alternatively, you could use a monadic bind, but you'd first need to masquerade the pure function as an IO action (an action which doesn't make use of any of the side-effect possibilities):

    main = getContents >>= pure . (++ " hello") >>= putStrLn
    

    Or you could, rather than “transforming putStrLn to prepend " hello" after the stuff it prints”, instead “transform getContents to prepend " hello" to the stuff it fetches”:

    main = (++ " hello")<$>getContents >>= putStrLn
    

    All of these are equivalent by the monad laws.


谢谢!这解释得非常清楚。 如果可以的话,我还有一个问题。pure函数是做什么的?文档中只说它“提升了一个值”,这是什么意思? - Rares Dima
1
pure 几乎与 return 相同,只是稍微更通用且命名更好。(return 在一些情况下类似于过程式语言的 return 关键字,但有非常显著的区别。) 两者都只是将一个纯值“包装”起来,使其具有不实际产生任何副作用的单子动作类型。 “Masquerade” 实际上是一个非常合适的词。 - leftaroundabout

3
  1. 我有什么没理解的吗?(99% 的概率是有的)

是的。右侧的函数在 >>= 符号的右侧,它是一个带有签名 a -> m b函数,其中 m 是单子。

一个比喻:生日礼物

帮助我理解单子(和具有绑定函数 >>= 的函数)的一些东西是不要想着 IO

您还可以将单子(请注意,IO 只是许多单子之一)视为集合。例如,Maybe a,这也是一个单子。

您可以将 Maybe a 视为某种“盒子”。该盒子可以具有对象(在 Just x 的情况下),或者可以是“空盒子”(而不是 Nothing)。

现在,绑定运算符 >>= 在左侧具有这样的盒子,在另一侧具有函数 f :: a -> Maybe b。想象一下,f 是一个人,现在是他/她的生日。他/她收到了盒子的内容,必须将另一个礼物传递给日历上的下一个人。因此,绑定运算符 >>=打开该盒子,将其传递给该人,并期望能够进一步处理新礼物。

所以简单来说:您必须返回一个新的盒子。现在,(++ " hello") 接受字符串作为输入,但它不会将该内容放入新盒子中(所以没有派对 :( )。

但是您可以自己将其包装在盒子中。为此,有 return 函数(这是一个函数,而不是关键字)。因此,您可以将其编写为:

getContents >>= <b>return .</b> (++ " hello") >>= putStrLn

请注意,这些函数并不一定要给出相同的“礼物”,但“盒子”的类型(单子)必须相同。例如,putStrLn 的类型为String -> IO ()。因此,putStrLn 是一个可以用字符串作为礼物使之快乐的人,函数将返回一个带有()实例的盒子(或者是一个空盒子,因为()只有一个值:())。
因此,我们可以通过一个函数来简单地进行字符串处理,比如:
getContents >>= putStrLn<b> . (++ " hello")</b>

如果你写了如下的一段代码:

a >>= b >>= c >>= d >>= e

这意味着a将构建第一个盒子。绑定运算符将打开盒子并将内容传递给b。基于内容,b将构建一个新盒子(其中包含他/她了解c所喜欢的对象类型)。绑定运算符打开盒子并将内容传递给c,以此类推。
这与I/O有什么关系?
I/O函数可以看作生日礼物故事中的人物。它们返回一个“IO盒子”,里面装有内容。绑定运算符将打开“IO盒子”(事实上>>=是少数几个知道如何打开IO盒子的函数之一),并将内容传递给下一个函数。通过这种方式,它会强制执行顺序,因为下一个人在未检查自己的礼物和构建新礼物之前无法处理礼物。 Maybe再访
I/O是相当难以理解的I/O单子,因为它已经被连接到计算机系统中。更容易理解的单子是Maybe单子。正如我们已经讨论过的那样,Maybe具有以下构造器:
data Maybe a = Nothing | Just a

现在我们把Nothing想象成:
  +---+
 /   /|
+---+ +
|   |/
+---+

Just x翻译为:

  +---+
 /   /|
+---+ +
| x |/
+---+

一个带内容的盒子。当然,你可能知道如何拆开Just x构造函数并获取x值。但是想象一下,我们不允许这样做。只有>>=操作符可以打开Maybe盒子。

那么我们可以使用以下方法构建monad:

instance Monad Maybe where
    return x = Just x

    (>>=) Nothing _ = Nothing
    (>>=) (Just x) f = f x

我们看到的是一个return函数,它将一个对象包装成一个Maybe "盒子",因此使用return,我们可以制作一个“礼物”。
绑定运算符将检查左侧的礼物。如果发现盒子里没有任何东西,它将不会打扰右侧的人,只会返回Nothing
如果盒子里包含一个x,它将把该对象交给f,但期望f构建一个新的礼物。因此,>>=打开了这个礼物。他/她是一个“专业的生日礼物拆礼物手”。
所以现在我们可以写出像这样的代码:
Just 2 >>= Just . (+5) >>= Just . (*6)

它将返回Just 42。为什么?我们从一个包含2的礼物开始。 >>=打开礼物并将内容传递给Just . (+2),因此我们计算(Just . (+2)) 2。注意,Just 2Just已经消失了。现在我们评估这个,因此链中的第二个项目向系统提供了Just 7
然后再次打开该包裹,你猜怎么着,它包含一个7,现在7被传递给最后一个函数Just . (*6),因此最后一个函数会将其当前值乘以6,并将其重新装入盒子中。
但是,如果您编写:
Just 2 >>= (+5) >>= Just . (*6)

这将会失败。为什么?因为显然第二个函数情绪不好,忘记把礼物装进盒子里。


谢谢!盒子的比喻真的很有用。虽然很奇怪,没有任何教程或文档(至少我找到的)提到你需要在下一个绑定之前重新包装值,有些甚至暗示一旦你解包它,就可以顺利进行了。 - Rares Dima

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