跳转到内容

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的真正含义。
华夏公益教科书