Skip to content
This repository has been archived by the owner on Sep 7, 2023. It is now read-only.

Latest commit

 

History

History
1145 lines (965 loc) · 39.3 KB

02-lists.md

File metadata and controls

1145 lines (965 loc) · 39.3 KB

多态和函数式编程范式

CIS 194 Week 2
28 January, 2015

其他语法

第一节中没有介绍本地变量。现在,我们来看几个本地变量的例子,会对你们的作业有所帮助。

let 表达式

let 来定义表达式范围内的本地变量:

strLength :: String -> Int
strLength []     = 0
strLength (_:xs) = let len_rest = strLength xs in
                   len_rest + 1

这个例子中的本地变量有点多余(较好的写法是 1 + strLength xs),但是足够演示 let 的用法,不要忘了变量定义之后的 in

where 从句

where 来定义多个分支范围内的本地变量:

frob :: String -> Char
frob []  = 'a'   -- len is NOT in scope here
frob str
  | len > 5   = 'x'
  | len < 3   = 'y'
  | otherwise = 'z'
  where
    len = strLength str

注意变量 len 可以在 where 上方的各个选择分支内使用,但是不能在另一个顶层定义 frob [] = 'a' 中使用。比起 let,Haskell 中更常使用 where, 因为它可以让程序员第一次看到函数时,首先关注函数本身做了什么,而不是先看到一堆本地变量的定义。

Haskell 的布局

Haskell 是对空白字符敏感的语言,这和其他大多数仅把空白字符用来分割标示符的语言形成鲜明对比。 (这点 Haskell 和 Python 相似。)Haskell 中缩进层次指示了代码块结束和新语句开始的位置。 基本上,当一个所谓的布局暗示出现的时候,GHC 会看下一个位置并记住其缩进层次。 如果下一行缩进相同则被认为是同一组代码,如果下一行缩进更少则不认为是同一组。

布局暗示包括 whereletdoofcase。 Haskell 模块总是以 module Name where 开头,表明布局规则对整个文件里的定义都起效。 所以下面这样的定义有误:

x :: Int
x =
5

问题出在 5 和其他顶层定义缩进相同(零缩进),所以 GHC 认为这是一个新的定义而不是 x 定义的一部分。

布局规则常常给 Haskell 新手带来困扰。如果你在以后遇到麻烦,回来重新看这里(或者其他网络资料)—— 通常,多仔细想一想你就明白是怎么一回事了。

在计算缩进层次的时候,代码中的制表符(你没写对吧?!?)被认为是 8 个字符的长度, 而不一定是编辑器显示给你的长度。这种潜在的混乱使得制表符在 Haskell 缩进中被认为是很不好的用法。

累加器

Haskell 中重复给定计算的方法之一是递归。递归也是解决很多问题的最自然的解法。 但是有时候问题的结构并不完全符合 Haskell 的结构。比如我们有一组数字的列表 [Int]。 我们想累加列表中数字的和,但是超过 20 就停止,忽略之后的数字。 因为列表上的递归是从后向前计算结果的,所以通常的递归并不符合我们的要求。 随着列表的深入,我们需要即时跟踪当前的和,这个和就叫做累加器

这是解决上面问题的代码:

sumTo20 :: [Int] -> Int
sumTo20 nums = go 0 nums   -- the acc. starts at 0
  where go :: Int -> [Int] -> Int
        go acc [] = acc   -- empty list: return the accumulated sum
        go acc (x:xs)
         | acc >= 20 = acc
         | otherwise = go (acc + x) xs

sumTo20 [4,9,10,2,8] == 23

参数多态

多态函数的一个重要的特点是调用者决定其类型。 当你写一个多态函数时,它应该对所有可能的输入类型都工作。 这连同 Haskell 无法只根据某表达式的类型直接做决定的事实,产生了一些有趣的意味(?)。

对初学者来说,下面的代码很:

bogus :: [a] -> Bool
bogus ('X':_) = True
bogus _       = False

