Haskell/序言:IO,一个 Applicative 函子
对于此章节的更短链接,无论是在书中还是在维基百科之外,可以使用 Haskell/Applicative prologue 重定向。 |
函子的出现是本书发展过程中的一个分水岭。本序言将开始揭示其原因,为本书接下来的几章奠定基础。虽然我们在这里使用的代码示例非常简单,但我们将用它们引入几个新的重要概念,这些概念将在本书后面的章节中被重新审视和进一步发展。因此,我们建议您以一种缓和的节奏学习本章,这样您将有时间思考每一步的含义,以及在 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
之外,您可能会发现 getLine
、putStrLn
和 show
有用。如果您需要提醒如何从控制台读取和打印到控制台,请查看 简单输入和输出 章节。
以下是一个可能的实现
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
但是我们应该用什么来代替 somehowSumMaybes
呢?fmap
就不够。虽然 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 函子_ 的类型类。对于初步解释,我们可以说,Applicative 函子是一个支持在函子内应用函数的函子,从而允许平滑地使用部分应用(因此支持多参数函数)。所有 Applicative
的实例都是 Functor
,除了 Maybe
之外,还有许多其他常见的 Functor
也是 Applicative
。
这是 Maybe
的 Applicative
实例
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
将值带入函子的“平凡”方式。由于本书这一部分的内容很多,我们现在不会讨论这些定律;然而,我们将在不久的将来回到这个重要主题。
注意
无论如何,如果您好奇,请随时绕道至 Applicative 函子 章节并阅读其“Applicative 函子定律”小节。如果您选择去那里,您不妨也看一下“ZipList”部分,它提供了一个额外的常见 Applicative 函子的示例,可以使用我们迄今为止所学到的内容来理解。
为了结束本章,以下是用 (<*>)
增强的 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
。我们要做的就是将 s
替换为 "Hello"
,位于 addExclamation
定义的右侧,然后将 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 操作也是如此:它们的输出结果是*不透明*的,因为无法事先知道它们,因为它们取决于程序外部的因素。
拨开迷雾
[edit | edit source]正如getLine
所例证的那样,I/O 操作存在着根本的不确定性。为了维护引用透明性,必须尊重这种不确定性。在 Haskell 中,这是通过IO
类型构造函数实现的。getLine
是一个IO String
,这意味着它不是任何实际的String
,而是一个String
的占位符,只有在程序执行时才会实现,并且承诺这个String
确实会被传递(在getLine
的情况下,是通过从终端获取)。因此,当我们操作一个IO String
时,我们就是在为这个未知String
出现后将要执行的操作制定计划。有很多方法可以做到这一点。在本节中,我们将考虑其中两种方法;在接下来的几章中,我们将添加第三种方法。
处理一个实际上并不存在的价值的概念,乍一看可能很奇怪。但是,我们已经讨论过至少一个例子,它与之非常相似,而且我们并没有对此感到惊讶。如果mx
是一个Maybe Double
,那么fmap (2*) mx
将对该值进行加倍*如果存在*,并且无论该值是否实际存在,它都将起作用。[1]Maybe a
和IO a
都意味着,由于不同的原因,在访问类型为a
的相应值时存在一层间接性。既然如此,就不足为奇的是,像Maybe
一样,IO
是一个Functor
,fmap
是穿越间接性的最基本方法。
首先,我们可以利用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 操作传递的值的方法。我们将用一个与interactiveSumming
类似的interactiveConcatenating
操作来说明这一点。第一个版本就在下面。你能预料到如何用(<*>)
简化它吗?
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 操作的指令,从而对操作进行排序。
开始的结束
[edit | edit source]本章有点像旋风!让我们回顾一下我们在本章中讨论的关键点。
Applicative
是应用函子的Functor
的子类,应用函子是支持在不离开函子的情况下进行函数应用的函子。Applicative
的(<*>)
方法可以被用作对多个参数的fmap
的泛化。- 一个
IO a
不是类型为a
的实际值,而是一个a
值的占位符,只有在程序执行时才会出现,并且承诺这个值将通过某种方式传递。这使得即使在处理 I/O 操作时,引用透明性也是可能的。 IO
是一个函子,更准确地说,它是Applicative
的实例,它提供了一种方法,即使在 I/O 操作的不确定性下,也可以修改由 I/O 操作产生的值。- 一个函子值可以被看作是由一个上下文中的值组成的。
(<$>)
操作符(即,fmap
)会穿过上下文来修改底层值。(<*>)
操作符会组合两个函子值上下文和底层值。 - 在
IO
的情况下,(<*>)
,以及密切相关的(*>)
,通过排序 I/O 操作来组合上下文。 do
块的很大一部分作用只是为(*>)
提供语法糖。
最后,请注意,do
块背后的奥秘还有很大一部分没有解释:左箭头做了什么?在这样的do
块行中...
sx <- getLine
...看起来我们正在从IO
上下文中提取getLine
产生的值。感谢关于引用透明性的讨论,我们现在知道这肯定是一种错觉。但幕后到底发生了什么?请随时下注,因为我们马上就要揭晓答案了!
说明
- ↑ 这两种情况的关键区别在于,对于
Maybe
,不确定性只是明显的,并且可以提前确定mx
背后是否有一个实际的Double
- 或者更准确地说,只要mx
的值不依赖于 I/O,就可以做到这一点!