跳转到内容

Haskell/序言:IO,一个应用函子

来自 Wikibooks,开放的书,开放的世界

函子的出现是本书发展中的一个分水岭。在本章中,我们将开始揭示这些原因,为本书接下来的几章奠定基础。虽然我们在这里使用的代码示例非常简单,但我们将利用它们引入几个新的重要概念,这些概念将在本书的后面章节中重新审视和进一步发展。因此,我们建议您以轻松的速度学习本章,这将为您提供思考每个步骤的影响以及在 GHCi 中尝试代码示例的空间。

场景 1 : Applicative

[edit | edit source]

我们的初始示例将使用 Text.Read 模块提供的函数 readMaybe

GHCi> :m +Text.Read
GHCi> :t readMaybe
readMaybe :: Read a => String -> Maybe a

readMaybe 提供了一种简单的方法将字符串转换为 Haskell 值。如果提供的字符串具有正确的格式,可以读取为类型 a 的值,则 readMaybe 会将转换后的值包装在 Just 中;否则,结果为 Nothing

GHCi> readMaybe "3" :: Maybe Integer
Just 3
GHCi> readMaybe "foo" :: Maybe Integer
Nothing
GHCi> readMaybe "3.5" :: Maybe Integer
Nothing
GHCi> readMaybe "3.5" :: Maybe Double
Just 3.5

备注

要使用 readMaybe,我们需要指定要读取的类型。大多数情况下,这可以通过类型推断和代码中的签名组合来完成。然而,有时,简单地添加一个 *类型注释* 比写一个完整的签名更方便。例如,在上面的第一个示例中,readMaybe "3" :: Maybe Integer 中的 :: Maybe Integer 表示 readMaybe "3" 的类型为 Maybe Integer


我们可以使用 readMaybe 用类似于 *简单输入和输出* 章节中的那些程序的风格写一个小的程序,该程序

  • 通过命令行获取用户提供的字符串;
  • 尝试将其读入一个数字(让我们使用 Double 作为类型);并且
  • 如果读取成功,则打印您的数字乘以 2;否则,打印解释性消息并重新开始。

备注

在继续之前,我们建议您尝试编写该程序。除了 readMaybe 之外,您可能会发现 getLineputStrLnshow 有用。如果您需要关于如何从控制台读取和打印的提醒,请查看 *简单输入和输出* 章节。


这里是一个可能的实现

import Text.Read

interactiveDoubling = do
    putStrLn "Choose a number:"
    s <- getLine
    let mx = readMaybe s :: Maybe Double
    case mx of
        Just x -> putStrLn ("The double of your number is " ++ show (2*x))
        Nothing -> do
            putStrLn "This is not a valid number. Retrying..."
            interactiveDoubling
GHCi> interactiveDoubling 
Choose a number:
foo
This is not a valid number. Retrying...
Choose a number:
3
The double of your number is 6.0

简洁明了。此解决方案的变体可能会利用 Maybe 作为 Functor 的事实,我们可以在 case 语句中解开 mx 之前对值进行加倍

interactiveDoubling = do
    putStrLn "Choose a number:"
    s <- getLine
    let mx = readMaybe s :: Maybe Double
    case fmap (2*) mx of
        Just d -> putStrLn ("The double of your number is " ++ show d)
        Nothing -> do
            putStrLn "This is not a valid number. Retrying..."
            interactiveDoubling

在这种情况下,这样做没有真正的优势。但是,请记住这种可能性。

函子中的应用

[edit | edit source]

现在,让我们做一些稍微复杂的事情:使用 readMaybe 读取两个数字并打印它们的总和(我们建议您在继续之前尝试编写这个程序)。

这里有一个解决方案

interactiveSumming = do
    putStrLn "Choose two numbers:"
    sx <- getLine
    sy <- getLine
    let mx = readMaybe sx :: Maybe Double
        my = readMaybe sy
    case mx of
        Just x -> case my of
            Just y -> putStrLn ("The sum of your numbers is " ++ show (x+y))
            Nothing -> retry
        Nothing -> retry
    where
    retry = do
        putStrLn "Invalid number. Retrying..."
        interactiveSumming
GHCi> interactiveSumming
Choose two numbers:
foo
4
Invalid number. Retrying...
Choose two numbers:
3
foo
Invalid number. Retrying...
Choose two numbers:
3
4
The sum of your numbers is 7.0

interactiveSumming 有效,但编写起来有点烦人。特别是嵌套的 case 语句不太美观,并且使得代码阅读有点困难。如果有一种方法可以在解开它们之前对数字求和,类似于我们在 interactiveDoubling 的第二个版本中使用 fmap 的方式,我们就可以只使用一个 case

