Haskell 简明教程
列表(List)是 Haskell 中使用最频繁的线性数据结构。从字符串(底层就是字符列表)到数据处理管道,列表都在其中扮演核心角色。
本章讨论列表的底层链表结构、模式解构、标准库函数、时间复杂度以及惰性求值特性。
Haskell 的列表不是 C/C++ 或 Java 中的连续内存动态数组,而是递归单向链表。它的定义等价于:
data [] a = [] | a : [a]
[]:空列表(读作 Nil),链表的终点。
(:):构造运算符(读作 Cons),把一个元素接到一个已有列表的前面,形成新列表。
方括号字面量只是连续 Cons 的语法糖:
-- 语法糖写法:
[1, 2, 3]
-- 底层展开:
1 : (2 : (3 : []))
由单向链表的结构可以得出:
头部插入(Cons):: 的时间复杂度为 O(1)。
列表拼接:++ 的时间复杂度为 O(n),其中 n 是左侧列表的长度,需要复制左侧链表的所有节点。
尾部追加:形如 xs ++ [x] 的写法每次都要复制整个列表。如果在递归中反复这样做,整体复杂度会变成 O(n²)。
惯用做法:在头部用 (:) 插入,最后需要时调用一次 reverse(O(n))。
只要元素类型实现了 Enum 类型类,就可以使用区间记号:
ghci> [1..10]
[1,2,3,4,5,6,7,8,9,10]
-- 用前两个元素指定步长(例如步长为 2 的奇数序列):
ghci> [1, 3..11]
[1,3,5,7,9,11]
-- 递减序列:
ghci> [10, 9..1]
[10,9,8,7,6,5,4,3,2,1]
-- 字符区间:
ghci> ['a'..'e']
"abcde"
由于惰性求值,可以直接定义无限长的列表,只有被消费的部分才会真正生成:
-- 从 1 开始的无限自然数流:
ghci> take 5 [1..]
[1,2,3,4,5]
-- repeat:无限重复单个元素
ghci> take 4 (repeat "hello")
["hello","hello","hello","hello"]
-- replicate:生成指定长度的重复元素列表
ghci> replicate 5 'A'
"AAAAA"
-- cycle:把一个有限列表首尾相接无限循环
ghci> take 7 (cycle [1, 2, 3])
[1,2,3,1,2,3,1]
模式匹配是处理列表最自然的方式:
describeList :: [a] -> String
describeList [] = "这是一个空列表"
describeList (x:xs) = "首元素存在,尾部可能还有更多元素"
firstTwoSum :: Num a => [a] -> a
firstTwoSum (x:y:_) = x + y
firstTwoSum [x] = x
firstTwoSum [] = 0
标准库提供了提取端点元素的函数:
head :: [a] -> a:获取首元素
tail :: [a] -> [a]:获取除去首元素后的剩余列表
last :: [a] -> a:获取最后一个元素
init :: [a] -> [a]:获取除去最后一个元素后的前缀列表
警告
这四个函数都是偏函数(Partial Functions)。传入空列表 [] 时,它们会抛出运行时异常。
如果想从类型层面排除空列表,可以使用标准库自带的 Data.List.NonEmpty:
import Data.List.NonEmpty (NonEmpty(..))
import qualified Data.List.NonEmpty as NE
-- 定义为一个头部元素加一个可能为空的尾部列表:
-- data NonEmpty a = a :| [a]
safeHeadTotal :: NonEmpty a -> a
safeHeadTotal (x :| _) = x
NonEmpty a 的首元素在类型上保证存在,所以 safeHeadTotal 是全函数,不可能因为空列表而崩溃。
take n xs:提取前 n 个元素(不足 n 个则返回全部,不会报错)。
drop n xs:丢弃前 n 个元素,返回剩余部分。
splitAt n xs:在索引 n 处拆分,等价于 (take n xs, drop n xs)。
ghci> take 3 [1..10]
[1,2,3]
ghci> drop 7 [1..10]
[8,9,10]
ghci> splitAt 4 "Haskell"
("Hask","ell")
takeWhile p xs:从左向右提取,遇到第一个不满足谓词的元素就停止。
dropWhile p xs:从左向右跳过满足谓词的元素,返回从第一个不满足处开始的剩余部分。
span p xs:等价于 (takeWhile p xs, dropWhile p xs)。
break p xs:在第一个满足谓词处切开,等价于 span (not . p) xs。
ghci> takeWhile (< 5) [1, 2, 3, 4, 10, 2, 1]
[1,2,3,4]
ghci> dropWhile (< 5) [1, 2, 3, 4, 10, 2, 1]
[10,2,1]
ghci> span even [2, 4, 6, 7, 8, 10]
([2,4,6],[7,8,10])
列表处理的大部分工作可以归结为三件事:变换每个元素、按条件筛选、按某个键排序。下面用一份成绩单贯穿这几组函数:
import Data.List (sortOn, partition, nub, find, intercalate)
import Data.Ord (Down (..))
scores :: [(String, Int)]
scores = [("Alice", 92), ("Bob", 58), ("Carol", 75), ("Dave", 58)]
map :: (a -> b) -> [a] -> [b] 对每个元素应用函数,filter :: (a -> Bool) -> [a] -> [a] 只保留满足条件的元素。要一份名单,要一份及格名单:
ghci> map fst scores
["Alice","Bob","Carol","Dave"]
ghci> filter ((>= 60) . snd) scores
[("Alice",92),("Carol",75)]
(>= 60) . snd 是运算符截断与函数组合的搭配:先取二元组的第二项,再和 60 比较。这种“组合出一个谓词”的写法在列表处理里随处可见。
sort :: Ord a => [a] -> [a] 直接按元素排序。更常用的是 sortOn,按一个键函数排序;降序用 Data.Ord 的 Down 包一层把比较方向翻转。需要完全自定义比较逻辑时才用 sortBy。partition 一次遍历把列表拆成满足条件与不满足条件的两部分:
ghci> sortOn snd scores
[("Bob",58),("Dave",58),("Carol",75),("Alice",92)]
ghci> sortOn (Down . snd) scores
[("Alice",92),("Carol",75),("Bob",58),("Dave",58)]
ghci> partition ((>= 60) . snd) scores
([("Alice",92),("Carol",75)],[("Bob",58),("Dave",58)])
排序是稳定的,分数相同的 Bob 和 Dave 保持原有顺序。这几个函数都是“列表进、列表出”,所以可以用 . 串成管道。按分数从高到低列出及格者的名字:
passedNames :: [(String, Int)] -> [String]
passedNames = map fst . filter ((>= 60) . snd) . sortOn (Down . snd)
ghci> passedNames scores
["Alice","Carol"]
读的时候从右往左:先排序,再筛选,最后取名字。这是 Haskell 处理数据最常见的形状,对应其他语言里 stream().filter().map() 这类链式调用。
nub :: Eq a => [a] -> [a]:去重,保留首次出现的顺序。它只要求 Eq,所以是 O(n²),数据量大时应改用 Data.Set。
find :: (a -> Bool) -> [a] -> Maybe a:找第一个满足条件的元素。
lookup :: Eq a => a -> [(a, b)] -> Maybe b:在关联列表中按键查找。
ghci> nub (map snd scores)
[92,58,75]
ghci> find ((> 90) . snd) scores
Just ("Alice",92)
ghci> lookup "Carol" scores
Just 75
find 和 lookup 找不到时返回 Nothing 而不是抛异常,是全函数。lookup 每次都要线性扫描,键一多就该换成下一章的 Map。
concat :: [[a]] -> [a]:把嵌套列表压平为单层。
concatMap :: (a -> [b]) -> [a] -> [b]:每个元素映射成一个列表,再压平。
intercalate :: [a] -> [[a]] -> [a]:在子列表之间插入分隔列表再拼接,即其他语言的 join。
ghci> intercalate ", " (map fst scores)
"Alice, Bob, Carol, Dave"
zip :: [a] -> [b] -> [(a, b)]
zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
unzip :: [(a, b)] -> ([a], [b])
ghci> zipWith (+) [1, 2, 3] [10, 20, 30]
[11,22,33]
ghci> zip [1..] ["Alice", "Bob", "Charlie"]
[(1,"Alice"),(2,"Bob"),(3,"Charlie")]
两个列表长度不一致时,结果以较短的列表为准,多余的元素被丢弃。
列表推导式由生成器(Generators)、布尔过滤条件(Guards)与局部绑定(let)组成:
-- 勾股数(满足 a² + b² = c² 且周长为 24 的直角三角形):
ghci> [ (a, b, c) | c <- [1..10], b <- [1..c], a <- [1..b], a^2 + b^2 == c^2, a + b + c == 24 ]
[(6,8,10)]
它不是新的能力,而是上一节几个函数的语法糖:一个生成器加过滤条件就是 filter 加 map,多个生成器就是嵌套的 concatMap:
ghci> [ fst p | p <- scores, snd p >= 60 ]
["Alice","Carol"]
ghci> map fst (filter ((>= 60) . snd) scores)
["Alice","Carol"]
ghci> [ (x, y) | x <- [1, 2], y <- "ab" ]
[(1,'a'),(1,'b'),(2,'a'),(2,'b')]
第二个例子等价于 concatMap (\x -> map (\y -> (x, y)) "ab") [1, 2]。两种写法按可读性挑选:条件和变换都简单时推导式更直观,需要复用谓词或者串进管道时用函数形式。
本章开头说列表是递归单向链表。这一节把它在内存中的样子画出来,并解释惰性求值在这个结构上是如何起作用的。
先看一个 C 程序员都熟悉的结构:
struct Node {
void *value; /* 指向元素 */
struct Node *next; /* 指向下一个节点,末尾为 NULL */
};
Haskell 列表的每个 (:) 就是这样一个节点,两个字段分别对应 (:) 的左右两个参数。[] 则对应 NULL。1 : (2 : (3 : [])) 就是三个 Node 首尾相接,最后一个的 next 指向 NULL。
这个结构在 Lisp 里叫 cons cell,它的两个字段分别叫 car 和 cdr。Haskell 的命名直接继承了这套传统:
概念 |
Lisp |
Haskell |
|---|---|---|
构造一个节点 |
|
|
空列表 |
|
|
取第一个字段(元素) |
|
|
取第二个字段(剩余列表) |
|
|
三元素列表 |
|
|
前面用到的 (x:xs) 模式,做的就是同时取出一个节点的 car 和 cdr。
有了节点的图景,就可以把列表拆成两部分来看:
脊柱(Spine):所有 (:) 节点沿 next 指针(Lisp 的 cdr)连成的骨架。它决定了列表有多长,以及在哪里结束。
元素(Values):每个节点的 value 字段(Lisp 的 car)指向的数据。
下图是 [1, 2, 3] 在内存中的结构。横向的一排是脊柱,元素挂在每个节点下方:
graph LR
subgraph spine["脊柱(Spine)"]
direction LR
C1["(:)"] --> C2["(:)"] --> C3["(:)"] --> Nil["[]"]
end
subgraph values["元素(Values)"]
direction LR
V1["1"]
V2["2"]
V3["3"]
end
C1 --> V1
C2 --> V2
C3 --> V3
classDef spineNode fill:#dbe9ff,stroke:#3b6fb6;
classDef valNode fill:#fff3d6,stroke:#c48a1a;
class C1,C2,C3,Nil spineNode;
class V1,V2,V3 valNode;
到这里为止,Haskell 列表和 C 链表、Lisp 列表没有本质区别。区别在下面。
在 C 里,一个 Node 被 malloc 出来时,value 和 next 都已经填好了确定的地址,整条链表是现成的。
在 Haskell 里,(:) 的两个字段指向的都是堆上的对象,而这个对象可以是一个尚未计算的表达式,称为 Thunk。程序真正需要它的值时才会计算,算完后用结果替换掉原来的 Thunk(第 21 章会详细讨论这套机制)。这带来两个后果。
第一,沿脊柱走不会碰元素。求值 length 只需要顺着 next 指针数节点个数,每个节点的 value 字段指向什么,它根本不看:
ghci> length [undefined, undefined, undefined]
3
undefined 一旦被求值就会抛出异常。这里三个 undefined 都还是 Thunk,从头到尾没有人去碰它们,所以程序正常返回。换成 C 的说法,length 只读了每个节点的 next,从没解引用过 value。
第二,脊柱本身也可以只造一部分。next 字段同样可以是一个 Thunk,代表”剩下的列表还没生成”。前面见过的 [1..] 就是这样:它在堆上不是一条无限长的链,而是一个节点加上一个指向”1 之后的部分”的 Thunk。消费者每向后走一步,就多生成一个节点。
ghci> take 3 [1..]
[1,2,3]
-- length 需要走完整条脊柱,而这条脊柱永远走不完:
ghci> length [1..]
(不会返回)
在 C 里想做到同样的事,需要手写一个带状态的迭代器,或者把链表换成回调。Haskell 列表天然就是这种”按需生成”的结构,生产者与消费者用普通的 (:) 和模式匹配就能对接。
这两条性质合起来,就是 Haskell 能用列表做流式处理的原因。像 take 10 (filter even [1..]) 这样的表达式,filter 不会先把整个输入过一遍,而是在 take 每次索要下一个节点时才向 [1..] 多要一个数。内存中同时存在的只有正在处理的那几个节点,处理过的部分可以立刻被回收。
备注
value 字段永远是一个指针,这一点和 C 也不同。C 的 struct Node { int value; struct Node *next; } 可以把整数直接存在节点里,Haskell 的 [Int] 则是每个节点再指向一个独立的 Int 对象,也就是所谓的装箱(Boxed)。这是 [Int] 比 C 数组占用内存多得多的原因之一。需要紧凑存储数值时可以用第 11 章介绍的 Data.Vector.Unboxed。
列表是递归单向链表,头部插入 O(1),尾部追加 O(n),避免在循环中尾部追加。
head/tail 等是偏函数,可以用模式匹配或 Data.List.NonEmpty 代替。
列表处理的常见形状是 map f . filter p . sortOn g 这样的管道,列表推导式是 map、filter、concatMap 的语法糖。
(:) 节点就是 C 的 struct Node { value; next },也就是 Lisp 的 cons cell。head/tail 对应 car/cdr。
脊柱(next 链)与元素(value)的求值相互独立,两者都可以是未求值的 Thunk,这是列表可以惰性流式处理的基础。