Haskell/控制結構
Haskell 提供了幾種表達不同值之間選擇的方式。我們在 Haskell 基礎章節中探索了一些。本節將把我們迄今為止所見的內容整合在一起,討論一些更細緻的要點,並介紹一種新的控制結構。
我們已經遇到過這些結構。if 表示式的語法是
if <condition> then <true-value> else <false-value>
<條件>是一個表示式,其結果為布林值。如果<條件>是True那麼<真值>將被返回,否則<假值>將被返回。請注意,在 Haskell 中if是一個表示式(被轉換為一個值),而不是像許多命令式語言中的語句(被執行)。[1] 因此,else在 Haskell 中是必須的。由於if是一個表示式,它必須計算出一個結果,無論條件是真還是假,並且else確保了這一點。此外,<真值>和<假值>必須計算出相同型別的值,這將是整個 if 表示式的型別。
當 if 表示式跨越多行時,它們通常透過將 else 與 then 對齊來縮排,而不是與 if 對齊。一種常見的風格如下所示
describeLetter :: Char -> String
describeLetter c =
if c >= 'a' && c <= 'z'
then "Lower case"
else if c >= 'A' && c <= 'Z'
then "Upper case"
else "Not an ASCII letter"
守衛和頂層 if 表示式基本上是可以互換的。使用守衛,上面的例子稍微簡潔一些
describeLetter :: Char -> String
describeLetter c
| c >= 'a' && c <= 'z' = "Lower case"
| c >= 'A' && c <= 'Z' = "Upper case"
| otherwise = "Not an ASCII letter"
請記住,otherwise 只是 True 的別名,因此最後一個守衛是一個萬用字元,扮演了 if 表示式中最後一個 else 的角色。
守衛按其出現的順序進行評估。考慮如下設定
f (pattern1) | predicate1 = w
| predicate2 = x
f (pattern2) | predicate3 = y
| predicate4 = z
在這裡,f 的引數將與 pattern1 進行模式匹配。如果成功,則我們繼續進行第一組守衛:如果 predicate1 計算結果為 True,則返回 w。如果不是,則計算 predicate2;如果它是真值,則返回 x。同樣,如果不是,則我們繼續進行下一種情況,並嘗試將引數與 pattern2 匹配,並使用 predicate3 和 predicate4 重複守衛過程。(當然,如果模式都不匹配或模式匹配後謂詞都不為真,則會出現執行時錯誤。無論選擇哪種控制結構,都必須確保覆蓋所有情況。)
if 結構是表示式的一個方便的結果是,它們可以放置在任何 Haskell 表示式可以放置的位置,允許我們編寫如下程式碼
g x y = (if x == 0 then 1 else sin x / x) * y
請注意,我們編寫了沒有換行的 if 表示式,以達到最大簡潔性。與 if 表示式不同,守衛塊不是表示式;因此,let 或 where 定義是在使用它們時最接近這種風格的方法。不用說,更復雜的單行 if 表示式將難以閱讀,在這種情況下,let 和 where 成為有吸引力的選擇。
前面提到的 || 和 && 運算子實際上是控制結構:它們首先評估第一個引數,然後僅在需要時評估第二個引數。
例如,假設要檢查一個很大的數字 n 是否為素數,並且可以使用一個函式 isPrime,但是,評估它需要大量的計算。使用函式 \n -> n == 2 || (n `mod` 2 /= 0 && isPrime n) 將有助於減少對 n 為偶數的情況進行大量評估。
&& 可用於避免發出執行時錯誤訊號,例如除以零或索引超出範圍等。例如,以下程式碼查詢列表中最後一個非零元素
lastNonZero a = go a (length a-1)
where
go a l | l >= 0 && a !! l == 0 = go a (l-1)
| l < 0 = Nothing
| otherwise = Just (a !! l)
如果列表的所有元素都為零,則迴圈將一直執行到 l = -1,在這種情況下,第一個守衛中的條件將在不嘗試取消引用不存在的元素 -1 的情況下進行評估。
我們還沒有討論過的一種控制結構是 case 表示式。它們對於分段函式定義來說,就像 if 表示式對於守衛一樣。以這個簡單的分段定義為例
f 0 = 18
f 1 = 15
f 2 = 12
f x = 12 - x
它等價於——實際上是——case 版本的語法糖
f x =
case x of
0 -> 18
1 -> 15
2 -> 12
_ -> 12 - x
無論我們選擇哪個定義,當呼叫 f 時都會發生相同的事情:引數 x 按順序與所有模式進行匹配,並且在第一次匹配時,對應等號(在分段版本中)或箭頭(在 case 版本中)右側的表示式將被評估。請注意,在這個 case 表示式中,不需要在模式中寫入 x;萬用字元模式 _ 具有相同的效果。[2]
在使用 case 時,縮排非常重要。case 必須比包含 of 關鍵字的行開頭縮排更多,並且所有 case 必須具有相同的縮排。為了說明,這裡列出了 case 表示式的另外兩種有效佈局
f x = case x of
0 -> 18
1 -> 15
2 -> 12
_ -> 12 - x
f x = case x of 0 -> 18
1 -> 15
2 -> 12
_ -> 12 - x
由於任何 case 分支的左側只是一個模式,因此它也可以用於繫結,就像在分段函式定義中一樣:[3]
describeString :: String -> String
describeString str =
case str of
(x:xs) -> "The first character of the string is: " ++ [x] ++ "; and " ++
"there are " ++ show (length xs) ++ " more characters in it."
[] -> "This is an empty string."
此函式使用人類可讀的字串描述了str的一些屬性。在這裡,使用 case 語法將變數繫結到列表的頭和尾很方便,但您也可以使用 if 表示式來實現此目的(使用 null str 作為條件來選擇空字串情況)。
最後,就像 if 表示式(與分段定義不同)一樣,case 表示式可以嵌入到任何其他表示式適合的位置
data Colour = Black | White | RGB Int Int Int
describeBlackOrWhite :: Colour -> String
describeBlackOrWhite c =
"This colour is"
++ case c of
Black -> " black"
White -> " white"
RGB 0 0 0 -> " black"
RGB 255 255 255 -> " white"
_ -> "... uh... something else"
++ ", yeah?"
上面的 case 塊適合任何字串的位置。以這種方式編寫 describeBlackOrWhite 使 let/where 變得不必要(儘管結果定義的可讀性不如前者)。
| 練習 |
|---|
使用 case 表示式實現一個 fakeIf 函式,該函式可以用作熟悉的 if 表示式的替代品。 |
在本章的最後部分,我們將回顧“簡單輸入和輸出”章節中的討論,並介紹一些關於控制結構的額外要點。在控制操作部分,我們使用了以下函式來說明如何在使用if表示式的do塊中條件執行操作
doGuessing num = do
putStrLn "Enter your guess:"
guess <- getLine
if (read guess) < num
then do putStrLn "Too low!"
doGuessing num
else if (read guess) > num
then do putStrLn "Too high!"
doGuessing num
else putStrLn "You Win!"
我們可以使用case表示式編寫相同的doGuessing函式。為此,我們首先介紹Prelude函式compare,它接受相同型別(在Ord類中)的兩個值,並返回型別為Ordering的值——即GT、LT、EQ中的一個,具體取決於第一個值是否大於、小於或等於第二個值。
doGuessing num = do
putStrLn "Enter your guess:"
guess <- getLine
case compare (read guess) num of
LT -> do putStrLn "Too low!"
doGuessing num
GT -> do putStrLn "Too high!"
doGuessing num
EQ -> putStrLn "You Win!"
在do中,->後面的操作是必要的,因為我們正在每個分支內對操作進行順序執行。
現在,我們將消除一個可能存在的混淆來源。在一個典型的命令式語言(例如C)中,doGuessing的實現可能如下所示(如果您不瞭解C,請不要擔心細節,只需關注if-else鏈即可)
void doGuessing(int num) {
printf("Enter your guess:");
int guess = atoi(readLine());
if (guess == num) {
printf("You win!\n");
return;
}
// we won't get here if guess == num
if (guess < num) {
printf("Too low!\n");
doGuessing(num);
} else {
printf("Too high!\n");
doGuessing(num);
}
}
這個doGuessing首先測試相等情況,這不會導致doGuessing的新呼叫,並且if沒有伴隨的else。如果猜測正確,則使用return語句立即退出函式,跳過其他情況。現在,回到Haskell,do塊中的操作順序看起來很像命令式程式碼,而且實際上Prelude中確實存在一個return。然後,知道case表示式(不像if表示式)不會強制我們覆蓋所有情況,人們可能會傾向於編寫上面C程式碼的逐字翻譯(如果您好奇,可以嘗試執行它)...
doGuessing num = do
putStrLn "Enter your guess:"
guess <- getLine
case compare (read guess) num of
EQ -> do putStrLn "You win!"
return ()
-- we don't expect to get here if guess == num
if (read guess < num)
then do putStrLn "Too low!"
doGuessing num
else do putStrLn "Too high!"
doGuessing num
...但它不會工作!如果您猜對了,函式將首先列印“您贏了!”,但它不會退出return ()。相反,程式將繼續執行if表示式,並檢查guess是否小於num。當然不是,因此將執行else分支,它將列印“太高了!”,然後要求您再次猜測。對於錯誤的猜測,情況也沒有好轉:它將嘗試評估case表示式,並獲得compare結果的LT或GT。在任何一種情況下,它都沒有匹配的模式,程式將立即因異常而失敗(像往常一樣,不完整的case本身就足以引起懷疑)。
這裡的問題是return根本不等於同名C(或Java等)語句。對於我們的直接目的,我們可以說return是一個函式。[4] 特別是return ()計算出一個什麼也不做的操作。return根本不影響控制流。在猜測正確的案例中,case表示式計算出return (),一個型別為IO ()的操作,執行將正常繼續。
底線是,雖然操作和do塊類似於命令式程式碼,但必須根據它們自己的術語——Haskell術語來處理。
| 練習 |
|---|
main =
do x <- getX
putStrLn x
getX =
do return "My Shangri-La"
return "beneath"
return "the summer moon"
return "I will"
return "return"
return "again"
|
註釋
- ↑ 如果您使用過C或Java,您會將Haskell的if/then/else識別為三元條件運算子
?:的等價物。 - ↑ 要了解為什麼是這樣,請考慮我們在模式匹配部分中對匹配和繫結的討論
- ↑ 因此,
case表示式比命令式語言中大多數表面上類似的switch/case語句要通用得多,後者通常僅限於對整數基本型別的相等性測試。 - ↑ 多餘的說明:更接近於正確的解釋,我們可以說
return是一個函式,它接受一個值並將其轉換為一個操作,當該操作被評估時,會給出原始值。我們在處理的do塊中的return "strawberry"將具有型別IO String——與getLine相同的型別。如果現在還不理解,請不要擔心;當我們真正開始在本書的後面討論單子時,您將理解return的真正含義。