ESC
科技 1 分钟阅读

if 上推、for 下推:这一编程惯用法、其代数本质及其局限

本文深入剖析"将 if 上推、将 for 下推"的编程惯用法:让调用方处理分支、把循环封装进批处理函数,从而获得更窄的输入类型空间与可向量化的热循环。文章进一步将这一原则推广到关系数据库查询优化(谓词下推、延迟连接、向量化执行)以及函数式编程与范畴论(子对象、余积、filter 与 map 的代数律),揭示同一思想在不同领域的统一表达。

来源:Hacker News

将条件判断(“if”)上推: 如果一个函数会根据输入进行分支,可以考虑把该分支移动到调用方。以文中的例子来说——不要让 frobnicate(walrus: Option<Walrus>) 在函数内部解包 Option,而是由调用方处理 None 的情况,函数直接接收一个纯粹的 Walrus。这样函数的类型就声明了它的前置条件,输入状态空间变得更窄,起到了一种过滤作用。但关键在于决策所处的位置,而不是有多少数据流向下游。

将循环(“for”)下推: 把循环推迟到过滤或缩减数据集之后,可以最小化不必要的计算。与其在循环中反复调用 frobnicate(walrus),不如提供 frobnicate_batch(walruses),让循环留在函数内部。这样热循环中就没有分支,成为可向量化的候选。

这两种手法可以组合使用。给定一个 Option<Walrus> 值的集合,调用方丢弃 None 并把其余的解包成 Vec<Walrus>,然后把它交给 frobnicate_batch,后者完全不必考虑 None 的情况。

let maybe_walruses: Vec<Option<Walrus>> = ...; let walruses: Vec<Walrus> = maybe_walruses.into_iter().filter_map(|w| w).collect(); frobnicate_batch(&walruses); // never sees a None

稍微思考一下,你会发现"if 上推、for 下推"原则的应用范围要广泛得多。本文将探讨这一原则在关系数据库查询优化以及函数式编程和范畴论方面更宏观的视角。

同样的原则也出现在数据库查询优化中。在 SQL 查询计划领域,众所周知应当尽早执行投影和选择,而把连接或其他高开销操作推迟到后面。但这里的术语方向是反过来的。查询计划是一棵树,叶子是表扫描,根产生结果。数据从叶子向上流动,因此"树的下方"意味着"执行的早期"。当优化器谈到将谓词下推时,意思是尽可能早地对其进行求值,这正是本文其余部分所称的"上推"或"提早"在数据库领域的对应物。

早期投影与选择: 用数据库的术语来说,投影(例如 SELECT 指定特定列)通过在查询执行早期只选取必要的列来缩减数据集的宽度,而选择(WHERE 子句)则充当过滤器。两者都会被下推到计划树中,从而尽早执行并过滤掉无关数据,减少传递给后续操作的数据量。

延迟连接: 连接操作组合来自多张表的数据,计算代价高昂。优化器把选择和投影移到连接之下,使连接在更小的输入上运行。其效果是让高开销的组合算符在查询语义所允许的最小输入上运行。

向量化执行(即那个 “for”): “for 下推"在数据库中的另一个类比是执行语义的改变。Volcano 风格的执行一次处理一行,每个算符通过虚函数 next() 为每个元组各被调用一次。替代方案是向量化或批处理执行,每个算符针对大约一千个元组组成的批次各被调用一次,并在内部运行一个紧凑的循环。这就是查询引擎层面的 frobnicate 与 frobnicate_batch 之分:每次调用的开销和每次调用的决策按批次只支付一次,内层循环分支少且对缓存友好。

让我们透过函数式编程和范畴论的视角来看同样的原则。

在范畴论中,当我们谈论集合的范畴(或宽泛地说,编程中的类型),可以有一个谓词 p : A -> Bool。元素要么满足谓词,要么不满足。现在考虑满足该谓词的元素构成的子集,即 {a ∈ A | p a}。再加上定义包含关系的态射 {a | p a} ↪ A。这个子集连同该态射在范畴论中定义了一个子对象。

注意态射中的钩形箭头。这是有意的,它表示一种特定类型的态射——单态射(数学家就喜欢定义各种箭头类型 :-))。在 Set 中,单态射是一个单射函数:包含映射把每个元素映到它自身,因此不同的输入给出不同的输出。

被调函数不再需要 if,因为它能收到的每个输入都已经通过了检验。在代码中,你用类型来表示这个子集:用 Walrus 而不是 Option<Walrus>。

从范畴的角度来说,Option<Walrus> 是余积 1 + Walrus,即"要么什么都没有,要么一个 walrus”。一个接收 Option<Walrus> 并在内部分支的函数,实际上是从余积出发的函数,而根据余积的泛性质,这样的函数恰好是一对函数,每个加项对应一个。把 if 上推就是把这一对函数拆开:调用方处理 1 这个加项,而核心函数只是 Walrus 部分。

同一原则的另一种体现——组合子的代数。你经常听到"先 filter 再 map"的建议。但这究竟在什么时候才是合法的改写?因为下面两个表达式并不等价:

filter p (map f xs) -- p 检查的是 f 的*输出* map f (filter p xs) -- p 检查的是 f 的*输入*

第一行中 p 的类型是 B -> Bool;第二行中是 A -> Bool。真正把它们联系起来的法则是:

filter p . map f == map f . filter (p . f)

这可以从参数性(parametricity)推导出来,而通过 Maybe 对 filter 进行因式分解最容易看清这一点:

`keep :: (a -> Bool) -> a -> Maybe a keep p x = if p x then Just x else Nothing

filter p = catMaybes . map (keep p)`

这里 filter p 本身并不是一个自然变换——它不可能是,因为 p 固定了元素类型,你无法画出自然性方块(试试看!)——但 catMaybes :: [Maybe a] -> [a] 是一个自然变换,自然性正存在于其中: