跳转到内容

另一个 Haskell 教程/语言进阶

来自 Wikibooks,开放世界中的开放书籍
Haskell
另一个 Haskell 教程
前言
介绍
入门
语言基础 (解答)
类型基础 (解答)
IO (解答)
模块 (解答)
高级语言 (解答)
高级类型 (解答)
单子 (解答)
高级 IO
递归
复杂度

节和中缀运算符

[编辑 | 编辑源代码]

我们已经了解了如何使用 map 将列表中元素的值翻倍

示例

Prelude> map (\x -> x*2) [1,2,3,4]
[2,4,6,8]

但是,有一种更简洁的方式来编写它

示例

Prelude> map (*2) [1,2,3,4]
[2,4,6,8]

这种操作可以针对任何中缀函数进行

示例

Prelude> map (+5) [1,2,3,4]
[6,7,8,9]
Prelude> map (/2) [1,2,3,4]
[0.5,1.0,1.5,2.0]
Prelude> map (2/) [1,2,3,4]
[2.0,1.0,0.666667,0.5]

你可能会想尝试通过将 -2 映射到列表上来从列表中的元素中减去值。但这行不通,因为虽然 +2 中的 + 被解析为标准加号运算符(因为没有歧义),但 -2 中的 - 被解释为一元减号,而不是二元减号。因此,这里的 -2数字 ,而不是函数 .

一般来说,这些被称为节。对于二元中缀运算符(如 +),我们可以通过将其括在括号中来使函数成为前缀。例如

示例

Prelude> (+) 5 3
8
Prelude> (-) 5 3
2

此外,我们可以提供其任何一个参数来创建一个节。例如

示例

Prelude> (+5) 3
8
Prelude> (/3) 6
2.0
Prelude> (3/) 6
0.5

非中缀函数可以通过将其括在反引号中("`")来变为中缀。例如

示例

Prelude> (+2) `map` [1..10]
[3,4,5,6,7,8,9,10,11,12]
Prelude> (`map` [1..10]) (+2)
[3,4,5,6,7,8,9,10,11,12]



局部声明

[编辑 | 编辑源代码]

回想一下关于 函数 的部分,有很多计算需要在函数中的多个位置使用同一个计算的结果。在那里,我们考虑了计算二次多项式根的函数

roots a b c =
    ((-b + sqrt(b*b - 4*a*c)) / (2*a),
     (-b - sqrt(b*b - 4*a*c)) / (2*a))

除了let绑定在那里介绍,我们也可以使用where子句。where 子句紧随函数定义之后,并引入了新的布局级别(参见关于 布局 的部分)。我们将其写为

roots a b c =
    ((-b + det) / (2*a), (-b - det) / (2*a))
    where det = sqrt(b*b-4*a*c)

where子句中定义的任何值都会遮蔽任何其他具有相同名称的值。例如,如果我们有以下代码块

det = "Hello World"

roots a b c =
    ((-b + det) / (2*a), (-b - det) / (2*a))
    where det = sqrt(b*b-4*a*c)

f _ = det

roots 的值不会注意到 det 的顶级声明,因为它被局部定义遮蔽了(类型不匹配也不重要)。此外,由于 f 无法“看到” roots 的内部,所以它唯一知道的关于 det 的信息是顶级可用的信息,即字符串“Hello World”。

我们也可以提取 2*a 计算并得到以下代码

roots a b c =
    ((-b + det) / (a2), (-b - det) / (a2))
    where det = sqrt(b*b-4*a*c)
          a2 = 2*a

子表达式在where子句中必须出现在函数定义之后。有时将局部定义放在函数的实际表达式之前更方便。这可以通过使用let/in子句来完成。我们已经看到了let子句;where子句与其let子句兄弟姐妹几乎完全相同,只是位置不同。同一个 roots 函数可以使用letas

roots a b c =
    let det = sqrt (b*b - 4*a*c)
        a2 = 2*a
    in  ((-b + det) / a2, (-b - det) / a2)

使用where子句,它看起来像

roots a b c = ((-b + det) / a2, (-b - det) / a2)
  where
    det = sqrt (b*b - 4*a*c)
    a2  = 2*a

这两种类型的子句可以混合使用(即,你可以编写一个既有let子句又有where子句的函数)。强烈建议不要这样做,因为它会使代码难以阅读。但是,如果你选择这样做,那么let子句中的值会遮蔽where子句中的值。因此,如果你定义了函数

f x =
    let y = x+1
    in  y
    where y = x+2

f 5 的值为 6,而不是 7。当然,我恳求你永远不要编写这样的代码。没有人应该记住这条规则,而且通过遮蔽where-定义的值在let子句中只会让你的代码难以理解。

一般来说,使用let子句还是where子句主要取决于个人喜好。通常,你为子表达式赋予的名称应该足够有表达性,这样即使不阅读它们的定义,任何阅读你代码的人也能弄清楚它们的功能。在这种情况下,where子句可能更可取,因为它们允许读者立即看到函数的功能。但是,在现实生活中,值通常被赋予了神秘的名称。



偏应用

[编辑 | 编辑源代码]

部分应用是指你将一个接收 个参数的函数,并提供它 个参数。当讨论 时,我们看到了“部分应用”的一种形式,其中诸如 + 之类的函数被部分应用。例如,在表达式 map (+1) [1,2,3] 中,节 (+1)+ 的部分应用。这是因为 + 实际上需要两个参数,但我们只提供了一个。

部分应用在函数定义中非常常见,有时被称为“η(eta)归约”。例如,假设我们要编写一个函数 lcaseString,该函数将整个字符串转换为小写。我们可以这样写:

lcaseString s = map toLower s

这里没有部分应用(尽管你可以争辩说对 toLower 应用零个参数可以被认为是部分应用)。但是,我们注意到 s 的应用出现在 lcaseStringmap toLower 的末尾。事实上,我们可以通过执行 eta 归约来删除它,得到:

lcaseString = map toLower

现在,我们对 map 进行部分应用:它期望一个函数和一个列表,但我们只提供了一个函数。