-- Wishful thinking...
    case somehowSumMaybes mx my of
        Just z -> putStrLn ("The sum of your numbers is " ++ show z)
        Nothing -> do
            putStrLn "Invalid number. Retrying..."
            interactiveSumming

但是我们应该用什么来代替 somehowSumMaybesfmap 就够了。虽然 fmap (+) 很好地将 (+) 部分应用于 Maybe 包装的值...

GHCi> :t (+) 3
(+) 3 :: Num a => a -> a
GHCi> :t fmap (+) (Just 3)
fmap (+) (Just 3) :: Num a => Maybe (a -> a)

... 但我们不知道如何将包装在 Maybe 中的函数应用于第二个值。为此,我们需要一个具有以下签名的函数...

(<*>) :: Maybe (a -> b) -> Maybe a -> Maybe b

... 然后像这样使用它

GHCi> fmap (+) (Just 3) <*> Just 4
Just 7

然而,此示例中的 GHCi 提示不是痴人说梦:(<*>) 确实存在,如果您在 GHCi 中尝试它,它实际上会起作用!如果我们使用 fmap 的中缀别名 (<$>),表达式看起来更整洁

GHCi> (+) <$> Just 3 <*> Just 4
Just 7

(<*>) 的实际类型比我们刚刚写的更通用。检查一下...

GHCi> :t (<*>)
(<*>) :: Applicative f => f (a -> b) -> f a -> f b

... 向我们介绍了一个新的类型类:Applicative,即 *应用函子* 的类型类。为了初步解释,我们可以说应用函子是一个支持在函子内部应用函数的函子,因此可以平滑地使用偏应用(因此可以使用多个参数的函数)。所有 Applicative 的实例都是 Functor,除了 Maybe 之外,还有许多其他常见的 Functor 也是 Applicative

这是 MaybeApplicative 实例

instance Applicative Maybe where
    pure                  = Just
    (Just f) <*> (Just x) = Just (f x)
    _        <*> _        = Nothing

(<*>) 的定义实际上非常简单:如果两个值都不是 Nothing,则将函数 f 应用于 x 并将结果包装在 Just 中;否则,返回 Nothing。请注意,该逻辑与 interactiveSumming 中嵌套的 case 语句所执行的逻辑完全相同。

请注意,除了 (<*>) 之外,上面的实例中还有一个第二种方法,即 pure

GHCi> :t pure
pure :: Applicative f => a -> f a

pure 接受一个值,并以默认的、平凡的方式将其带入函子中。在 Maybe 的情况下,平凡的方式相当于将值包装在 Just 中 - 非平凡的替代方案将是丢弃该值并返回 Nothing。使用 pure,我们可以将上面的三加四示例改写为...

GHCi> (+) <$> pure 3 <*> pure 4 :: Num a => Maybe a
Just 7

... 甚至

GHCi> pure (+) <*> pure 3 <*> pure 4 :: Num a => Maybe a
Just 7

就像 Functor 类有一些指定合理实例应该如何表现的规律一样,Applicative 也有一个规律集。其中,这些规律指定了通过 pure 将值带入函子的“平凡”方式是什么。由于本书的这一部分有太多内容,我们现在不讨论这些规律;然而,我们将在不久的将来回到这个重要的主题。

备注

无论如何,如果您好奇,可以自由地绕道进入 *应用函子* 章节并阅读其“应用函子规律”子节。如果您选择去那里,您不妨也看看“ZipList”部分,它提供了一个额外的示例,可以通过我们目前所学内容来理解一个常见的应用函子。


为了总结,这里有一个使用 (<*>) 增强的 interactiveSumming 版本

interactiveSumming = do
    putStrLn "Choose two numbers:"
    sx <- getLine
    sy <- getLine
    let mx = readMaybe sx :: Maybe Double
        my = readMaybe sy
    case (+) <$> mx <*> my of
        Just z -> putStrLn ("The sum of your numbers is " ++ show z)
        Nothing -> do
            putStrLn "Invalid number. Retrying..."
            interactiveSumming

场景 2 : IO

[edit | edit source]

在上面的示例中,我们一直将 getLine 等 I/O 操作视为理所当然。现在我们发现自己处于一个适当的时刻,可以重新审视一个在很多章节之前提出的问题:getLine 的类型是什么?

回到 *简单输入和输出* 章节,我们看到了这个问题的答案是

GHCi> :t getLine
getLine :: IO String

