第 3 部分 · 函数式:不动状态的计算
模式匹配——用形状拆解数据
match让你按数据的“形状”做判断:一次匹配同时完成“是不是这种结构”和“把里面的部件取出来”两件事。它是cond的进化版——cond只比值的相等,match能比形状。
你拆过嵌套的列表吗?用 car、cdr 一层一层剥,代码很快糊成一堆括号;用 cond 判断“是不是三元素列表”,判断完了还得再 cadr、caddr 把元素取出来。判断一次、提取一次,同一份结构被你描述了两遍。
模式匹配把这两步合一:你把数据的形状画在模式里,Racket 既拿它做判断、又顺手把部件绑到变量上。
先看清 match 的骨架
判断一个值是不是 3,cond 这么写:
(cond
[(equal? x 3) 'yes]
[else 'no])
match 这么写:
(match x
[3 'yes]
[_ 'no])
3 是字面量模式,匹配等于 3 的值;_ 是通配模式,匹配任何值。每一行 [模式 结果] 是一个分支,从上往下试,第一个匹配的执行,跟 cond 一样;一个都匹配不上就抛异常。差别在后面:一旦模式里出现结构,match 的威力就显出来了。
三个最朴素的模式
所有复杂模式,都由字面量、变量、通配符这三种原子搭起来。
字面量匹配一个具体的值——数字、字符串、布尔、字符都行:
(match "ok"
["yes" 'accepted]
["no" 'rejected]
[_ 'unknown]) ;; 'unknown
变量匹配任何值,并把它绑定到一个名字上。一个单独的标识符(只要不是 _、... 这类保留写法)就是变量模式:
(match 42
[x x]) ;; 42,x 被绑到 42
通配符 _ 同样匹配任何值,但不绑定——你只关心“匹配上了”,不关心值是什么。在 cond 里你写 else 兜底,在 match 里 else 只是个普通变量名,兜底要用 _。
按形状拆开列表
列表是 Lisp 的心脏,也是模式匹配最常发力之处。
固定长度的列表,用 list 模式,按位置绑变量:
(match '(1 2 3)
[(list a b c) (+ a b c)]) ;; 6
长度不定,用 ...(省略号模式)吃掉任意多个元素,绑成一个列表:
(match '(1 2 3 4)
[(list first rest ...) (list first rest)])
;; '(1 (2 3 4))
first 匹配第一个元素,rest ... 匹配剩下的零个或多个。把 first 写在前面,就锁定了“列表非空”——少一个元素都匹配不上。想连空列表也接住,写 (list xs ...) 即可,... 本身允许匹配零次。
要拿点对的 car 和 cdr,用 cons 模式:
(match '(1 . 2)
[(cons head tail) (list head tail)]) ;; '(1 2)
cons 匹配任意点对,head 绑 car,tail 绑 cdr。递归处理链表时,(cons head tail) 是最自然的写法——tail 还是个列表,接着往下剥。
按字段名拆开结构体
自定义结构体用 struct 定义,匹配时直接按字段位置命名:
(struct pos (x y))
(match (pos 3 4)
[(pos x y) (sqrt (+ (* x x) (* y y)))])
;; 5,直角三角形斜边
写 (pos x y),等于说“这是个 pos,把它的两个字段分别叫 x 和 y”,比 (pos-x p)、(pos-y p) 直观得多。结构体有继承时,模式要把父类字段也算上。dog 继承自 animal,匹配时第一个字段是 animal 的 name,第二个才是 dog 自己的 age:
(struct animal (name))
(struct dog animal (age)) ; dog 继承 animal
(match (dog "Lucky" 5)
[(dog name age) (format "~a is ~a" name age)])
;; "Lucky is 5"
结构体模式按所有字段(含父类)从左到右对应,父类字段在前。
用谓词给模式把关
光会匹配形状还不够——有时候你要“是数字,并且……”。? 模式接一个谓词,先检查值过不过关,过了再交给后面的模式:
(match 42
[(? exact-integer? n) (* n 2)]) ;; 84
(? exact-integer? n) 的意思:值满足 exact-integer?,就把它绑到 n。谓词可以是任何单参数函数,包括你自己写的 lambda:
(match 5
[(? (λ (x) (< x 10)) small) (format "~a 太小了" small)])
;; "5 太小了"
? 后面还能再套模式,组合出“满足条件、形状也对”的双重约束。
把模式组合起来
真正的复杂判断,靠几个组合模式拼起来。
or 匹配几种形状之一,每个分支必须绑定同一组变量:
(match 'yes
[(or 'yes 'y) 'ok]) ;; 'ok
and 要求所有模式同时成立,常用来“先绑个名字,再提条件”:
(match 8
[(and (? integer?) (? even?)) 'good]) ;; 'good
app 是最灵活的一个——它把一个函数作用到值上,再拿结果去匹配模式。比如先求列表长度,再比是不是 3:
(match '(a b c)
[(app length 3) 'three-elements]) ;; 'three-elements
app 让你能“先变换、再判断”,任何函数都能塞进模式里。not 是它们的反面,匹配“不是这种形状”的值,不绑定任何变量:
(match "hi"
[(not "bye") 'greeting]) ;; 'greeting
在定义里直接拆
模式匹配不必只写在 match 里,它还能钻进 define 和 let。
match-define 一步完成“求值 + 拆解”,省得再写一遍 car cdr:
(match-define (list a b) '(1 2))
a ;; 1
b ;; 2
match-let 把同样的能力放进 let,一次性拆开多个值:
(match-let ([(list a b) '(1 2)]
[(vector x y) #(3 4)])
(list a b x y)) ;; '(1 2 3 4)
要按顺序复用前面的绑定,换 match-let*,就像 let* 之于 let。定义函数时想把参数直接按模式拆开,用 define/match,每个分支是一组对应参数的模式:
(define/match (sum3 lst)
[((list a b c)) (+ a b c)])
(sum3 '(1 2 3)) ;; 6
注意那层双括号——define/match 的每个分支对应一组参数模式,所以 (list a b c) 外面还要包一层。
只要数据有固定形状,就别再手动 car cdr——match-define 直接拆开。
实战:拆解一门小语言
模式匹配的杀手锏,是处理 S-表达式这类“数据即程序”的结构。写一个只能做加减乘除的表达式求值器,整个核心就这几行:
(define (eval-expr expr)
(match expr
[(list '+ a b) (+ (eval-expr a) (eval-expr b))]
[(list '* a b) (* (eval-expr a) (eval-expr b))]
[(? number? n) n]))
(eval-expr '(+ 1 (* 2 3))) ;; 7
每条规则就是一个模式:看到 (+ a b) 就递归求 a 和 b 再相加,看到数字就原样返回。'+ 是字面量,匹配那个符号本身(写 + 不加引号会被当成变量)。写解释器、走 AST、解析配置,本质都是按形状分派——match 正是为此而生。
找问题,再改对
下面两段都在「先判断、再提取」,把同一份数据的形状描述了两遍。先说出各自的啰嗦在哪,再用 match 一次说清:
1. 有人想处理一个带标签的结果(成功带值、失败带原因):
(define (handle-result r)
(cond [(and (pair? r) (eq? (car r) 'ok))
(format "成功:~a" (cadr r))]
[(and (pair? r) (eq? (car r) 'error))
(format "失败:~a" (cadr r))]
[else "未知"]))
2. 有人想从一个 user 结构体里取出名字和邮箱,拼成一行:
(struct user (name email))
(define (show-user u)
(string-append (user-name u) " <" (user-email u) ">"))
改完想一想:原代码里,「判断它是什么形状」和「把里面的东西取出来」是分两步做的。改用 match 后,这两步合成了几步?
模式匹配让你用数据的形状说话:画出一个模式,判断与提取一次完成。一旦习惯了按形状思考,
car/cdr和嵌套cond的噪音就从代码里消失。