跳轉到內容

Haskell/序言:IO,一個 Applicative 函子

來自 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

簡潔而簡單。此解決方案的一種變體可能會利用 MaybeFunctor 的事實,我們可以先將值加倍,然後再在 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

這是 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 將值帶入函子的“平凡”方式。由於本書這一部分的內容很多,我們現在不會討論這些定律;然而,我們將在不久的將來回到這個重要主題。

注意

無論如何,如果您好奇,請隨時繞道至 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 aIO a都意味著,由於不同的原因,在訪問型別為a的相應值時存在一層間接性。既然如此,就不足為奇的是,像Maybe一樣,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 操作傳遞的值的方法。我們將用一個與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產生的值。感謝關於引用透明性的討論,我們現在知道這肯定是一種錯覺。但幕後到底發生了什麼?請隨時下注,因為我們馬上就要揭曉答案了!

說明

  1. 這兩種情況的關鍵區別在於,對於Maybe,不確定性只是明顯的,並且可以提前確定mx背後是否有一個實際的Double - 或者更準確地說,只要mx的值不依賴於 I/O,就可以做到這一點!
華夏公益教科書