是因为 bogus 的定义实际上假定了输入是 [Char]。 这个函数并不能对任意类型的 a 都奏效。 相反,下面的代码则没有问题:

notEmpty :: [a] -> Bool
notEmpty (_:_) = True
notEmpty []    = False

notEmpty 函数并不关心 a 是什么就能工作。

这种“不关心”就是参数多态中“参数化(parametric)”的含义。 所有的 Haskell 函数实现对于它的类型都必须是参数化的;函数不能介意或者基于这些类型参数做选择。 函数不能在 aInt 时做一种事,而在 aBool 时做另一种事。 Haskell 不允许这样。这种语言特性叫做参态(parametricity)

参态有一些很深刻的意义。其中之一叫做类型擦除。 因为 Haskell 程序运行的时候不能再根据类型信息做决定,所以所有的类型信息在编译期间就可以被丢弃掉了。 不管类型对于 Haskell 代码来说多么重要,他们和运行中的程序都没有关系。 这个特性给 Haskell 带来了相比其他需要在运行时保留类型信息的语言(比如 Python)巨大的速度提升。 (类型擦除并不是唯一让 Haskell 比其他语言更快的方法,但 Haskell 有时候能比 Python 快 20 倍。)

参态的另一个影响是它限制了多态函数的实现。例如下面的类型签名:

strange :: a -> b

strange 函数获得一个类型是 a 的东西返回另一个类型是 b 的东西。 但是关键是,它不能得知 ab 具体是什么。所以根本没有办法写出这样的 strange

strange = error "impossible!"

Prelude 中定义的 error 函数可以终止整个程序并显示指定信息。)

再来看看

limited :: a -> a

这个函数对于给定的 a 返回 a。只有唯一的 a 它能产生,就是它获得的那个! 所以 limited 只可能有一种实现:

limited x = x

总的来说,给定一个函数的类型,只根据参态就可以推断出该函数的很多特性。 函数可以有多种产生输出值的方式,但是给定输出类型的值应从何而来? 通过试图回答这个问题,可以获得对函数的一些了解。

Total 函数和 partial 函数

考虑下面的多态类型:

[a] -> a

什么样的函数有这样的类型?这个类型说明了给定一个 a 的列表,函数必须产生一个类型为 a 的值。 例如,Prelude 里的函数 head 即是此类型。

[a] -> a

糟糕!好像除此之外没有别的选择了,因为这个函数必须能对所有类型工作。 没有办法凭空造出来一个任意类型的值。

head 就是俗称的partial 函数:对于某些输入不能工作。 函数在某些输入上会无限递归也称做 partial。 函数在所有可能的输入上都有良好的定义叫做total 函数

好的 Haskell 实践当然是尽量避免 partial 函数。 这也是任何编程语言中的良好实践 —— 但是在决大多数语言中非常麻烦。 而在 Haskell 中往往简单又合理。

**head 是个错误!**它不应该存在 Prelude 中。 其他你应该尽力避免的 Prelude 中的 partial 函数包括 tailinitlast(!!)。 从现在开始在作业中使用其中任意一个都会丢掉样式分!

那应该怎么做?

替换 partial 函数

headtail 等等 partial 函数常常可以被模式匹配取代。比如下面两个定义:

doStuff1 :: [Int] -> Int
doStuff1 []  = 0
doStuff1 [_] = 0
doStuff1 xs  = head xs + (head (tail xs))

doStuff2 :: [Int] -> Int
doStuff2 []        = 0
doStuff2 [_]       = 0
doStuff2 (x1:x2:_) = x1 + x2

这两个函数做的同样的事,而且也都是 total 的。但是只有第二个显然是 total 的,而且更易读。

递归模式

对于 [a] 我们会做什么?有几种可能:

  • 对列表中的每一个元素做某种操作

  • 基于某种测试,只保留一些元素,丢掉其他的

  • 对所有元素做“总结”(比如求和、求积、求最大值)

  • 你能想到的其他的!

映射(Map)

让我们来考虑第一种情况(对列表中的每一个元素做某种操作)。 比如我们可以对列表中的每一个元素加一:

