关于两条相关经验法则的简短说明。

原文链接:Push Ifs Up And Fors Down(2023 年 11 月 15 日)

把 If 上推

如果函数内部有一个 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 的组合容易得多!

一个相关的模式:溶解枚举

这里一个相关的模式是我所称的「溶解枚举」(dissolving 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)
  }
}

把 For 下推

这条来自面向数据的思路流派。少的东西就是少,多的东西就是多。程序通常处理成批的对象;或者至少,热路径通常涉及处理许多实体——正是实体的数量让这条路径变热。

所以,引入对象「批次」(batch)的概念,并让对批量的操作成为基本情形,而标量版本成为批量版本的特例,往往是明智的:

// ✅ GOOD
frobnicate_batch(walruses)

// ❌ BAD
for walrus in walruses {
  frobnicate(walrus)
}

这里的主要好处是性能——大量的性能,在极端情况下尤其如此。

如果你有一整批东西要处理,就可以摊薄启动成本,并灵活安排处理顺序。事实上,你甚至不必按任何特定顺序处理实体:你可以使用向量化 / 结构体数组(struct-of-array)技巧,先处理所有实体的某一个字段,然后再继续处理其他字段。

也许这里最有趣的例子是基于 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;
  • 它从热循环中移除了一个分支;
  • 它可能解锁向量化。

这个模式在微观和宏观层面都适用——GOOD 版本就是 TigerBeetle 的架构:在数据平面中,我们同时处理成批对象,以摊薄控制平面中做决策的成本。

虽然性能也许是 for 这条建议的主要动机,但有时它也有助于表达力。jQuery 当年相当成功,它就是作用于元素集合的。抽象向量空间的语言,往往比一堆逐坐标方程更适合作为思考工具。


总之,把 if 上推,把 for 下推!