Haskell/序言:IO,一個應用函子
對於本書內或維基百科外部指向本章的較短連結,可以使用 Haskell/應用函子序言 重定向。 |
函子的出現是本書發展中的一個分水嶺。在本章中,我們將開始揭示這些原因,為本書接下來的幾章奠定基礎。雖然我們在這裡使用的程式碼示例非常簡單,但我們將利用它們引入幾個新的重要概念,這些概念將在本書的後面章節中重新審視和進一步發展。因此,我們建議您以輕鬆的速度學習本章,這將為您提供思考每個步驟的影響以及在 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",或者使用者在終端上輸入的任何其他內容。然而,用任何String替換getLine都會導致程式崩潰,因為使用者將無法再在終端上輸入字串。因此,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型別的值時需要一層間接訪問。因此,毫不奇怪的是,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操作的指令,從而對操作進行排序。
本章內容有點像旋風!讓我們回顧一下本章討論的關鍵點。
Applicative是Functor的一個子類,用於應用函子,它們是支援函式應用而不離開函子的函子。Applicative的(<*>)方法可以作為fmap對多個引數的泛化。IO a不是一個型別為a的實際值,而是一個佔位符,表示一個只有在程式執行時才會出現的a值,以及一個承諾,表示這個值將透過某種方式傳遞過來。這使得即使在處理I/O操作時也能實現引用透明性。IO是一個函子,更具體地說,它是Applicative的一個例項,它提供了一種方法,儘管存在不確定性,但仍然可以修改由I/O操作產生的值。- 一個函子值可以被看作是由一個上下文中的值組成的。
(<$>)運算子(即fmap)透過上下文來修改底層的值。(<*>)運算子組合了兩個函子值的值和上下文。 - 在
IO的情況下,(<*>),以及與之密切相關的(*>),透過對I/O操作進行排序來組合上下文。 do塊很大一部分的作用只是為(*>)提供語法糖。
最後,請注意,do塊背後的一個主要謎團還有待解釋:左箭頭起什麼作用?在類似於...這樣的do塊行中。
sx <- getLine
...看起來我們正在從IO上下文中提取getLine產生的值。由於我們對引用透明性的討論,我們現在知道這必須是一種錯覺。但幕後到底發生了什麼?請隨意下注,因為我們即將揭曉答案!
註釋
- ↑ 這兩種情況之間的關鍵區別在於,對於
Maybe,不確定性只是表面的,並且可以提前判斷mx後面是否存在一個實際的Double——或者,更準確地說,只要mx的值不依賴於I/O,就可以這樣做!