纯函数:没有副作用的计算
Haskell 是纯函数式语言,最硬的一条纪律是:函数是纯的。同一个输入永远得到同一个输出,函数内部不读写全局状态、不打日志、不抛异常、不碰任何「外部世界」。副作用(打印、读文件、网络请求)被隔离在类型系统标记的特殊区域——IO 类型里。
double :: Int -> Int
double x = x * 2
main :: IO ()
main = print (map double [1, 2, 3, 4, 5])
-- 输出 [2,4,6,8,10]
注意类型签名 Int -> Int:一个 Int 进、一个 Int 出,编译器据此保证 double 不可能偷偷做别的事。而 main :: IO () 明确宣告「这里有副作用」,读代码的人一眼就知道副作用边界在哪。
纯函数的价值是推论变得极其简单:你可以把 double (double 2) 等价替换成 double 4,因为函数没有隐藏行为。测试时不需要 mock,并发时不需要锁——纯函数天然线程安全。这是 Haskell 最根本的「以约束换自由」。
纯函数把「这段代码能不能出问题」从运行时问题变成了编译期问题。
- 纯函数同输入必同输出,无副作用。
- 不可变与惰性数据不修改,按需求值。
- 代数数据类型用构造器精确建模领域。
- Maybe / Either把「可能失败」写进类型。
- 类型类给不同类型定义共同行为。
不可变与惰性求值
Haskell 里没有「变量赋值」——x = 5 定义的是一个永不变的值,而不是一块可以改写的内存。列表变换生成新列表,旧列表原封不动。不可变让「共享」变得安全:两个函数拿着同一份大列表各自变换,互不干扰。
更特别的是惰性求值:表达式只在「结果真正被需要」时才计算。这意味着你能定义无限结构,只取用其中一小段:
nums :: [Int]
nums = [1 ..] -- 无限列表:1,2,3,4,...
take 5 nums -- [1,2,3,4,5]
take 5 (map (*2) nums) -- [2,4,6,8,10]
[1 ..] 是无限序列,但 take 只取前 5 个,其余永不计算——内存和时间都只花在被用到的部分。惰性求值让代码可以先「声明全部规则」,再按需消费,处理流式数据时尤其优雅。
代价是理解门槛:你以为某行代码「执行了」,其实它可能只是建立了待求值表达式。用 seq、! 严格性注解可以强制立即求值,性能敏感路径上会用到。先掌握「声明即数据」的直觉,性能优化是后话。
代数数据类型与模式匹配
Haskell 定义数据的方式叫代数数据类型(ADT):用构造器枚举一个类型的所有可能形态。组合出「要么是 A 要么是 B」的联合类型,是建模领域问题的利器。
data Shape = Circle Double -- 圆,半径
| Rect Double Double -- 矩形,宽高
area :: Shape -> Double
area (Circle r) = pi * r * r
area (Rect w h) = w * h
模式匹配与 ADT 是天生一对:area (Circle r) 同时做「判断是 Circle」和「取出半径 r」两件事。编译器会检查所有构造器是否都有对应分支——漏了 Rect 就编译不过,这是「穷尽性检查」带来的安全感。
ADT 的美妙在于「不合法的状态无法被表示」。比如「一个可能有余额也可能没余额的账户」:
data Account = Empty | WithBalance Double
编译器强制你处理 Empty 分支,从源头杜绝「忘判断空账户就取余额」这类 bug。数据建模的合法性从运行时约定升级为编译期结构——这是 Haskell 对复杂业务最有价值的能力。
Maybe 与 Either:显式的错误处理
标准库的 Maybe 和 Either 是两种最常用的 ADT:Maybe a 表示「有值 Just a 或没有 Nothing」,Either e a 表示「失败 Left e 或成功 Right a」。Haskell 里没有 null,没有裸奔的异常——可能失败的事,类型上先说明。
safeDiv :: Int -> Int -> Maybe Int
safeDiv _ 0 = Nothing
safeDiv a b = Just (a `div` b)
调用方拿到 Maybe 后必须「解开」它。直接上手的方式是模式匹配,更函数式的方式是组合子:
safeDiv 10 2 -- Just 5
fmap (*2) (safeDiv 10 2) -- Just 10
maybe (-1) id (safeDiv 1 0) -- -1(Nothing 时的兜底值)
fmap 把函数应用进 Maybe 内部:有值就变换,Nothing 就原样传递,错误不会「传染」到不该去的地方。需要携带错误详情时用 Either——习惯上 Left 装错误、Right 装成功,配合 case ... of 解包。
这套设计的核心:错误路径不是「意外」,而是类型系统里的一等公民。写出「不可能忘记处理失败」的代码,比写出「小心翼翼 try/catch」的代码要稳得多。
高阶函数:map、filter 与 fold
列表处理三大件 map / filter / fold 构成了 Haskell 数据流水线的主干。它们都是高阶函数——接收函数作为参数,把「遍历逻辑」与「具体操作」彻底分离。
map (*2) [1,2,3] -- [2,4,6]
filter (> 2) [1,2,3,4] -- [3,4]
foldr (+) 0 [1,2,3] -- 6
foldl' (++) "" ["a","b"] -- "ab"
foldr f z 把列表从右往左折叠:f 1 (f 2 (f 3 z)),适合惰性友好的操作;foldl' 从左折叠且严格求值,适合大列表累加。三个函数覆盖了「逐个变换、按条件挑选、整体聚合」三类最常见的遍历意图。
写 Haskell 的习惯是「先想要什么变换,再找对应函数」,而不是手写递归。遇到没有现成函数的场景,再用递归——但很多时候组合 map/filter/fold 就够了。这种风格产出的代码是「数据流的描述」,读起来像一份处理说明书。
类型类:给类型定义行为
类型类(typeclass)是 Haskell 实现「多态」的机制:先声明一组行为签名,再为具体类型提供实现。它比「继承」更灵活——你可以为任何类型补上行为,包括别人定义的类型。
class Describable a where
describe :: a -> String
instance Describable Int where
describe n = "number " ++ show n
instance Describable Bool where
describe True = "yes"
describe False = "no"
标准库到处是类型类:Eq(相等)、Ord(排序)、Show(转字符串)、Num(数值运算)。约束写法 a => 表达「这个函数对任何满足某类型类的 a 都适用」:
sumList :: (Num a) => [a] -> a
sumList = foldr (+) 0
(Num a) => 读作「对任意数值类型 a」——sumList 同时适用于 Int、Double、Integer,类型类约束让它安全地多态。这才是 Haskell 多态的完整形态:不是继承树,而是「能力约束」。
类型类让通用函数(sort、show、==)能作用于无数具体类型,同时保留类型安全:编译器保证调用点满足约束。理解类型类,就理解了 Haskell 抽象层的地基。
实战:用纯函数写一个文本统计程序
把纯函数、惰性、ADT 与高阶函数收拢成一个单词统计小程序:读文本、清洗、按词频排序、取前几名。除了最外层的 IO 读取,整个核心全是纯函数。
import Data.Char (toLower)
import Data.List (sort, group, sortOn)
import Data.Function ((&))
wordCount :: String -> [(String, Int)]
wordCount text =
text
& map toLower
& words
& filter (not . null)
& group . sort
& map (\xs -> (head xs, length xs))
& sortOn (negate . snd)
main :: IO ()
main = print (take 3 (wordCount "Go Go Haskell Haskell Haskell Scala"))
每一行都是管道的一节:转小写、切词、滤空、排序分组、折叠成 (词, 频次)、按频次降序。注意 group . sort 的组合——把「排序后相邻相同项合并」表达为函数组合,这比手写循环清晰得多。
整个处理链没有任何可变状态、没有任何赋值,只有数据从一段流到下一段。副作用被推到 main 的 print 一行。这就是 Haskell 日常的写法:核心逻辑纯函数化,边界处集中处理副作用。
Haskell 教给其他语言最重要的一课:把不可变、纯函数和类型当作默认选择,而不是特殊风格。