这一切都与 map 的类型有关,即 (a -> b) -> ([a] -> [b]),当包含所有括号时。在我们的例子中,toLower 的类型为 Char -> Char。因此,如果我们将此函数提供给 map,我们将得到一个类型为 [Char] -> [Char] 的函数,如预期。

现在,考虑将字符串转换为小写并删除所有非字母字符的任务。我们可以这样写:

lcaseLetters s = map toLower (filter isAlpha s)

但请注意,我们实际上可以用函数组合来写:

lcaseLetters s = (map toLower . filter isAlpha) s

同样,我们得到一个可以进行 eta 归约的函数:

lcaseLetters = map toLower . filter isAlpha

以这种方式编写函数在高级 Haskell 用户中非常常见。事实上,它有一个名字:无点编程(不要与无意义编程混淆)。之所以称为无点,是因为在 lcaseLetters 的原始定义中,我们可以将值 s 视为函数起作用的点。通过从函数定义中删除该点,我们得到了一个无点函数。

类似于 (.) 的函数是 ($)(.) 是函数组合,($) 是函数应用。($) 在 Prelude 中的定义非常简单:

f $ x = f x

但是,此函数被赋予了非常低的固定性,这意味着它可以用来替换括号。例如,我们可以编写一个函数:

foo x y = bar y (baz (fluff (ork x)))

但是,使用函数应用函数,我们可以将其改写为:

foo x y = bar y $ baz $ fluff $ ork x

这与函数组合语法非常相似。($) 函数在与其他中缀函数组合使用时也很有用。例如,我们不能写:

示例

Prelude> putStrLn "5+3=" ++ show (5+3)

因为这被解释为 (putStrLn "5+3=") ++ (show (5+3)),这是没有意义的。但是,我们可以通过改写来解决这个问题:

示例

Prelude> putStrLn $ "5+3=" ++ show (5+3)

这可以正常工作。

现在考虑从元组列表中提取所有第一个组件大于零的元组的任务。一种编写方法是:

fstGt0 l = filter (\ (a,b) -> a>0) l

我们可以首先对整个函数应用 eta 归约,得到:

fstGt0 = filter (\ (a,b) -> a>0)

现在,我们可以改写 lambda 函数以使用 fst 函数而不是模式匹配:

fstGt0 = filter (\x -> fst x > 0)

现在,我们可以使用 fst> 之间的函数组合得到:

fstGt0 = filter (\x -> ((>0) . fst) x)

最后,我们可以进行 eta 归约:

fstGt0 = filter ((>0).fst)

此定义既比原始定义更短,也更容易理解。我们可以清楚地看到它在做什么:我们通过检查某个东西是否大于零来过滤列表。我们在检查什么?fst 元素。

虽然转换为无点风格通常会导致更清晰的代码,但这当然并不总是如此。例如,将以下映射转换为无点风格会导致几乎无法理解的内容:

foo = map (\x -> sqrt (3+4*(x^2)))
foo = map (sqrt . (3+) . (4*) . (^2))

Prelude 中定义了一些组合子,它们对无点编程很有用:

  • uncurry 接受一个类型为 a -> b -> c 的函数,并将其转换为类型为 (a,b) -> c 的函数。这很有用,例如,当映射跨越一对列表时:

示例

Prelude> map (uncurry (*)) [(1,2),(3,4),(5,6)]
[2,12,30]
  • curryuncurry 的反面,接受一个类型为 (a,b) -> c 的函数,并生成一个类型为 a -> b -> c 的函数。
  • flip 反转函数前两个参数的顺序。也就是说,它接受一个类型为 a -> b -> c 的函数,并生成一个类型为 b -> a -> c 的函数。例如,我们可以使用 flip compare 按反序对列表进行排序:

示例

Prelude> List.sortBy compare [5,1,8,3]
[1,3,5,8]
Prelude> List.sortBy (flip compare) [5,1,8,3]
[8,5,3,1]

这与以下语句相同:

示例

Prelude> List.sortBy (\a b -> compare b a) [5,1,8,3]
[8,5,3,1]

只是更短。

当然,并非所有函数都可以用无点风格编写。例如:

square x = x*x

无法用无点风格编写,除非使用其他组合子。例如,如果我们可以定义其他函数,我们可以写:

pair x = (x,x)
square = uncurry (*) . pair

但在这种情况下,这不是很有用。

练习

如果可能,将以下函数转换为无点风格。

func1 x l = map (\y -> y*x) l

func2 f g l = filter f (map g l)

func3 f l = l ++ map f l

func4 l = map (\y -> y+2)
              (filter (\z -> z `elem` [1..10])
                      (5:l))

func5 f l = foldr (\x y -> f (y,x)) 0 l


模式匹配

[edit | edit source]

模式匹配是 Haskell(以及大多数函数式编程语言)最强大的功能之一。它最常与case表达式结合使用,我们在关于 函数 的部分已经见过。让我们回到关于 数据类型 的部分中的 Color 示例。我将重复我们在数据类型部分已经有的定义:

data Color
    = Red
    | Orange
    | Yellow
    | Green
    | Blue
    | Purple
    | White
    | Black
    | Custom Int Int Int  -- R G B components
    deriving (Show,Eq)

然后,我们想要编写一个函数,该函数将在 Color 类型的值与 Int 的三元组之间进行转换,这三个 Int 分别对应于 RGB 值。具体来说,如果我们看到一个 ColorRed,我们想要返回 (255,0,0),因为这是红色的 RGB 值。因此我们这样写(记住分段函数定义只是case语句):

colorToRGB Red = (255,0,0)

如果我们看到一个 ColorOrange,我们想要返回 (255,128,0);如果我们看到 Yellow,我们想要返回 (255,255,0),等等。最后,如果我们看到一个自定义颜色,它包含三个组件,我们想要用这些组件组成一个三元组,所以我们写:

colorToRGB Orange = (255,128,0)
colorToRGB Yellow = (255,255,0)
colorToRGB Green  = (0,255,0)
colorToRGB Blue   = (0,0,255)
colorToRGB Purple = (255,0,255)
colorToRGB White  = (255,255,255)
colorToRGB Black  = (0,0,0)
colorToRGB (Custom r g b) = (r,g,b)

