纯函数:没有副作用的计算

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 纯函数与类型类实战配图:深蓝至青蓝渐变背景,中央透明胶囊状封闭管道内白色输入光球与蓝色输出光球由光线相连,管道上方四个菱形节点由细线连接成层级树,底部横向进度条式节点逐个发光
封闭管道里的光球进出、上方的类型层级树与按需点亮的节点——纯函数、类型类与惰性求值三位一体。

不可变与惰性求值

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 就够了。这种风格产出的代码是「数据流的描述」,读起来像一份处理说明书。