跳轉到內容

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. 用“正常操作”我們指的是排除由現實世界中不可控因素導致的故障,例如記憶體耗盡或狗咬斷印表機線。
華夏公益教科書