Haskell/序言:IO,一個應用函子
為了更短的章節連結,無論是在書本內還是書本外,你可以使用 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 的例項都是 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 將值“簡單地”帶入函子的方式。由於本書的這一部分有很多內容,所以我們現在不會討論這些定律;但是,我們將在不久的將來重新審視這個重要主題。
注意
無論如何,如果你好奇,請隨意瀏覽 應用函子 章節並閱讀其“應用函子定律”小節。如果你選擇去那裡,你也可以看一下“ZipList”部分,它提供了一個額外的常見應用函子的示例,可以僅使用我們目前所學到的知識來理解。
為了總結,這裡是一個使用 (<*>) 增強後的 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。我們只需在addExclamation定義的右側將s替換為"Hello",然後將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",或者使用者選擇在終端輸入的任何其他內容。然而,替換getLine為任何String都會破壞程式,因為使用者將無法再在終端輸入字串。因此,getLine具有String型別將破壞引用透明性。所有其他 I/O 操作也是如此:它們的返回值是不透明的,因為不可能事先知道它們,因為它們依賴於程式外部的因素。
正如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 操作傳遞的值。我們將用一個interactiveConcatenating操作來演示它,該操作與interactiveSumming在精神上相似。第一個版本就在下面。你能預測如何用(<*>)來簡化它嗎?
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 時很重要——這種情況不勝列舉,但作為開場白,請考慮以下問題:如果我們在上面的示例中用(take 3 <$> getLine)替換第二個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塊中的一個神奇的換行符,這些換行符使操作一個接一個地執行。事實上,這就是替換換行符的全部內容:它們只是(*>)的語法糖。
早些時候,我們說過,一個 functor 為訪問其內部的值添加了一層間接性。這個觀察的另一面是,間接性是由一個上下文引起的,值是在這個上下文中找到的。對於IO來說,間接性在於值只有在程式執行時才會確定,而上下文由用於生成這些值的指令序列組成(在getLine的情況下,這些指令相當於“從終端吸取一行文字”)。從這個角度來看,(<*>)獲取兩個 functor 值,並將內部的值和上下文字身結合起來。在IO的情況下,組合上下文意味著將一個 I/O 操作的指令追加到另一個 I/O 操作的指令,從而對操作進行排序。
本章有點像旋風!讓我們回顧一下我們在本章中討論的關鍵點
Applicative是應用 functor的Functor的子類,應用 functor 是支援函式應用而不會離開 functor 的 functor。Applicative的(<*>)方法可以用作fmap對多個引數的泛化。IO a不是型別為a的具體值,而是對將在程式執行時才會實現的a值的佔位符,以及透過某種方式傳遞此值的承諾。這使得即使在處理 I/O 操作時也能保持引用透明性。IO是一個 functor,更具體地說,它是Applicative的一個例項,它提供了一種方法來修改 I/O 操作生成的值,儘管存在非確定性。- 一個 functor 值可以看作是由一個上下文中的值組成。
(<$>)運算子(即,fmap)會穿過上下文來修改底層的值。(<*>)運算子會將兩個 functor 值的上下文和底層值都結合起來。 - 在
IO的情況下,(<*>)以及密切相關的(*>)透過對 I/O 操作進行排序來組合上下文。 do塊的作用很大程度上只是為(*>)提供語法糖。
最後,請注意,do塊背後的神秘之處還有很大一部分有待解釋:左箭頭到底在做什麼?在一個do塊行中,例如...
sx <- getLine
... 它看起來像是我們正在從IO上下文中提取getLine生成的值。由於我們現在瞭解了有關引用透明性的討論,我們知道這肯定是一種錯覺。但幕後到底發生了什麼?請隨意下注,因為我們即將找到答案!
註釋
- ↑ 兩種情況之間的主要區別在於,對於
Maybe來說,非確定性只是表面現象,並且可以提前確定mx後面是否有一個實際的Double——或者更準確地說,只要mx的值不依賴於 I/O,就可以做到這一點!