使用我们自那时以来学到的知识,我们现在可以看出 IO 是一个只有一个类型变量的类型构造器,它恰好在 getLine 的情况下被实例化为 String。然而,这并没有触及问题的核心:IO String 究竟是什么意思,它与普通的 String 有什么区别?

引用透明性

[edit | edit source]

Haskell 的一个关键特性是,我们能够写的所有表达式都是 *引用透明* 的。这意味着我们可以用任何表达式的值来替换它,而不会改变程序的行为。例如,考虑这个非常简单的程序

addExclamation :: String -> String
addExclamation s = s ++ "!"

main = putStrLn (addExclamation "Hello")

它的行为完全不出所料

GHCi> main
Hello!

鉴于 addExclamation s = s ++ "!",我们可以重写 main 使其不再提及 addExclamation。我们只需要在 addExclamation 定义的右侧将 s 替换为 "Hello",然后将 addExclamation "Hello" 替换为得到的表达式。正如预期的那样,程序行为没有改变

GHCi> let main = putStrLn ("Hello" ++ "!")
GHCi> main
Hello!

引用透明性确保了这种替换能够起作用。这种保证扩展到任何 Haskell 程序中的任何地方,这极大地提高了程序的可理解性,并使得预测其行为变得更加容易。

现在,假设 getLine 的类型为 String。在这种情况下,我们就可以将它用作 addExclamation 的参数,如下所示

-- Not actual code.
main = putStrLn (addExclamation getLine)

在这种情况下,一个新的问题会出现:如果getLine是一个String,它究竟是哪个String?没有令人满意的答案:它可能是"Hello""Goodbye",或者用户在终端上输入的任何其他内容。然而,用任何String替换getLine都会导致程序崩溃,因为用户将无法再在终端上输入字符串。因此,getLine具有String类型会破坏引用透明性。所有其他I/O操作也是如此:它们的结果是不透明的,因为不可能提前知道它们,因为它们取决于程序外部的因素。

拨开迷雾

[编辑 | 编辑源代码]

正如getLine所示,I/O操作存在着根本的不确定性。为了保持引用透明性,必须尊重这种不确定性。在Haskell中,这可以通过IO类型构造函数来实现。getLine是一个IO String,这意味着它不是任何实际的String,而是一个占位符,表示一个只有在程序执行时才会出现的String,并且它承诺这个String确实会传递过来(在getLine的情况下,通过从终端读取它)。因此,当我们操作一个IO String时,我们是在为这个未知的String出现后要做什么制定计划。实现这一点的方法有很多。在本节中,我们将考虑其中两种方法;在接下来的几章中,我们将添加第三种方法。

处理一个实际上并不存在的价值的概念起初可能看起来很奇怪。但是,我们已经讨论过至少一个与之类似的东西,而且我们没有眨眼。如果mx是一个Maybe Double,那么fmap (2*) mx会将该值加倍(如果它存在),并且无论该值是否实际存在,它都会正常工作。[1] Maybe aIO a都暗示着,出于不同的原因,在访问a类型的值时需要一层间接访问。因此,毫不奇怪的是,IO是一个Functorfmap是克服间接访问的最基本方法。

首先,我们可以利用IO是一个Functor这一事实,用更简洁的东西替换上一节末尾interactiveSumming中的let定义。

interactiveSumming :: IO ()
interactiveSumming = do
    putStrLn "Choose two numbers:"
    mx <- readMaybe <$> getLine -- equivalently: fmap readMaybe getLine
    my <- readMaybe <$> getLine
    case (+) <$> mx <*> my :: Maybe Double of
        Just z -> putStrLn ("The sum of your numbers is " ++ show z)
        Nothing -> do
            putStrLn "Invalid number. Retrying..."
            interactiveSumming

readMaybe <$> getLine可以理解为“一旦getLine传递了一个字符串,无论它是什么,都将readMaybe应用于它”。引用透明性没有受到损害:readMaybe <$> getLine背后的值与getLine一样不透明,它的类型(在本例中为IO (Maybe Double))阻止我们用任何确定值(例如,Just 3)替换它,因为这会违反引用透明性。

除了是一个Functor之外,IO也是一个Applicative,它为我们提供了一种操作I/O操作传递的值的第二种方法。我们将用一个类似于interactiveSumminginteractiveConcatenating操作来演示它。第一个版本就在下面。你能预想到如何使用(<*>)来简化它吗?

interactiveConcatenating :: IO ()
interactiveConcatenating = do
    putStrLn "Choose two strings:"
    sx <- getLine
    sy <- getLine
    putStrLn "Let's concatenate them:"
    putStrLn (sx ++ sy)

