跳转到内容

Haskell/理解单子/Maybe

来自维基教科书,开放世界中的开放书籍

我们使用Maybe 作为示例来介绍单子。Maybe 单子表示可能“出错”而无法返回值的计算。作为参考,以下是我们在上一章中看到的 Maybereturn(>>=) 的定义:[1]

    return :: a -> Maybe a
    return x  = Just x

    (>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b
    (>>=) m g = case m of
                   Nothing -> Nothing
                   Just x  -> g x

安全函数

[编辑 | 编辑源代码]

Maybe 数据类型提供了一种方法,可以对部分函数进行安全包装,即对于一系列参数可能无法正常工作的函数。例如,headtail 只能用于非空列表。另一个典型情况是我们在本节中将探讨的数学函数,如 sqrtlog;(就实数而言)这些函数仅针对非负参数定义。

> log 1000
6.907755278982137
> log (-1000)
''ERROR'' -- runtime error

为了避免这种情况,log 的“安全”实现可以是

safeLog :: (Floating a, Ord a) => a -> Maybe a
safeLog x
    | x > 0     = Just (log x)
    | otherwise = Nothing
> safeLog 1000
Just 6.907755278982137
> safeLog -1000
Nothing

我们可以为所有具有有限域的函数编写类似的“安全函数”,例如除法、平方根和反三角函数(safeDivsafeSqrtsafeArcSin 等,它们都具有与 safeLog 相同的类型,但定义特定于其约束条件)。

如果我们想组合这些单子函数,最干净的方法是使用单子组合(在上一章末尾简要提到了这一点)和无点风格。

safeLogSqrt = safeLog <=< safeSqrt

以这种方式编写,safeLogSqrt 与其不安全的、非单子对应部分非常相似。

unsafeLogSqrt = log . sqrt

查找表

[编辑 | 编辑源代码]

查找表将关联到。您可以通过知道键并使用查找表来查找值。例如,您可能有一个电话簿应用程序,其中查找表将联系人姓名作为键关联到相应的电话号码。在 Haskell 中实现查找表的基本方法是使用一对列表:[(a, b)]。这里 a 是键的类型,b 是值的类型。[2] 以下是电话簿查找表的外观

phonebook :: [(String, String)]
phonebook = [ ("Bob",   "01788 665242"),
              ("Fred",  "01624 556442"),
              ("Alice", "01889 985333"),
              ("Jane",  "01732 187565") ]

使用查找表最常见的事情是查找值。如果我们尝试在电话簿中查找“Bob”、“Fred”、“Alice”或“Jane”,一切正常,但如果我们尝试查找“Zoe”会怎么样?Zoe 不在我们的电话簿中,因此查找将失败。因此,从表中查找值的 Haskell 函数是一个 Maybe 计算(它可以在 Prelude 中使用)

lookup :: Eq a => a  -- a key
       -> [(a, b)]   -- the lookup table to use
       -> Maybe b    -- the result of the lookup

让我们探索一些查找结果

Prelude> lookup "Bob" phonebook
Just "01788 665242"
Prelude> lookup "Jane" phonebook
Just "01732 187565"
Prelude> lookup "Zoe" phonebook
Nothing

现在让我们扩展它,使用单子接口的全部功能。假设我们现在为政府工作,一旦我们从联系人那里获得电话号码,我们希望在大型的政府级查找表中查找此电话号码,以找出他们汽车的注册号。当然,这将是另一个 Maybe 计算。但是,如果我们要查找的人不在我们的电话簿中,我们当然无法在政府数据库中查找他们的注册号。我们需要一个函数,该函数将从第一个计算中获取结果,并仅当我们在第一个查找中获得成功的值时才将其放入第二个查找中。当然,如果我们在任何一个查找中获得 Nothing,我们的最终结果应该是 Nothing

getRegistrationNumber :: String       -- their name
                      -> Maybe String -- their registration number
getRegistrationNumber name = 
  lookup name phonebook >>=
    (\number -> lookup number governmentDatabase)

如果我们然后想在第三个查找中使用从政府数据库查找中获得的结果(假设我们想查找他们的注册号以查看他们是否欠任何汽车税),那么我们可以扩展我们的 getRegistrationNumber 函数

getTaxOwed :: String       -- their name
           -> Maybe Double -- the amount of tax they owe
getTaxOwed name = 
  lookup name phonebook >>=
    (\number -> lookup number governmentDatabase) >>=
      (\registration -> lookup registration taxDatabase)

或者,使用 do 块样式

getTaxOwed name = do
  number       <- lookup name phonebook
  registration <- lookup number governmentDatabase
  lookup registration taxDatabase

让我们在此暂停一下,思考一下如果我们在任何地方得到 Nothing 会发生什么。根据定义,当 >>= 的第一个参数是 Nothing 时,它只返回 Nothing,同时忽略它所给定的任何函数。因此,大型计算中任何阶段Nothing 都将导致总体的 Nothing,而不管其他函数如何。在第一个 Nothing 命中后,所有 >>= 将只是将它传递给彼此,跳过其他函数参数。技术描述表明 Maybe 单子的结构传播失败

提取值

[编辑 | 编辑源代码]

如果我们有 Just 值,我们可以通过模式匹配来提取它包含的底层值。

zeroAsDefault :: Maybe Int -> Int
zeroAsDefault mx = case mx of
    Nothing -> 0
    Just x -> x

用默认值替换 Nothing 的使用模式由 Data.Maybe 中的 fromMaybe 函数捕获。

zeroAsDefault :: Maybe Int -> Int
zeroAsDefault mx = fromMaybe 0 mx

maybe Prelude 函数允许我们以更通用的方式做到这一点,通过提供一个函数来修改提取的值。

displayResult :: Maybe Int -> String
displayResult mx = maybe "There was no result" (("The result was " ++) . show) mx
Prelude> :t maybe
maybe :: b -> (a -> b) -> Maybe a -> b
Prelude> displayResult (Just 10)
"The result was 10"
Prelude> displayResult Nothing
"There was no result"

能够尽可能提取底层值对 Maybe 来说是有意义的:它相当于从成功的计算中提取结果或通过提供默认值来恢复失败的计算。值得注意的是,我们刚刚看到的实际上并不涉及 Maybe 是单子的事实。return(>>=) 本身并不能让我们从单子计算中提取底层值,因此完全有可能创建一个“无出口”单子,从该单子中永远无法提取值。最明显的例子是 IO 单子。

Maybe 和安全性

[编辑 | 编辑源代码]

我们已经看到 Maybe 如何通过提供一种不涉及运行时错误的优雅方式来处理失败,从而使代码更安全。但这是否意味着我们应该始终对所有内容使用 Maybe?并非如此。

当您编写函数时,您可以判断该函数在程序的正常运行过程中是否可能无法产生结果,[3] 可能是因为您使用的函数可能失败(如本章中的示例所示),也可能是因为您知道某些参数或中间结果值没有意义(例如,想象一个只有在参数小于 10 时才有意义的计算)。如果是这种情况,请务必使用 Maybe 来表示失败;它远比返回任意默认值或抛出错误要好。

现在,在没有理由的情况下将 Maybe 添加到结果类型只会使代码更混乱,而不会更安全。具有不必要的 Maybe 的函数的类型签名会告诉代码用户该函数可能失败,而实际上它不会失败。当然,这不像相反的说法那样糟糕(即声称函数不会失败,而实际上会失败),但我们真正想要的是在所有情况下都诚实地使用代码。此外,使用 Maybe 会迫使我们传播失败(使用 fmap 或单子代码),并最终处理失败情况(使用模式匹配、maybe 函数或 Data.Maybe 中的 fromMaybe)。如果函数实际上不会失败,那么为失败编码就是不必要的复杂化。

注释

  1. Data.Maybe 中的实际实例中的定义写得略有不同,但与这些完全等效。
  2. 查看实践中 Haskell 中有关映射的章节,以获得不同的,可能更有用的实现。
  3. 用“正常操作”我们指的是排除由现实世界中不可控因素导致的故障,例如内存耗尽或狗咬断打印机线。
华夏公益教科书