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
的真正含义。