然后,在我们的解释器中,如果我们输入:

示例

Color> colorToRGB Yellow
(255,255,0)

发生的事情是这样的:我们创建一个值,称之为 ,它的值为 Yellow。然后我们将它应用于 colorToRGB。我们检查是否可以将 Red 进行“匹配”。此匹配失败,因为根据 Eq{Color} 的定义,Red 不等于 Yellow。我们继续遍历 colorToRGB 的定义,并尝试将 YellowOrange 进行匹配。这也失败了。然后我们尝试将 YellowYellow 进行匹配,这成功了,因此我们使用此函数定义,它只是返回值 (255,255,0),如预期。

假设相反,我们使用了一个自定义颜色:

示例

Color> colorToRGB (Custom 50 200 100)
(50,200,100)

我们应用相同的匹配过程,从 RedBlack 的所有值都失败了。然后我们尝试将 Custom 50 200 100Custom r g b 进行匹配。我们可以看到 Custom 部分匹配,所以我们继续查看子元素是否匹配。在匹配过程中,变量 rgb 本质上是通配符,因此将 r 与 50、g 与 200 和 b 与 100 进行匹配没有任何问题。作为这种匹配的“副作用”,r 获得值 50,g 获得值 200,b 获得值 100。因此,整个匹配成功,我们查看此函数部分的定义,并使用 rgb 的匹配值捆绑三元组。

我们还可以编写一个函数来检查 Color 是否是自定义颜色:

isCustomColor (Custom _ _ _) = True
isCustomColor _ = False


当我们将一个值应用于 isCustomColor 时,它会尝试将该值与 Custom _ _ _ 进行匹配。如果该值为 Custom x y z(对于任何 xyz),此匹配将成功。_(下划线)字符是一个“通配符”,将匹配任何内容,但不会执行将变量名放在那里的绑定操作。如果此匹配成功,函数返回 True;但是,如果此匹配失败,它会继续执行下一行,该行将匹配任何内容,然后返回 False

出于某种原因,我们可能想要定义一个函数,该函数告诉我们给定的颜色是否“明亮”,我的“明亮”定义是指它的 RGB 组件之一等于 255(公认是一个任意的定义,但这只是一个例子)。我们可以将此函数定义为:

isBright = isBright' . colorToRGB
    where isBright' (255,_,_) = True
          isBright' (_,255,_) = True
          isBright' (_,_,255) = True
          isBright' _         = False

让我们细究一下此定义。isBright 函数是先前定义的函数 colorToRGB 和一个辅助函数 isBright' 的组合,该函数告诉我们给定的 RGB 值是否明亮。我们可以用 isBright c = isBright' (colorToRGB c) 来替换这里的第一行,但这里没有必要显式地编写参数,所以我们没有编写。同样,这种函数组合式编程需要一些时间来适应,因此在本教程中我会尽量经常使用它。


isBright' 辅助函数接受由 colorToRGB 生成的 RGB 三元组。它首先尝试将其与 (255,_,_) 进行匹配,如果该值在第一个位置具有 255,则匹配成功。如果此匹配成功,isBright' 返回 TrueisBright 也返回 True。第二行和第三行定义分别检查三元组的第二个位置和第三个位置是否为 255。第四行,即穿透,匹配其他所有内容,并将其报告为不亮。

我们可能还想编写一个函数来在 RGB 三元组和Color之间进行转换。我们只需将所有内容放在一个Custom构造函数中,但这将违背我们的目的;我们希望仅将Custom槽用于与预定义颜色不匹配的值。但是,我们不希望允许用户构建像 (600,-40,99) 这样的自定义颜色,因为这些是无效的 RGB 值。如果给出这样的值,我们可以抛出错误,但这可能难以处理。相反,我们使用Maybe数据类型。它在(Prelude 中)定义为

data Maybe a = Nothing
             | Just a

我们的使用方法如下:我们的rgbToColor函数返回一个类型为Maybe Color的值。如果传递给我们的函数的 RGB 值无效,我们将返回Nothing,这对应于失败。另一方面,如果 RGB 值有效,我们将创建相应的Color值并返回Just。执行此操作的代码如下

rgbToColor 255   0   0 = Just Red
rgbToColor 255 128   0 = Just Orange
rgbToColor 255 255   0 = Just Yellow
rgbToColor   0 255   0 = Just Green
rgbToColor   0   0 255 = Just Blue
rgbToColor 255   0 255 = Just Purple
rgbToColor 255 255 255 = Just White
rgbToColor   0   0   0 = Just Black
rgbToColor   r   g   b =
    if 0 <= r && r <= 255 &&
       0 <= g && g <= 255 &&
       0 <= b && b <= 255
      then Just (Custom r g b)
      else Nothing   -- invalid RGB value

前八行将 RGB 参数与预定义值匹配,如果匹配,则rgbToColor返回Just相应的颜色。如果这些都不匹配,则rgbToColor的最后一个定义将第一个参数与r匹配,将第二个参数与g匹配,将第三个参数与b匹配(这会导致将这些值绑定作为副作用)。然后它检查这些值是否有效(每个值都大于或等于零且小于或等于 255)。如果是,则返回Just (Custom r g b);否则,它返回Nothing,对应于无效的颜色。

利用这一点,我们可以编写一个函数来检查正确的 RGB 值是否有效

rgbIsValid r g b = rgbIsValid' (rgbToColor r g b)
    where rgbIsValid' (Just _) = True
          rgbIsValid' _        = False

在这里,我们将辅助函数rgbIsValid'与我们的函数rgbToColor组合。辅助函数检查rgbToColor返回的值是否为Just任何东西(通配符)。如果是,则返回True。否则,它匹配任何东西并返回False

模式匹配不是魔法,虽然。你只能对数据类型进行匹配;你不能对函数进行匹配。例如,以下无效

f x = x + 1

g (f x) = x

即使g的预期含义很清楚(即,g x = x - 1),编译器通常不知道f是否有逆函数,因此它无法执行这样的匹配。



