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,就可以做到這一點!