addOneToAll :: [Int] -> [Int]
addOneToAll []     = []
addOneToAll (x:xs) = x + 1 : addOneToAll xs

或者对每一个取绝对值得到一个非负的列表:

absAll :: [Int] -> [Int]
absAll []     = []
absAll (x:xs) = abs x : absAll xs

或者对每一个平方:

squareAll :: [Int] -> [Int]
squareAll []     = []
squareAll (x:xs) = x^2 : squareAll xs

现在,你脑中应该有红灯作闪警报乱响了。 这几个函数这么相像,应该有什么办法把这种共性抽象出来,省去我们的重复工作。

确实有这么一种办法。你有没有看出来,这三个例子里哪些部分是相同的,哪些部分变化了?

变化的,当然是我们对每个元素做的操作。我们可以把这个操作特化为一个类型为 a -> a 的函数。 现在我们看到了把函数当做函数的输入的大用途!

map :: (a -> b) -> [a] -> [b]
map _ []     = []
map f (x:xs) = f x : map f xs

现在我们可以用 map 来实现 addOneToAllabsAllsquareAll:

exampleList = [-1, 2, 6]

map (+1) exampleList
map abs  exampleList
map (^2) exampleList

过滤(Filter)

另一种常见的模式是,我们只想根据某种测试保留列表中的部分元素,扔掉其他的。 比如我们只想保留所有的正数:

keepOnlyPositive :: [Int] -> [Int]
keepOnlyPositive [] = []
keepOnlyPositive (x:xs) 
  | x > 0     = x : keepOnlyPositive xs
  | otherwise = keepOnlyPositive xs

或者只保留所有的偶数:

keepOnlyEven :: [Int] -> [Int]
keepOnlyEven [] = []
keepOnlyEven (x:xs)
  | even x    = x : keepOnlyEven xs
  | otherwise = keepOnlyEven xs

如何抽象这种模式?哪一部分是不变的,需要抽象出来?

需要抽象出来的应是用来决定哪些值该保留下来的测试(或者说断言)这部分。 断言是一个类型为 a -> Bool 的函数,对应当保留的值返回 True,应当忽略的值返回 False

filter :: (a -> Bool) -> [a] -> [a]
filte _ [] = []
filter p (x:xs)
  | p x       = x : filter p xs
  | otherwise = filter p xs

折叠(Fold)

还有一个列表上的递归模式我们还没有说到:折叠。下面是几个模式相似的列表上的函数: 所有函数都试图“结合”列表中的所有元素来得到一个值。

sum' :: [Int] -> Int
sum' []     = 0
sum' (x:xs) = x + sum' xs

product' :: [Int] -> Int
product' [] = 1
product' (x:xs) = x * product' xs

length' :: [a] -> Int
length' []     = 0
length' (_:xs) = 1 + length' xs

这几个函数有什么共同点又有什么不同?像刚才一样,我们想用高阶函数来把变化的部分抽象出来。

fold :: (a -> b -> b) -> b  -> [a] -> b
fold f z []     = z
fold f z (x:xs) = f x (fold f z xs)

注意到 fold 中把 [] 替换为 z,把 (:) 替换为 f,即

fold f z [a,b,c] == a `f` (b `f` (c `f` z))

从这个角度来看,你可能就知道如何推广 fold 到除了列表之外的其他数据结构上了。

下面,我们来用 fold 重写一下 sum'product'length'

sum''     = fold (+) 0
product'' = fold (*) 1
length''  = fold addOne 0
 where addOne _ s = 1 + s

当然 fold 已经在 Prelude 中了,叫做 foldr。 下面是一些 Prelude 中用 foldr 定义了的函数:

  • length :: [a] -> Int
  • sum :: Num a => [a] -> a
  • product :: Num a => [a] -> a
  • and :: [Bool] -> Bool
  • or :: [Bool] -> Bool
  • any :: (a -> Bool) -> [a] -> Bool
  • all :: (a -> Bool) -> [a] -> Bool

Prelude 还有一个 foldl, 表示 “从左向右” 折叠,即