守卫

[edit | edit source]

守卫可以被认为是模式匹配机制的扩展。它们允许你根据任意布尔表达式来允许分段函数定义。守卫出现在函数的所有参数之后,但在等号之前,并以竖线开头。我们可以使用守卫来编写一个简单的函数,该函数返回一个字符串,告诉你比较两个元素的结果

comparison x y | x < y = "The first is less"
               | x > y = "The second is less"
               | otherwise = "They are equal"

你可以将竖线读作“使得”。因此,我们说comparison x y的值“使得”x 小于 y 为“第一个更小”。该值使得 x 大于 y 为“第二个更小”,而该值否则为“它们相等”。关键字otherwise仅定义为等于True,因此与任何通过该处的匹配项都匹配。因此,我们可以看到它有效

示例

Guards> comparison 5 10
"The first is less"
Guards> comparison 10 5
"The second is less"
Guards> comparison 7 7
"They are equal"

守卫与模式匹配结合使用。当一个模式匹配成功时,它会连续尝试其所有守卫,直到一个守卫匹配成功。如果没有任何守卫匹配成功,则模式匹配将继续下一个模式。

守卫的一个好处是where子句对所有守卫都通用。因此,我们上一节中isBright函数的另一个可能的定义将是

isBright2 c | r == 255 = True
            | g == 255 = True
            | b == 255 = True
            | otherwise = False
    where (r,g,b) = colorToRGB c

该函数等效于先前版本,但执行其计算方式略有不同。它接受一个颜色c,并对其应用colorToRGB,生成一个 RGB 三元组,该三元组通过模式匹配匹配到(r,g,b)。此匹配成功,并将值rgb绑定到它们各自的值。第一个守卫检查r是否为 255,如果是,则返回 true。第二个和第三个守卫分别检查gb是否为 255,如果匹配,则返回 true。最后一个守卫作为最后的手段触发,并返回False



实例声明

[edit | edit source]

为了声明一个类型是某个类的实例,你需要为它提供一个实例声明。大多数类提供所谓的“最小完整定义”。这意味着为了满足类的定义,必须为该类实现的函数。在为你的类型编写了这些函数后,你可以将它声明为该类的实例。


TheEqClass

[edit | edit source]

Eq 类有两个成员(即两个函数)

(==) :: Eq a => a -> a -> Bool
(/=) :: Eq a => a -> a -> Bool

这些类型签名中的第一个表示函数==是一个函数,它接受两个a(它们是Eq的成员),并生成一个Bool/=(不相等)的类型签名是相同的。Eq 类的最小完整定义要求定义这两个函数中的一个(如果你定义了==,则/=会通过否定==的结果自动定义,反之亦然)。这些声明必须在实例声明中提供。

这可以通过示例来最好地说明。假设我们有我们的颜色示例,这里为了方便重复一遍

data Color
    = Red
    | Orange
    | Yellow
    | Green
    | Blue
    | Purple
    | White
    | Black
    | Custom Int Int Int  -- R G B components

我们可以通过以下声明定义ColorEq的实例