这是一个利用(<*>)的版本。

interactiveConcatenating :: IO ()
interactiveConcatenating = do
    putStrLn "Choose two strings:"
    sz <- (++) <$> getLine <*> getLine
    putStrLn "Let's concatenate them:"
    putStrLn sz

(++) <$> getLine <*> getLine是一个I/O操作,它由另外两个I/O操作(两个getLine)组成。当它被执行时,这两个I/O操作会被执行,它们传递的字符串会被连接起来。需要注意的一点是,(<*>)保持了它组合的行动之间一致的执行顺序。在处理I/O时,执行顺序很重要——这样的例子不胜枚举,但首先请考虑这个问题:如果我们将上面的例子中的第二个getLine替换为(take 3 <$> getLine),哪个在终端输入的字符串会被截断到三个字符?

由于(<*>)尊重操作的顺序,它提供了一种对它们进行排序的方法。特别是,如果我们只关心排序,而不在乎第一个操作的结果,我们可以使用\_ y -> y来丢弃它。

GHCi> (\_ y -> y) <$> putStrLn "First!" <*> putStrLn "Second!"
First!
Second!

这是一种非常常见的用法模式,因此有一个专门的操作符用于它:(*>)

u *> v = (\_ y -> y) <$> u <*> v
GHCi> :t (*>)
(*>) :: Applicative f => f a -> f b -> f b
GHCi> putStrLn "First!" *> putStrLn "Second!"
First!
Second!

它可以很容易地应用于interactiveConcatenating示例。

interactiveConcatenating :: IO ()
interactiveConcatenating = do
    putStrLn "Choose two strings:"
    sz <- (++) <$> getLine <*> getLine
    putStrLn "Let's concatenate them:" *> putStrLn sz

或者,更进一步。

interactiveConcatenating :: IO ()
interactiveConcatenating = do
    sz <- putStrLn "Choose two strings:" *> ((++) <$> getLine <*> getLine)
    putStrLn "Let's concatenate them:" *> putStrLn sz

请注意,每个(*>)都替换了do块中的一个神奇的换行符,这些换行符导致操作一个接一个地执行。事实上,这就是替换的换行符的全部内容:它们只是(*>)的语法糖。

早些时候,我们说过,一个函子添加了一层间接访问,以便访问其中的值。这一观察的另一面是,间接访问是由一个上下文引起的,值就在这个上下文中找到。对于IO来说,间接访问是指值只有在程序执行时才会被确定,而上下文则是一系列用于生成这些值的指令(在getLine的情况下,这些指令相当于“从终端读取一行文本”)。从这个角度来看,(<*>)获取两个函子值,不仅组合其中的值,而且组合上下文本身。在IO的情况下,组合上下文意味着将一个I/O操作的指令追加到另一个I/O操作的指令,从而对操作进行排序。

开端的结束

[编辑 | 编辑源代码]

本章内容有点像旋风!让我们回顾一下本章讨论的关键点。

  • ApplicativeFunctor的一个子类,用于应用函子,它们是支持函数应用而不离开函子的函子。
  • Applicative(<*>)方法可以作为fmap对多个参数的泛化。
  • IO a不是一个类型为a的实际值,而是一个占位符,表示一个只有在程序执行时才会出现的a值,以及一个承诺,表示这个值将通过某种方式传递过来。这使得即使在处理I/O操作时也能实现引用透明性。
  • IO是一个函子,更具体地说,它是Applicative的一个实例,它提供了一种方法,尽管存在不确定性,但仍然可以修改由I/O操作产生的值。
  • 一个函子值可以被看作是由一个上下文中的值组成的。(<$>)操作符(即fmap)通过上下文来修改底层的值。(<*>)操作符组合了两个函子值的值和上下文。
  • IO的情况下,(<*>),以及与之密切相关的(*>),通过对I/O操作进行排序来组合上下文。
  • do块很大一部分的作用只是为(*>)提供语法糖。

最后,请注意,do块背后的一个主要谜团还有待解释:左箭头起什么作用?在类似于...这样的do块行中。

sx <- getLine

...看起来我们正在从IO上下文中提取getLine产生的值。由于我们对引用透明性的讨论,我们现在知道这必须是一种错觉。但幕后到底发生了什么?请随意下注,因为我们即将揭晓答案!

注释

  1. 这两种情况之间的关键区别在于,对于Maybe,不确定性只是表面的,并且可以提前判断mx后面是否存在一个实际的Double——或者,更准确地说,只要mx的值不依赖于I/O,就可以这样做!
华夏公益教科书