跳转到内容

另一个 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元组,它们分别对应于RGB值。具体来说,如果我们看到一个Color,它的值为Red,我们想要返回(255,0,0),因为这是红色的RGB值。所以我们这样写(记住,分段函数定义只是一些case语句)

colorToRGB Red = (255,0,0)

如果我们看到一个Color,它的值为Orange,我们想要返回(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(这 admittedly an arbitrary definition,但这只是一个例子)。我们可以将这个函数定义为

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]

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


Eq

[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'都相等时才相等。继续匹配表示任何我们之前没有声明为相等的对都不相等。

Show

[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 组件,并通过串联单独显示组件的结果(在中间加空格,并在开头加上“Custom”)来创建一个字符串。

其他重要类

[edit | edit source]

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


Ord

[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子类 - 关于这部分内容,请参阅部分)。

Enum

[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,它们分别将类型转换为和从Intpredsucc函数分别给出前驱和后继。enum函数枚举元素列表。例如,enumFrom x列出所有元素(在x之后);enumFromThen x step列出所有元素(从x开始,步长为step)。To函数在给定元素处结束枚举。

Num

[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表示.


Read

[编辑 | 编辑源代码]

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 类很乏味,应该自动化。幸运的是,它可以自动化。如果您编写一个“足够简单”的数据类型(除非您开始编写不动点类型,否则几乎任何数据类型都可以编写),编译器可以自动派生一些最基本类。为此,您只需添加一个派生子句到数据类型声明之后,例如


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
...

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

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 来获得一个新的状态,然后递归地调用 foldlxs 使用这个新的状态。


还有一类函数: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类(参见实例部分)的列表,例如IntChar,有一些语法糖。如果我们想创建一个包含从的所有元素的列表,我们可以简单地写


示例

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]

这些表达式分别是enumFromToenumFromThenTo的简写形式。当然,您不需要指定上限。试试以下代码(但准备好按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)]

最后,对于mapfilter,有一些基于数学中标准集合表示法的有用语法糖。在数学中,我们可以写类似于表示当应用于满足元素时,的所有值的集合。这等效于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]

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


为了使用数组,您必须导入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`,它将在 Finitemaps 部分进行讨论,并且具有 访问和更新。



映射

[edit | edit source]

来自 `Data.Map` 模块的 `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)

通过以中缀形式编写最后一行,这应该是显而易见的

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转换为一个折叠

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

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

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

现在,问题是我们是否可以用折叠符号表示它。首先,我们可以对第一行应用 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

现在,我们可以尝试将其转换为折叠符号。首先,我们注意到基本情况将[]转换为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

应该很清楚,折叠的z元素是[],递归函数是++,得到

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