foldr f z [a,b,c] == a `f` (b `f` (c `f` z))
foldl f z [a,b,c] == ((z `f` a) `f` b) `f` c

但一般情况下,你应该使用 Data.List 中的 foldl' 代替 foldl。它们做的事情是一样的,但前者更高效。

函数式编程

我们已经见过不少函数式编程的例子了。 为了更加适应这种编程的方式,我们现在来看看几个非常具有函数式风格的函数。

函数组合子

首先,我们还需要几个组合子:

(.) :: (b -> c) -> (a -> b) -> a -> c
(.) f g x = f (g x)

Prelude 中的 (.) 操作符即是函数的组合。 比如我们想要把列表中的每个元素加一然后乘以四,可以这样做:

add1Mul4 :: [Int] -> [Int]
add1Mul4 x = map ((*4) . (+1)) x

同时,我们也来看看 ($) 操作符,它的定义很简单:

($) :: (a -> b) -> a -> b
f $ x = f x

为什么需要这个?因为 ($) 是一个操作符,可以用来避免写很多括号。 例如,我们想得到列表中的偶数的个数的负数,可以这样写

negateNumEvens1 :: [Int] -> Int
negateNumEvens1 x = negate (length (filter even x))

或者这样

negateNumEvens2 :: [Int] -> Int
negateNumEvens2 x = negate $ length $ filter even x

少写很多括号呢!

Lambda

有时候我们需要创建匿名的函数,即lambda 表达式。用例子来说明,比如我们想重复列表中的所有字符串:

duplicate1 :: [String] -> [String]
duplicate1 = map dup
  where dup x = x ++ x

单独给 dup 起个名字有点多余。其实,我们可以写成匿名函数:

duplicate2 :: [String] -> [String]
duplicate2 = map (\x -> x ++ x)

反斜线后面的变量名被绑定在了 -> 后面的表达式里。不过,长一些的函数还是起个名字最好。

柯里化(currying)和部分应用

还记得么,多参数函数的类型看起来有点儿奇怪,其中有很多“多余的”箭头?比如这个函数

f :: Int -> Int -> Int
f x y = 2*x + y

我曾经说过这其中是有优美深刻的原因的,现在终于是时间揭晓了: 其实所有的 Haskell 中的函数都只有一个参数。 什么?!上面这个函数 f 不是就有两个参数吗? 其实不是:它得到第一个参数(Int)然后返回了另一个函数(类型为 Int -> Int); 而这个函数再得到另一个参数才返回最终的结果。 事实上我们可以把 f 的类型写成这样:

f' :: Int -> (Int -> Int)
f' x y = 2*x + y

需要指出的是,类型中的箭头是向右结合的,W -> X -> Y -> Z 等价于 W -> (X -> (Y -> Z))。 类型中最右顶层的的括号去掉也没有关系。

反过来,函数应用是结合的。f 3 2 其实是 (f 3) 2 的简写。 这么说来我们之前说的 f 其实只需要一个参数然后返回另一个函数就合理了: 我们先把 f 应用在参数 3 上,得到一个类型为 Int -> Int 的函数,即一个对整数加六的函数。 然后把这个函数应用在参数 2 上,写作 (f 3) 2,最后得到一个整数。 因为函数应用是左结合的,所以我们可以把 (f 3) 2 缩写为 f 3 2, 看起来 f 就像一个“多参数”的函数一样。

“多参数”的 lambda

\x y z -> ...

其实只是

\x -> (\y -> (\z -> ...))

的语法糖。

同样,下面的定义

f x y z = ...

也是

f = \x -> (\y -> (\z -> ...)).

的语法糖。

把多参数函数表示为单参数并返回函数的函数叫做柯里化(currying),以英国数学和逻辑学家 Haskell Curry 命名。 (他的名字我们应该很熟悉,对,就是他。)柯里生于 1900 年逝于 1982 年,在宾州度过了他的大部分时光。 把多参数函数表示为单参数并返回函数的函数的想法其实首先是 Moses Schönfinkel 提出的, 所以可能更应该叫做 schönfinkeling。 柯里自己也指出这个想法来源于 schönfinkel,但是太晚了,别人早已经开始称其为 “currying” 了。