instance Eq Color where
    Red == Red = True
    Orange == Orange = True
    Yellow == Yellow = True
    Green == Green = True
    Blue == Blue = True
    Purple == Purple = True
    White == White = True
    Black == Black = True
    (Custom r g b) == (Custom r' g' b') =
        r == r' && g == g' && b == b'
    _ == _ = False

这里的第一行以关键字instance开始,告诉编译器我们正在进行实例声明。然后它指定了类Eq,以及将成为该类实例的类型Color。在那之后,是where关键字。最后是方法声明。

方法声明的前八行基本上是相同的。例如,第一个说表达式Red == Red的值等于True。第二行到第八行是相同的。自定义颜色的声明略有不同。我们在==的两边模式匹配Custom。在左侧,我们将rgb分别绑定到组件。在右侧,我们将r'g'b'绑定到组件。然后我们说,这两个自定义颜色只有在r == r'g == g'b == b'都相等时才相等。后续部分表示我们以前没有声明为相等的任何对都是不相等的。

TheShowClass

[edit | edit source]

Show 类用于将任意值显示为字符串。该类有三个方法

show :: Show a => a -> String
showsPrec :: Show a => Int -> a -> String -> String
showList :: Show a => [a] -> String -> String

最小完整定义是showshowsPrec(我们将在后面讨论showsPrec - 它存在是为了效率原因)。我们可以通过以下实例声明定义我们的Color数据类型为Show的实例

instance Show Color where
    show Red = "Red"
    show Orange = "Orange"
    show Yellow = "Yellow"
    show Green = "Green"
    show Blue = "Blue"
    show Purple = "Purple"
    show White = "White"
    show Black = "Black"
    show (Custom r g b) =
        "Custom " ++ show r ++ " " ++
        show g ++ " " ++ show b

此声明精确地指定了如何将类型为Color的值转换为String。同样,前八行是相同的,只是简单地接受一个Color并生成一个字符串。处理自定义颜色的最后一行匹配出 RGB 组件,并通过将各个组件show的结果连接起来(中间用空格隔开,开头用“Custom”)来创建一个字符串。

其他重要类

[edit | edit source]

还有一些其他重要的类,我将简要提及,因为它们要么被广泛使用,要么是因为我们很快就会使用它们。我不会提供示例实例声明;现在你应该清楚如何做到这一点。


TheOrdClass

[edit | edit source]

排序类,其函数为

compare :: Ord a => a -> a -> Ordering
(<=) :: Ord a => a -> a -> Bool
(>) :: Ord a => a -> a -> Bool
(>=) :: Ord a => a -> a -> Bool
(<) :: Ord a => a -> a -> Bool
min :: Ord a => a -> a -> a
max :: Ord a => a -> a -> a

几乎任何函数都可以作为最小完整定义;但是,如果你只实现了一个函数,建议你实现compare。此函数返回一个类型为Ordering的值,它定义为

data Ordering = LT | EQ | GT

因此,例如,我们得到

示例

Prelude> compare 5 7
LT
Prelude> compare 6 6
EQ
Prelude> compare 7 5
GT

为了声明一个类型为Ord的实例,你必须已经将它声明为Eq的实例(换句话说,OrdEq子类 - 有关更多信息,请参阅关于的部分)。

TheEnumClass

[edit | edit source]

Enum 类用于枚举类型;也就是说,对于每个元素都有一个后继者和一个前驱者的类型。它的方法是

pred :: Enum a => a -> a
succ :: Enum a => a -> a
toEnum :: Enum a => Int -> a
fromEnum :: Enum a => a -> Int
enumFrom :: Enum a => a -> [a]
enumFromThen :: Enum a => a -> a -> [a]
enumFromTo :: Enum a => a -> a -> [a]
enumFromThenTo :: Enum a => a -> a -> a -> [a]

最小完整定义包含toEnumfromEnum,它们用于在Int之间进行转换。predsucc函数分别给出前驱者和后继者。enum函数枚举元素列表。例如,enumFrom x列出x之后的所有元素;enumFromThen x step列出从x开始以step大小的步长列出所有元素。To函数在给定元素处结束枚举。

TheNumClass

[edit | edit source]

Num 类提供标准算术运算

(-) :: Num a => a -> a -> a
(*) :: Num a => a -> a -> a
(+) :: Num a => a -> a -> a
negate :: Num a => a -> a
signum :: Num a => a -> a
abs :: Num a => a -> a
fromInteger :: Num a => Integer -> a

除了negate,所有这些都显而易见,negate是单目减运算符。也就是说,negate x 表示.


The阅读Class

[编辑 | 编辑源代码]

Read 类与 Show 类相反。它是一种将字符串读取为任意类型的值的方法。Read 的方法是

readsPrec :: Read a => Int -> String -> [(a, String)]
readList :: String -> [([a], String)]

最小的完整定义是 readsPrec。与此相关最重要的函数是 read,它使用 readsPrec 作为

read s = fst (head (readsPrec 0 s))

如果解析字符串失败,这将失败。您可以定义一个 maybeRead 函数为

maybeRead s =
    case readsPrec 0 s of
      [(a,_)] -> Just a
      _ -> Nothing

如何在示例中进一步讨论如何直接编写和使用 readsPrec


类上下文

[编辑 | 编辑源代码]

假设我们正在从头开始定义 Maybe 数据类型。定义将类似于

data Maybe a = Nothing
             | Just a

现在,当我们去写实例声明时,比如 Eq,我们需要知道 aEq 的实例,否则我们无法编写声明。我们将其表达为

instance Eq a => Eq (Maybe a) where
    Nothing == Nothing = True
    (Just x) == (Just x') = x == x'

这第一行可以读作“aEq 的实例意味着 (=>) Maybe aEq 的实例”。

派生类

[编辑 | 编辑源代码]

编写像这样的明显的 EqOrdReadShow 类很乏味,应该自动化。幸运的是,它是。如果你编写了一个“足够简单”的数据类型(除非你开始编写不动点类型,否则你将编写的几乎所有数据类型都是如此),编译器可以自动派生一些最基本类。要做到这一点,您只需添加一个deriving子句到数据类型声明之后,如下所示


data Color
    = Red
    | ...
    | Custom Int Int Int  -- R G B components
    deriving (Eq, Ord, Show, Read)

这将自动为 Color 数据类型创建命名类的实例。类似地,声明

data Maybe a = Nothing
             | Just a
             deriving (Eq, Ord, Show, Read)

仅在 a 适当的时候派生这些类。

总而言之,您可以派生 EqOrdEnumBoundedShowRead 的实例。在“多态编程”或“泛型编程”领域做了大量工作,其中包括允许为任何类派生实例声明。这远远超出了本教程的范围;相反,我将您推荐给相关文献。



数据类型再谈

[编辑 | 编辑源代码]

我知道到目前为止,你可能已经厌倦了听到关于数据类型的消息。然而,它们非常重要,否则我不会花那么多时间在它们身上。数据类型提供了一种符号上的便利,例如,如果你有一个数据类型包含许多值。这些被称为命名字段。


命名字段

[编辑 | 编辑源代码]

考虑一个数据类型,它的目的是保存配置设置。通常,当你从这种类型中提取成员时,你实际上只关心许多设置中的一两个。此外,如果许多设置具有相同的类型,你可能会经常发现自己在想:“等等,这是第四个还是第五个元素?”你可以做的一件事是编写访问器函数。考虑以下为终端程序制作的配置类型

data Configuration =
    Configuration String          -- user name
                  String          -- local host
                  String          -- remote host
                  Bool            -- is guest?
                  Bool            -- is super user?
                  String          -- current directory
                  String          -- home directory
                  Integer         -- time connected
              deriving (Eq, Show)

然后,你可以编写访问器函数,比如(我只列出了几个)

getUserName (Configuration un _ _ _ _ _ _ _) = un
getLocalHost (Configuration _ lh _ _ _ _ _ _) = lh
getRemoteHost (Configuration _ _ rh _ _ _ _ _) = rh
getIsGuest (Configuration _ _ _ ig _ _ _ _) = ig
...

你也可以编写更新函数来更新单个元素。当然,现在如果你在配置中添加一个元素或删除一个元素,所有这些函数现在都必须接受不同的参数数量。这非常烦人,并且很容易导致错误。但是,有一个解决方案。我们只需在数据类型声明中为字段命名,如下所示

data Configuration =
    Configuration { username      :: String,
                    localhost     :: String,
                    remotehost    :: String,
                    isguest       :: Bool,
                    issuperuser   :: Bool,
                    currentdir    :: String,
                    homedir       :: String,
                    timeconnected :: Integer
                  }

这将自动为我们生成以下访问器函数

username :: Configuration -> String
localhost :: Configuration -> String
...

此外,它还为我们提供了非常方便的更新方法。以下是一个针对“工作后目录”和“更改目录”的简短示例,这些示例适用于 Configurations

changeDir :: Configuration -> String -> Configuration
changeDir cfg newDir =
    -- make sure the directory exists
    if directoryExists newDir
      then -- change our current directory
           cfg{currentdir = newDir}
      else error "directory does not exist"

postWorkingDir :: Configuration -> String
  -- retrieve our current directory
postWorkingDir cfg = currentdir cfg

因此,一般来说,要将数据类型 y 中的字段 x 更新为 z,你编写 y{x=z}。您可以更改多个字段;每个字段都应该用逗号隔开,例如,y{x=z, a=b, c=d}

当然,您可以像以前一样继续对 Configuration 进行模式匹配。命名字段只是语法糖;您仍然可以编写类似于以下内容的内容

getUserName (Configuration un _ _ _ _ _ _ _) = un

但几乎没有理由这样做。最后,您可以对命名字段进行模式匹配,如下所示

getHostData (Configuration {localhost=lh,remotehost=rh})
  = (lh,rh)

这将变量 lhConfiguration 上的 localhost 字段匹配,并将变量 rhConfiguration 上的 remotehost 字段匹配。当然,这些匹配会成功。您也可以通过在这些位置放置值而不是变量名来限制匹配,就像您对标准数据类型一样。

您可以使用以下第一个定义中所示的旧方法创建 Configuration 的值,或者使用命名字段的类型创建 Configuration 的值,如下面的第二个定义所示

initCFG =
    Configuration "nobody" "nowhere" "nowhere"
                  False False "/" "/" 0
initCFG' =
    Configuration
       { username="nobody",
         localhost="nowhere",
         remotehost="nowhere",
         isguest=False,
         issuperuser=False,
         currentdir="/",
         homedir="/",
         timeconnected=0 }

尽管第二个可能更容易理解,除非你在代码中散布了大量注释。




更多列表

[编辑 | 编辑源代码]

待办事项:在此处添加内容

标准列表函数

[编辑 | 编辑源代码]

回想一下,内置 Haskell 列表数据类型的定义等效于

data List a = Nil
            | Cons a (List a)

除了 Nil 被称为 []Cons x xs 被称为 x:xs 之外。这只是为了使模式匹配更容易,代码更小。让我们研究一下如何编写一些标准列表函数。考虑 map。以下给出了一个定义

map _ [] = []
map f (x:xs) = f x : map f xs

这里,第一行表示当你对一个空列表进行 map 时,无论函数是什么,你都会得到一个空列表。第二行表示当你对一个以 x 为头,xs 为尾的列表进行 map 时,结果是将 f 应用于 x 并连接到 fxs 上进行映射的结果。


filter 可以类似地定义

filter _ [] = []
filter p (x:xs) | p x = x : filter p xs
                | otherwise = filter p xs

这应该很清楚。对于空列表,我们返回一个空列表。对于非空列表,我们返回尾部的过滤器,可能在前面加上头部,具体取决于它是否满足谓词 p


我们可以定义 foldr

foldr _ z [] = z
foldr f z (x:xs) = f x (foldr f z xs)

这里,最好的解释是我们用一个特定值替换空列表 ([]),用某个函数替换列表构造器 (:)。在第一行,我们可以看到用 z 替换 []。使用反引号使 f 成为中缀运算符,我们可以将第二行写成

foldr f z (x:xs) = x `f` (foldr f z xs)

由此,我们可以直接看到 : 如何被 f 替换。


最后,foldl

foldl _ z [] =  z
foldl f z (x:xs) = foldl f (f z x) xs

这稍微复杂一些。记住,z 可以被认为是当前状态。因此,如果我们折叠一个空列表,我们只需返回当前状态。另一方面,如果列表不为空,它将是 x:xs 的形式。在这种情况下,我们通过将 f 应用于当前状态 z 和当前列表元素 x 来获得一个新状态,然后对 xs 使用这个新状态递归调用 foldl


还有一类函数:zipunzip 函数,它们分别接受多个列表并创建一个列表,或者接受一个列表并将它们拆分。例如,zip 执行以下操作

示例

Prelude> zip "hello" [1,2,3,4,5]
[('h',1),('e',2),('l',3),('l',4),('o',5)]

基本上,它将两个列表的第一个元素配对,并将它们作为新列表的第一个元素。然后它将两个列表的第二个元素配对,并将它们作为第二个元素,等等。如果列表长度不相等会怎样?它会在较短的列表结束时停止。zip 的一个合理的定义是

zip [] _ = []
zip _ [] = []
zip (x:xs) (y:ys) = (x,y) : zip xs ys

unzip 函数则相反。它接受一个压缩列表并返回两个“原始”列表

示例

Prelude> unzip [('f',1),('o',2),('o',3)]
("foo",[1,2,3])

有一系列 zipunzip 函数,名为 zip3unzip3zip4unzip4 等等;...3 函数使用三元组而不是二元组;...4 函数使用四元组,等等。


最后,函数 `take` 获取一个整数 和一个列表,并返回列表中的前 个元素。相应地,`drop` 获取一个整数 和一个列表,并返回丢弃列表中前 个元素后的结果。这两个函数都不会产生错误;如果 太大,它们都会返回较短的列表。


列表推导式

[edit | edit source]

对于处理元素属于 `Enum` 类(参见 实例 部分)的列表,比如 `Int` 或 `Char`,有一些语法糖。如果我们想要创建一个包含从 的所有元素的列表,我们可以简单地写


示例

Prelude> [1..10]
[1,2,3,4,5,6,7,8,9,10]

我们也可以引入一个步长

示例

Prelude> [1,3..10]
[1,3,5,7,9]
Prelude> [1,4..10]
[1,4,7,10]

这些表达式分别是 `enumFromTo` 和 `enumFromThenTo` 的简写形式。当然,您不需要指定上限。尝试以下操作(但准备好按下 Control+C 停止计算!)

示例

Prelude> [1..]
[1,2,3,4,5,6,7,8,9,10,11,12{Interrupted!}

您可能打印了比这多出几千个元素。正如我们之前所说,Haskell 是惰性的。这意味着从 1 开始的所有数字的列表都是完全有效的,这正是这个列表。当然,如果您尝试打印列表(我们通过在解释器中输入它来隐式地执行此操作),它将不会停止。但是,如果我们只评估这个列表的初始段,我们就可以了

示例

Prelude> take 3 [1..]
[1,2,3]
Prelude> take 3 (drop 5 [1..])
[6,7,8]

这很有用,比如,如果我们想为列表中的每个元素分配一个 ID。如果没有惰性,我们将不得不写类似这样的代码

assignID :: [a] -> [(a,Int)]
assignID l = zip l [1..length l]

这意味着列表将被遍历两次。但是,由于惰性,我们可以简单地写

assignID l = zip l [1..]

我们就会得到我们想要的结果。我们可以看到这是有效的

示例

Prelude> assignID "hello"
[('h',1),('e',2),('l',3),('l',4),('o',5)]

最后,`map` 和 `filter` 有一些有用的语法糖,基于数学中标准的集合符号。在数学中,我们将写类似 来表示当应用于 `s` 的元素时 `f` 的所有值的集合,这些元素满足 `p`。这等效于 Haskell 语句 `map f (filter p s)`。但是,我们也可以使用更像数学的符号并写 `[f x | x <- s, p x]`。虽然在数学中,管道右侧语句的顺序是自由的,但在 Haskell 中并非如此。我们不能将 `p x` 放在 `x <- s` 之前,否则编译器将不知道 `x` 是什么。我们可以用它来进行简单的字符串处理。假设我们想要取一个字符串,只保留大写字母,并将这些大写字母转换为小写。我们可以通过以下两种等效的方式中的任何一种来完成此操作

示例

Prelude> map toLower (filter isUpper "Hello World")
"hw"
Prelude> [toLower x | x <- "Hello World", isUpper x]
"hw"

这两个是等效的,并且,根据您使用的确切函数,其中一个可能比另一个更具可读性。但是,我们还可以做更多的事情。假设您想要创建一个包含每对点的列表,这些点位于 (0,0) 和 (5,7) 之间,且低于对角线。使用列表和映射手动执行此操作会很麻烦,并且可能难以阅读。使用列表推导式再简单不过了

示例

Prelude> [(x,y) | x <- [1..5], y <- [x..7]]
[(1,1),(1,2),(1,3),(1,4),(1,5),(1,6),(1,7),(2,2),(2,3),
(2,4),(2,5),(2,6),(2,7),(3,3),(3,4),(3,5),(3,6),(3,7),
(4,4),(4,5),(4,6),(4,7),(5,5),(5,6),(5,7)]

如果您颠倒了 `x <-` 和 `y <-` 子句的顺序,遍历空间的顺序将被颠倒(当然,在这种情况下,`y` 将不再依赖于 `x`,您将需要使 `x` 依赖于 `y`,但这很简单)。




数组

[edit | edit source]

列表在很多方面都很好用。很容易在它们开头添加元素,并以各种改变列表长度的方式操作它们。但是,它们不适合随机访问,它们的平均复杂度为 以访问任意元素(如果您不知道 的含义,您可以忽略它或快速绕道阅读附录章节 Complexity,这是一个介绍复杂性理论的两页内容)。因此,如果您愿意放弃快速插入和删除,因为您需要随机访问,那么您应该使用数组而不是列表。


为了使用数组,您必须导入 `Array` 模块。创建数组有几种方法,`array` 函数、`listArray` 函数和 `accumArray` 函数。`array` 函数获取一个表示数组边界的对,以及一个表示数组初始值的关联列表。`listArray` 函数获取边界,然后只获取一个值的列表。最后,`accumArray` 函数获取一个累加函数、一个初始值和一个关联列表,并将列表中的对累加到数组中。以下是一些创建数组的示例

示例

Prelude> :m Array
Prelude Array> array (1,5) [(i,2*i) | i <- [1..5]]
array (1,5) [(1,2),(2,4),(3,6),(4,8),(5,10)]
Prelude Array> listArray (1,5) [3,7,5,1,10]
array (1,5) [(1,3),(2,7),(3,5),(4,1),(5,10)]
Prelude Array> accumArray (+) 2 (1,5) [(i,i) | i <- [1..5]]
array (1,5) [(1,3),(2,4),(3,5),(4,6),(5,7)]

当数组被打印出来(通过 show 函数)时,它们会以关联列表的形式打印。例如,在第一个例子中,关联列表显示数组在 处的值为 ,数组在 处的值为 ,等等。


您可以使用 `!` 函数提取数组中的元素,该函数接受一个数组和一个索引,例如

示例

Prelude Array> (listArray (1,5) [3,7,5,1,10]) ! 3
5

此外,您可以使用 `//` 函数更新数组中的元素。该函数接受一个数组和一个关联列表,并更新列表中指定的数组位置

示例

Prelude Array> (listArray (1,5) [3,7,5,1,10]) // [(2,99),(3,-99)]
array (1,5) [(1,3),(2,99),(3,-99),(4,1),(5,10)]

还有一些其他有趣的函数


bounds 返回数组的边界
indices 返回数组所有索引的列表
elems 按顺序返回数组中所有值的列表
assocs 返回数组的关联列表

如果我们将 `arr` 定义为 `listArray (1,5) [3,7,5,1,10]`,则这些函数应用于 `arr` 的结果是

示例

Prelude Array> bounds arr
(1,5)
Prelude Array> indices arr
[1,2,3,4,5]
Prelude Array> elems arr
[3,7,5,1,10]
Prelude Array> assocs arr
[(1,3),(2,7),(3,5),(4,1),(5,10)]

请注意,虽然数组的访问时间是 ,但它们并非 更新。实际上,它们是 更新,因为为了保持纯度,必须 *复制* 数组才能进行更新。因此,函数式数组在您只填充一次并随后只进行读取时才真正有用。如果您需要快速访问和更新,您可能应该使用 `FiniteMap`,它们将在有关 有限映射 的部分中讨论,并且具有 的访问和更新速度。



映射

[edit | edit source]

来自 `Data.Map` 模块的 `Map` 数据类型是平衡树的纯函数式实现。映射在执行固定大小为 的这些数据类型上的各种操作所需的时间方面,可以与列表和数组进行比较。简要比较如下所示:

List Array Map
insert
update
delete
find
map

我们可以看到,列表提供了快速插入(但其他操作都很慢),数组提供了快速查找(但其他操作都很慢),而映射则提供了一般速度的操作。

映射的类型是 `Map k a`,其中 `k` 是键的类型,`a` 是元素的类型。也就是说,映射是从类型 `k` 到类型 `a` 的查找表。

基本映射函数如下所示:

empty  :: Map k a
insert :: k -> a -> Map k a -> Map k a
delete :: k -> Map k a -> Map k a
member :: k -> Map k a -> Bool
lookup :: k -> Map k a -> a

在所有这些情况下,类型 `k` 必须是 `Ord` 的实例(因此也是 `Eq` 的实例)。

还存在 `fromList` 和 `toList` 函数,用于将列表转换为映射和从映射转换为列表。尝试以下操作:

示例

Prelude> :m Data.Map
Prelude Data.Map> let mymap = fromList [('a',5),('b',10),('c',1),('d',2)]
Prelude Data.Map> let othermap = insert 'e' 6 mymap
Prelude Data.Map> toList mymap
[('a',5),('b',10),('c',1),('d',2)]
Prelude Data.Map> toList othermap
[('a',5),('b',10),('c',1),('d',2),('e',6)]
Prelude Data.Map> Data.Map.lookup 'e' othermap
6
Prelude Data.Map> Data.Map.lookup 'e' mymap
*** Exception: user error (Data.Map.lookup: Key not found)








布局

[edit | edit source]

关于列表的最后的话

[edit | edit source]

您可能已经厌倦了听到有关列表的信息,但它们对于 Haskell(以及实际上所有函数式编程)是如此基础,如果我们不再谈论它们,那将是非常糟糕的。

事实证明,foldr 实际上是一个非常强大的函数:它可以计算原始递归函数。原始递归函数本质上是只能使用“for”循环,而不能使用“while”循环计算的函数。

事实上,我们可以相当容易地用foldr定义map

map2 f = foldr (\a b -> f a : b) []

这里,b 是累加器(即结果列表),a 是当前正在考虑的元素。事实上,我们可以通过一系列步骤简化这个定义

     foldr (\a b -> f a : b) []
==>  foldr (\a b -> (:) (f a) b) []
==>  foldr (\a -> (:) (f a)) []
==>  foldr (\a -> ((:) . f) a) []
==>  foldr ((:) . f) []

这与foldr (:) [] 是列表上的恒等函数直接相关。这是因为,正如之前提到的,foldr f z 可以被认为是将列表中的[] 替换为z,将: 替换为f。在这种情况下,我们保持两者不变,所以它是恒等函数。

事实上,你可以将任何以下风格的函数转换为foldr

myfunc [] = z
myfunc (x:xs) = f x (myfunc xs)

通过将最后一行用中缀形式的f 写出来,这应该是显而易见的

myfunc [] = z
myfunc (x:xs) = x `f` (myfunc xs)

很明显,我们只是将[] 替换为z,将: 替换为f。考虑filter 函数

filter p [] = []
filter p (x:xs) =
  if p x
    then x : filter p xs
    else filter p xs

这个函数也遵循上面的形式。根据第一行,我们可以得出z 应该为[],就像map 的情况一样。现在,假设我们将调用filter p xs 的结果简称为b,那么我们可以将其改写为

filter p [] = []
filter p (x:xs) =
  if p x then x : b else b

鉴于此,我们可以将filter 转换为一个 fold

filter p = foldr (\a b -> if p a then a:b else b) []

让我们考虑一个稍微复杂一点的函数:++++ 的定义是

(++) []     ys = ys
(++) (x:xs) ys = x : (xs ++ ys)

现在,问题是我们是否可以用 fold 表示法来写它。首先,我们可以对第一行应用 eta 归约,得到

(++) [] = id

通过一系列步骤,我们也可以对第二行进行 eta 归约

     (++) (x:xs) ys = x : ((++) xs ys)
==>  (++) (x:xs) ys = (x:) ((++) xs ys)
==>  (++) (x:xs) ys = ((x:) . (++) xs) ys
==>  (++) (x:xs) = (x:) . (++) xs

因此,我们得到++ 的 eta 归约定义为

(++) []     = id
(++) (x:xs) = (x:) . (++) xs

现在,我们可以尝试将它放入 fold 表示法中。首先,我们注意到基本情况将[] 转换为id。现在,如果我们假设(++) xs 被称为bx 被称为a,我们可以得到以下关于foldr 的定义

(++) = foldr (\a b -> (a:) . b) id

这在直觉上是有意义的。如果我们只考虑将++ 应用于一个参数,我们可以将其视为一个函数,它接受一个列表并创建一个函数,该函数在应用时会将该列表附加到另一个列表的前面。在 lambda 函数中,我们假设我们有一个函数b 会对列表的其余部分执行此操作,我们需要创建一个函数,它会对b 以及单个元素a 执行此操作。为了做到这一点,我们首先应用b,然后将a 添加到前面。

我们可以通过以下步骤进一步将此表达式简化为无点风格

==>  (++) = foldr (\a b -> (a:) . b) id
==>  (++) = foldr (\a b -> (.) (a:) b) id
==>  (++) = foldr (\a -> (.) (a:)) id
==>  (++) = foldr (\a -> (.) ((:) a)) id
==>  (++) = foldr (\a -> ((.) . (:)) a) id
==>  (++) = foldr ((.) . (:)) id

这个最终版本是无点的,虽然不一定容易理解。大概是原始版本更清晰。

作为最后的例子,考虑concat。我们可以将其写成

concat []     = []
concat (x:xs) = x ++ concat xs

应该立即清楚,fold 的z 元素是[],递归函数是++,得到

concat = foldr (++) []
练习
  1. 函数and 接受一个布尔值列表,当且仅当所有布尔值为True 时返回True。它也返回空列表上的True。用foldr 表示此函数。
  2. 函数concatMap 的行为使得concatMap f 等同于concat . map f。用foldr 表示此函数。
华夏公益教科书