将 Ifs 上推,Fors 下推
将 Ifs 上推,Fors 下推 Nov 15, 2023
关于两条相关经验法则的简短说明。
将 Ifs 上推
如果函数内部存在 if
条件,请考虑是否可以将其移动到调用者处:
// GOOD
fn frobnicate(walrus: Walrus) {
...
}
// BAD
fn frobnicate(walrus: Option<Walrus>) {
let walrus = match walrus {
Some(it) => it,
None => return,
};
...
}
如上面的示例所示,这通常出现在前提条件中:函数可能会在内部检查前提条件,如果不满足则“不执行任何操作”,或者它可以将前提条件检查的任务推送到其调用者,并通过类型(或断言)强制执行前提条件。特别是对于前提条件,“上推”可能会变得像病毒一样传播,并最终减少总体检查次数,这是这条经验法则的动机之一。
另一个动机是,控制流和 if
语句很复杂,是 bug 的来源。通过上推 if
语句,您通常最终会将控制流集中在单个函数中,该函数具有复杂的分支逻辑,但所有实际工作都委托给直线子程序。
如果 你有复杂的控制流,最好将其放在单个函数的一个屏幕上,而不是分散在整个文件中。更重要的是,将所有流程集中在一个地方通常可以注意到冗余和死条件。比较:
fn f() {
if foo && bar {
if foo {
} else {
}
}
}
fn g() {
if foo && bar {
h()
}
}
fn h() {
if foo {
} else {
}
}
对于 f
,比对于 g
和 h
的组合更容易注意到死分支!
这里的一个相关模式是我称之为“溶解 enum”的重构。有时,代码最终看起来像这样:
enum E {
Foo(i32),
Bar(String),
}
fn main() {
let e = f();
g(e)
}
fn f() -> E {
if condition {
E::Foo(x)
} else {
E::Bar(y)
}
}
fn g(e: E) {
match e {
E::Foo(x) => foo(x),
E::Bar(y) => bar(y)
}
}
这里有两个分支指令,通过将它们拉上来,很明显这是完全相同的条件,重复了三次(第三次被具体化为数据结构):
fn main() {
if condition {
foo(x)
} else {
bar(y)
}
}
将 Fors 下推
这来自面向数据的思想流派。少量的事物就是少量,大量的事物就是大量。程序通常处理成批的对象。或者至少热路径通常涉及处理许多实体。正是实体的数量使路径成为热路径。因此,引入“批量”对象的概念通常是谨慎的,并使对批量的操作成为基本情况,而标量版本是批量版本的特例:
// GOOD
frobnicate_batch(walruses)
// BAD
for walrus in walruses {
frobnicate(walrus)
}
这里的主要好处是性能。大量的性能,在极端情况下。
如果您要处理整批事物,则可以分摊启动成本,并且可以灵活地确定处理事物的顺序。实际上,您甚至不需要以任何特定顺序处理实体,您可以执行向量化/结构数组技巧,首先处理所有实体的一个字段,然后再继续处理其他字段。
这里最有趣的例子可能是基于 FFT 的多项式乘法:事实证明,同时评估多个点的多项式可能比大量单独的点评估更快!
关于 for
和 if
的两条建议甚至可以组合!
// GOOD
if condition {
for walrus in walruses {
walrus.frobnicate()
}
} else {
for walrus in walruses {
walrus.transmogrify()
}
}
// BAD
for walrus in walruses {
if condition {
walrus.frobnicate()
} else {
walrus.transmogrify()
}
}
GOOD
版本是好的,因为它避免了重复重新评估 condition
,从热循环中删除了一个分支,并可能解锁了向量化。这种模式在微观层面和宏观层面都有效——好的版本是 TigerBeetle 的架构,在数据平面中,我们同时对批量的对象进行操作,以分摊控制平面中决策制定的成本。
虽然性能可能是 for
建议的主要动机,但有时它也有助于表达。 jQuery
在当时非常成功,并且它对元素集合进行操作。抽象向量空间的语言通常是比大量逐坐标方程更好的思考工具。
总而言之,将 if
语句上推,将 for
语句下推!