跳轉到內容

Haskell/控制結構

來自 Wikibooks,開放世界中的開放書籍

Haskell 提供了幾種表達不同值之間選擇的方式。我們在 Haskell 基礎章節中探索了一些。本節將把我們迄今為止所見的內容整合在一起,討論一些更細緻的要點,並介紹一種新的控制結構。

if和守衛的回顧

[編輯 | 編輯原始碼]

我們已經遇到過這些結構。if 表示式的語法是

if <condition> then <true-value> else <false-value>

<條件>是一個表示式,其結果為布林值。如果<條件>True那麼<真值>將被返回,否則<假值>將被返回。請注意,在 Haskell 中if是一個表示式(被轉換為一個值),而不是像許多命令式語言中的語句(被執行)。[1] 因此,else在 Haskell 中是必須的。由於if是一個表示式,它必須計算出一個結果,無論條件是真還是假,並且else確保了這一點。此外,<真值><假值>必須計算出相同型別的值,這將是整個 if 表示式的型別。

if 表示式跨越多行時,它們通常透過將 elsethen 對齊來縮排,而不是與 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表示式

[編輯 | 編輯原始碼]

if 結構是表示式的一個方便的結果是,它們可以放置在任何 Haskell 表示式可以放置的位置,允許我們編寫如下程式碼

g x y = (if x == 0 then 1 else sin x / x) * y

請注意,我們編寫了沒有換行的 if 表示式,以達到最大簡潔性。與 if 表示式不同,守衛塊不是表示式;因此,letwhere 定義是在使用它們時最接近這種風格的方法。不用說,更復雜的單行 if 表示式將難以閱讀,在這種情況下,letwhere 成為有吸引力的選擇。

短路運算子

[編輯 | 編輯原始碼]

前面提到||&& 運算子實際上是控制結構:它們首先評估第一個引數,然後僅在需要時評估第二個引數。

避免不必要的計算

[編輯 | 編輯原始碼]

例如,假設要檢查一個很大的數字 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表示式

[編輯 | 編輯原始碼]

我們還沒有討論過的一種控制結構是 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的值——即GTLTEQ中的一個,具體取決於第一個值是否大於、小於或等於第二個值。

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中,->後面的操作是必要的,因為我們正在每個分支內對操作進行順序執行。

關於return的說明

[編輯 | 編輯原始碼]

現在,我們將消除一個可能存在的混淆來源。在一個典型的命令式語言(例如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結果的LTGT。在任何一種情況下,它都沒有匹配的模式,程式將立即因異常而失敗(像往常一樣,不完整的case本身就足以引起懷疑)。

這裡的問題是return根本不等於同名C(或Java等)語句。對於我們的直接目的,我們可以說return是一個函式[4] 特別是return ()計算出一個什麼也不做的操作。return根本不影響控制流。在猜測正確的案例中,case表示式計算出return (),一個型別為IO ()的操作,執行將正常繼續。

底線是,雖然操作和do塊類似於命令式程式碼,但必須根據它們自己的術語——Haskell術語來處理。

練習
  1. 簡單輸入和輸出/控制操作中重新完成“Haskell問候”練習,這次使用case表示式。
  2. 以下程式輸出什麼?為什麼?
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"


註釋

  1. 如果您使用過C或Java,您會將Haskell的if/then/else識別為三元條件運算子?:的等價物。
  2. 要了解為什麼是這樣,請考慮我們在模式匹配部分中對匹配和繫結的討論
  3. 因此,case表示式比命令式語言中大多數表面上類似的switch/case語句要通用得多,後者通常僅限於對整數基本型別的相等性測試。
  4. 多餘的說明:更接近於正確的解釋,我們可以說return是一個函式,它接受一個值並將其轉換為一個操作,當該操作被評估時,會給出原始值。我們在處理的do塊中的return "strawberry"將具有型別IO String——與getLine相同的型別。如果現在還不理解,請不要擔心;當我們真正開始在本書的後面討論單子時,您將理解return的真正含義。
華夏公益教科書