虎甲(TigerBeetle)的 Tiger Style 文档建议集中控制流,拆分大函数时将 switch/if 语句保留在父函数,把无分支逻辑移到辅助函数,即把 if 上推、for 下移。编程启发式 push-ifs-up-fors-down 主张条件逻辑上移、迭代循环下移以提升清晰与性能。马特克拉德(matklad)也撰文探讨该原则的优点。
正文
把 If 向上推,把 For 向下推:这个习语、它的代数与局限
引言
TigerBeetle 的 Tiger Style 文档建议:
集中控制流。拆分大函数时,尽量把所有 switch/if 语句留在“父”函数中,把无分支逻辑片段移到辅助函数。划分职责:所有控制流由一个函数处理,其余根本不关心控制流。换言之,“把 if 向上推,把 for 向下推”。
编程启发式“push-ifs-up-fors-down”指:条件逻辑(if)应上移(朝向调用者或流水线前端),迭代循环(for)应下移(转向批处理或后续无分支紧凑循环)。这通过集中分支和利用批量操作提升清晰度和性能。
matklad 也写过文章讨论此原则的优点。他描述优化代码的方式:
把条件(“ifs”)向上推: 若函数基于输入分支,考虑把分支移到调用者。例如,与其让 frobnicate(walrus: Option<Walrus>) 内部解包 Option,不如调用者处理 None 情况,函数接收普通 Walrus。函数类型现在声明了前置条件,输入状态空间变窄,起到过滤作用。关键在决策位置,而非多少数据向下传。
把循环(“fors”)向下推: 过滤或缩减数据集后再延迟循环,最小化不必要计算。与其在循环中调用 frobnicate(walrus),不如提供 frobnicate_batch(walruses) 让循环在里面。热循环无分支,可向量化。
两动作可组合。给定 Option<Walrus> 集合,调用者丢弃 None 并解包其余为 Vec<Walrus>,再交给 frobnicate_batch,后者完全不需考虑 None。如 let walruses: Vec<Walrus> = maybe_walruses.into_iter().filter_map(|w| w).collect(); frobnicate_batch(&walruses);
稍加思考,此原则有更广应用,可从关系数据库查询优化和函数式编程及范畴论探讨更广阔视角。
数据库查询中的类比:早投影,晚连接
同原则出现在数据库查询优化。SQL 计划树叶子是表扫描,根是结果,数据从叶向上流,故“向下树”意即“执行早”。优化器说“下推谓词”就是尽早评估,相当于其余部分说的“向上”或“早”。
早投影和选择(下推): 投影(SELECT 特定列)减少宽度,选择(WHERE)过滤。二者下推到计划树早执行,过滤无关数据,减少传给后续的数据量。
延迟连接(上推连接): 连接开销大。优化器把选择和投影移到连接下方,使连接在小输入上运行。昂贵组合算子只在语义允许的最小输入上运行。
向量化执行(即“for”): 火山式执行逐行处理,每元组调 next();向量化或批处理每批千元组调一次,内部紧循环。即查询引擎层面的 frobnicate 与 frobnicate_batch:每批付一次调用开销和决策,内循环少分支、缓存友好。
函数式编程与范畴论中的类比
把 If 向上推视为限制到子对象
范畴论中,集合范畴(或类型)有谓词 p : A -> Bool。满足谓词的元素子集 {a ∈ A | p a} 加上包含态射 {a | p a} ↪ A 定义子对象。钩箭头表示单态射(单射)。
对应 matklad:
- 之前:被调函数接收任意
A并内部运行if p(a)。 - 之后:调用者运行测试,被调函数输入类型为子集。
被调函数不再需要 if,因每个输入已通过。代码中用类型表示子集:Walrus 而非 Option<Walrus>。
范畴上说,Option<Walrus> 是余积 1 + Walrus。内部分支的函数实出自余积,由余积泛性质,它正是一对函数。上推 if 把这对分开:调用者处理 1,核心函数只处理 Walrus 部分。
Filter、Map 及关联定律
“先过滤再映射”建议何时合法重写?以下两式不等价:
filter p (map f xs) —— p 检查 f 的输出
map f (filter p xs) —— p 检查 f 的输入
实际关联定律是:filter p . map f == map f . filter (p . f)。这来自参数性,可通过 Maybe 分解 filter:keep p x = if p x then Just x else Nothing,filter p = catMaybes . map (keep p)。
filter p 本身不是自然变换,但 catMaybes :: [Maybe a] -> [a] 是,自然性在于:map g . catMaybes == catMaybes . map (fmap g)。由此推导定律。注意右侧不一定自动更便宜:filter (p . f) 仍为测试计算每个元素的 f。但当 p . f 简化为输入的廉价谓词 q(通常 p 检查 f 不动的部分),则 filter p . map f == map f . filter q,f 只在存活元素上运行。两边仍单遍 O(n),省的是对将丢弃元素的 f 调用。这正是 push-if-up-fors-down 的成效。
总结
主要启示:通用原则引导更好代码或系统代数,但都受跨组件约束。
- 从循环提取
if当条件循环不变时合法;逐元素条件不能离循环,只能移到边界并记录在类型如Walrus而非Option<Walrus>。 - 选择下推到连接下方,当谓词仅引用连接单侧的列。
- 先过滤后映射合法为
filter p . map f == map f . filter (p . f),仅当p . f化为输入廉价谓词时才省工。
代数告知哪些重写合法(如 catMaybes 自然性)。而“fors-down”更多关于成本而非等价:把箭头形状从 A -> B 改为 [A] -> [B],每批付一次设置成本。
评论
社区普遍质疑原文章缺乏性能基准且忽略现代编译器已自动完成此类优化,部分开发者从代码维护角度支持将条件上提作为通用最佳实践,并有人讽刺文章文风冗长如 LLM 生成。分歧主轴在于手动重构是否多余及条件应上移还是下沉。
认为将条件上提有助于性能优化
- 在大规模计算中将循环内条件外提可消除分支、利于向量化并行,仅分支预测收益也值得尝试(认可度:较多人) > 循环无分支且成为向量化候选,这在大规模科学计算中收益巨大(@alterom)
认为上提条件主要价值在代码维护与最佳实践而非性能
- 多年实践中该写法让代码易理解维护,速度几乎不是原因;本质即函数入口的守卫子句,属通用规范(认可度:较多人) > 我这么做多年,仅当让代码更易维护时才用,几乎从不为了速度(@ninalanyon)
质疑文章缺乏基准且忽略编译器已自动优化
- 文章未附任何测量,而 .NET 9 与 dotnet 运行时在安全时自动执行此类优化,现代 C 编译器同样如此,手动重构多此一举(认可度:高赞) > 为何既不给测量数据也不提大多数现代语言已自动做了这件优化,令人困惑(@wallstop)
反对条件上提,主张条件下沉与分发决策分离
- 相反哲学认为应将条件置于深层以保持高层控制流规律,把数据分发与决策点分离能让错误更明显,顶层条件易导致“方法为何没调用”的困扰(认可度:少数人) > 我始终相信反面:让条件深入代码,使上层控制流规整,避免分发与决策混淆(@rtpg)
视场景而定无普适强论证
- 从软件工程角度可用扁平映射抽象,从算法角度过滤拷贝可能浪费,优化应依据实际性能剖析,不能笼统推崇上提(认可度:少数人)
有价值的补充
- 该手法在函数定义处即为守卫子句,属常见编码规范。
- .NET 9 与 gcc、llvm 等编译器会在安全时自动应用相同优化技术。
- 科学计算中将操作表达为向量或矩阵可大幅并行获益,此场景下手写优化意义大。
- 原有观点可追溯至“推送 if 向上、for 向下”的既有文章,且可归纳为“绝不要只做一次某事”的泛化原则。