另一个 Haskell 教程/语言进阶
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
的应用出现在lcaseString
和map 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]
curry
与uncurry
相反,它接收一个类型为(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
的定义,并尝试将Yellow
与Orange
匹配。这也失败了。然后我们尝试将Yellow
与Yellow
匹配,这成功了,所以我们使用这个函数定义,它简单地返回(255,255,0)
,如预期的那样。
假设相反,我们使用了一个自定义颜色
示例
Color> colorToRGB (Custom 50 200 100) (50,200,100)
我们应用相同的匹配过程,在从Red
到Black
的所有值上都失败了。然后我们尝试将Custom 50 200 100
与Custom r g b
匹配。我们可以看到,Custom
部分匹配,所以我们继续查看子元素是否匹配。在匹配过程中,变量r
、g
和b
本质上是通配符,因此将r
与50匹配、将g
与200匹配以及将b
与100匹配没有任何问题。作为这个匹配的“副作用”,r
获得了值50,g
获得了值200,b
获得了值100。因此,整个匹配成功,我们查看函数这一部分的定义,并使用匹配到的r
、g
和b
的值来捆绑三元组。
我们也可以写一个函数来检查一个Color
是否是一个自定义颜色
isCustomColor (Custom _ _ _) = True isCustomColor _ = False
当我们将一个值应用于isCustomColor
时,它会尝试将该值与Custom _ _ _
匹配。如果该值为Custom x y z
,则此匹配将成功,无论x
、y
和z
的值是什么。_
(下划线)字符是一个“通配符”,它会匹配任何东西,但不会执行将变量名放在那里时会发生的绑定。如果这个匹配成功,该函数返回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'
返回True
,isBright
也返回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)
进行匹配(使用模式匹配!)。此匹配成功,并且值r
、g
和b
被绑定到它们各自的值。第一个守卫检查r
是否为 255,如果是,则返回 true。第二个和第三个守卫分别将g
和b
与 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
我们可以通过以下声明来定义Color
为Eq
的实例
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
进行模式匹配。在左侧,我们将r
、g
和b
分别绑定到组件。在右侧,我们将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
最小完整定义是show
或showsPrec
(我们将在稍后讨论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
的实例(换句话说,Ord
是Eq
的子类 - 关于这部分内容,请参阅类部分)。
该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]
最小完整定义包含toEnum
和fromEnum
,它们分别将类型转换为和从Int
。pred
和succ
函数分别给出前驱和后继。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
类与 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
,我们需要知道 a
是 Eq
的实例,否则我们无法编写声明。我们将此表示为
instance Eq a => Eq (Maybe a) where Nothing == Nothing = True (Just x) == (Just x') = x == x'
第一行可以解释为“a
是 Eq
的实例 意味着 (=>
) Maybe a
是 Eq
的实例。”
编写像这样的显而易见的 Eq
、Ord
、Read
和 Show
类很乏味,应该自动化。幸运的是,它可以自动化。如果您编写一个“足够简单”的数据类型(除非您开始编写不动点类型,否则几乎任何数据类型都可以编写),编译器可以自动派生一些最基本类。为此,您只需添加一个派生子句到数据类型声明之后,例如
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
适当的时候派生这些类。
总而言之,您可以派生 Eq
、Ord
、Enum
、Bounded
、Show
和 Read
的实例。在“多态编程”或“泛型编程”领域做了大量的工作,这些工作在其他方面允许为任何类派生实例声明。这远远超出了本教程的范围;相反,我将您推荐给相关文献。
我知道到目前为止您可能已经厌倦了听到关于数据类型的内容。但是,它们非常重要,否则我不会将如此多的时间投入到它们身上。如果您有一个例如包含许多许多值的数据类型,数据类型会提供一种符号上的便利。这些被称为命名字段。
考虑一个数据类型,其目的是保存配置设置。通常,当您从这种类型中提取成员时,您实际上只关心许多设置中的一两个。此外,如果许多设置具有相同的类型,您可能会经常发现自己想知道“等等,这是第四个还是第五个元素?”您可以做的一件事是编写访问器函数。考虑以下为终端程序制作的配置类型
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)
这将变量 lh
与 Configuration
上的 localhost
字段匹配,并将变量 rh
与 Configuration
上的 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
,然后连接到 f
对 xs
进行映射的结果。
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
来获得一个新的状态,然后递归地调用 foldl
对 xs
使用这个新的状态。
还有一类函数:zip
和 unzip
函数,它们分别采用多个列表并生成一个列表,或者采用一个列表并将它们拆分。例如,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])
有一系列 zip
和 unzip
函数,名为 zip3
、unzip3
、zip4
、unzip4
等等;...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
,有一些基于数学中标准集合表示法的有用语法糖。在数学中,我们可以写类似于表示当应用于满足的元素时,的所有值的集合。这等效于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
被调用为b
,x
被调用为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 (++) []
练习 |
---|
|