如果我们实在想表示一个有两个参数的函数,我们可以用一个元祖(tuple)来当参数。即

f'' :: (Int,Int) -> Int
f'' (x,y) = 2*x + y

这个函数可以看做有“两个”参数,尽管从某种意义上来说,它有的只是“一对”参数。 标准库里的 curryuncurry 两个函数提供了这两种表达方式之间的转换:

curry :: ((a,b) -> c) -> a -> b -> c
curry f x y = f (x,y)

uncurry :: (a -> b -> c) -> (a,b) -> c
uncurry f (x,y) = f x y

当你有一个元祖对,想把它应用在一个函数上时,uncurry 就非常有用了。比如:

Prelude> uncurry (+) (2,3)
5

部分应用

Haskell 的函数都是已经柯里化了的,这使得部分应用变得非常简单。 部分应用的意思是指,我们把一个多参数的函数只应用到一部分参数上,得到一个可以应用剩余参数的函数。 但是我们刚刚已经见过了,Haskell 中从来没有多参数的函数! 每一个函数都可以(并只能)被“部分应用”在它的第一个参数上,得到一个可以应用剩余参数的函数。

我们注意到在 Haskell 里部分应用除了第一个参数之外的参数就不那么容易了,除非使用中缀操作符。 我们已经见过,通过操作符域,中缀操作符可以被部分应用在它的两个参数任意一个上面。 所以在实际应用中这并不是什么限制了。 函数的参数的顺序也是有讲究的,要使得部分应用越方便越好:参数的顺序应服从“最小变化到最可能变化”。 即,不大会变的参数放在前面,时常变化的参数放在后面。

全麦编程

让我们来把刚学过的东西都放到一个例子里来,显示一下“全麦”风格的编程的威力。 比如如下定义的 foobar 函数:

foobar :: [Integer] -> Integer
foobar []     = 0
foobar (x:xs)
  | x > 3     = (7*x + 2) + foobar xs
  | otherwise = foobar xs

看起来足够直白,但这并不是最好的 Haskell 风格。问题出在:

  • 同时做了太多事;
  • 过于低层。

我们可以使用现有的几个递归模式,在整个输入上做增量变换,而不是考虑对每一个元素做什么。 下面是一个更为常见的 foobar 的实现:

foobar' :: [Integer] -> Integer
foobar' = sum . map ((+2) . (*7)) . filter (>3)

foobar' 被定义为三个函数串联的管道: 首先从列表中排除所有大于三的元素,然后对每个剩下的元素做算术操作,最后求和。

注意在上面的例子中,mapfilter 都是被部分应用的。例如,filter 的类型为:

(a -> Bool) -> [a] -> [a]

应用在 (>3)(其类型为 Integer -> Bool)上,返回一个类型为 [Integer] -> [Integer] 的函数。 恰好能和另一个在 [Integer] 上的函数组合。

Point-free 风格

这种定义一个函数而不具名其参数 —— 与其在说定义函数是什么,不如说在定义函数做什么 —— 的风格常被称为 “point-free” 风格。 从上面的例子可以看出来,这种风格有时候非常优美。有些人可能会认为无论什么时候都应该使用 point-free 风格; 但是过于追求这种风格反而会造成巨大的困惑。 IRC 频道 #haskell 中的 lambdabot 有一个命令是 @pl,可以把函数转化为等价的 point-free 表达式;下面是一个例子:

@pl \f g x y -> f (x ++ g x) (g y)
join . ((flip . ((.) .)) .) . (. ap (++)) . (.)

这可一点也没有提升!

再看下面两个函数:

mumble  = (`foldr` []) . ((:).)

grumble = zipWith ($) . repeat

你能明白他们在做什么吗?其实他们都等价于 map。 这些都是 point-free 风格过犹不及的例子。 所以有些人称之为 point-less(看不出要点)风格。