overreactedby Dan Abramov

“Bug-O” 记法

January 25, 2019

当你编写对性能敏感的代码时,牢记其算法复杂度是个好习惯。算法复杂度通常用 大 O 记法 来表示。

大 O 记法衡量的是当你处理更多数据时,代码会变慢多少。比如,如果某个排序算法的复杂度是 O(n2),那么排序数量增加 50 倍,执行时间大约会变慢 502 = 2500 倍。大 O 记法并不能给你一个精确的数字,但它可以帮助你理解算法的扩展性

常见的例子有:O(n)、O(n log n)、O(n2)、O(n!)。

然而,这篇文章并不是关于算法或性能的。它关注的是 API 和调试。事实证明,API 设计也涉及到非常类似的考量。


我们有很大一部分时间都花在查找和修复代码中的错误上。大多数开发者都希望能更快地发现 bug。虽然最终解决 bug 令人满意,但如果你花了一整天时间只为追踪一个 bug,而本可以用来实现 roadmap 上的新功能,这种感觉实在糟糕。

调试体验会影响我们对抽象、库和工具的选择。有些 API 和语言设计可以让某一类错误根本不可能发生,有些则会带来无穷无尽的问题。但你怎么判断哪种设计更好?

许多关于 API 的网络讨论主要关注美观性。但那并不能说明实际使用 API 时的感受。

我有一个帮助我思考这个问题的指标。我称之为 Bug-O 记法:

🐞(n)

大 O 记法描述的是算法在输入规模增长时会变慢多少。而 Bug-O 记法描述的是,随着你的代码库变大,API 会让变慢多少。


举个例子,假设有这样一段代码,通过命令式操作(如 node.appendChild()node.removeChild())手动更新 DOM,且没有明确的结构:

function trySubmit() {
  // 第 1 段
  let spinner = createSpinner();
  formStatus.appendChild(spinner);
  submitForm().then(() => {
  	// 第 2 段
    formStatus.removeChild(spinner);
    let successMessage = createSuccessMessage();
    formStatus.appendChild(successMessage);
  }).catch(error => {
  	// 第 3 段
    formStatus.removeChild(spinner);
    let errorMessage = createErrorMessage(error);
    let retryButton = createRetryButton();
    formStatus.appendChild(errorMessage);
    formStatus.appendChild(retryButton)
    retryButton.addEventListener('click', function() {
      // 第 4 段
      formStatus.removeChild(errorMessage);
      formStatus.removeChild(retryButton);
      trySubmit();
    });
  })
}

这段代码的问题并不是“丑陋”。我们这里不是在讨论美观性。问题在于,如果这段代码出现了 bug,我根本不知道该从哪里开始排查。

**根据回调和事件触发的顺序不同,这个程序可能走的代码路径会呈指数级爆炸。**有些情况下,你会看到正确的提示信息;而在其他情况下,你可能会看到多个加载动画、失败和错误信息同时出现,甚至程序崩溃。

这个函数分为 4 个不同的片段,且它们的执行顺序没有任何保证。我的一个非常不科学的估算是,这些片段可能有 4×3×2×1 = 24 种不同的执行顺序。如果再加上四个代码片段,那就是 8×7×6×5×4×3×2×1 —— 四万种组合。祝你调试愉快。

换句话说,这种写法的 Bug-O 是 🐞(n!),其中 n 是涉及 DOM 操作的代码片段数。没错,这是阶乘。当然,这个估算并不严谨,实际上并非所有转移都是可能的。但另一方面,每个片段还可能被多次执行。🐞(¯\_(ツ)_/¯) 可能更准确,但无论如何都很糟糕。我们完全可以做得更好。


要降低这段代码的 Bug-O,我们可以限制可能的状态和结果数量。为此我们不需要任何库,只需给代码加上一些结构即可。下面是一种做法:

let currentState = {
  step: 'initial', // 'initial' | 'pending' | 'success' | 'error'
};
 
function trySubmit() {
  if (currentState.step === 'pending') {
    // 不允许重复提交
    return;
  }
  setState({ step: 'pending' });
  submitForm().then(() => {
    setState({ step: 'success' });
  }).catch(error => {
    setState({ step: 'error', error });
  });
}
 
function setState(nextState) {
  // 清空所有已有的子元素
  formStatus.innerHTML = '';
 
  currentState = nextState;
  switch (nextState.step) {
    case 'initial':
      break;
    case 'pending':
      formStatus.appendChild(spinner);
      break;
    case 'success':
      let successMessage = createSuccessMessage();
      formStatus.appendChild(successMessage);
      break;
    case 'error':
      let errorMessage = createErrorMessage(nextState.error);
      let retryButton = createRetryButton();
      formStatus.appendChild(errorMessage);
      formStatus.appendChild(retryButton);
      retryButton.addEventListener('click', trySubmit);
      break;
  }
}

这段代码看起来似乎变化不大,甚至更啰嗦了一些。但它在调试时极其简单,因为有这样一行:

function setState(nextState) {
  // 清空所有已有的子元素
  formStatus.innerHTML = '';
 
  // ... 下面是往 formStatus 添加内容的代码 ...

通过在每次操作前清空 formStatus,我们确保每次 DOM 操作都是从零开始。这样可以对抗不可避免的 —— 不让错误积累。这就像是“重启一下”在编程中的体现,而且效果出奇地好。

如果输出有 bug,我们只需要回溯一步 —— 也就是上一次的 setState 调用。 这种渲染结果的调试 Bug-O 是 🐞(n),其中 n 是渲染代码路径的数量。这里只有四种(因为 switch 里只有四个分支)。

当然,在设置状态时仍可能有竞态条件,但调试这些问题更容易,因为每个中间状态都可以被记录和检查。我们还可以显式禁止不希望发生的状态转移:

function trySubmit() {
  if (currentState.step === 'pending') {
    // 不允许重复提交
    return;
  }

当然,每次都重置 DOM 也有代价。简单粗暴地移除和重建 DOM 会破坏内部状态、丢失焦点,并在大型应用中带来严重的性能问题。

这也是为什么像 React 这样的库很有用。它们让你可以用“每次都从头重建 UI”这种思路去编程,但实际上并不会真的这么做:

function FormStatus() {
  let [state, setState] = useState({
    step: 'initial'
  });
 
  function handleSubmit(e) {
    e.preventDefault();
    if (state.step === 'pending') {
      // 不允许重复提交
      return;
    }
    setState({ step: 'pending' });
    submitForm().then(() => {
      setState({ step: 'success' });
    }).catch(error => {
      setState({ step: 'error', error });
    });
  }
 
  let content;
  switch (state.step) {
    case 'pending':
      content = <Spinner />;
      break;
    case 'success':
      content = <SuccessMessage />;
      break;
    case 'error':
      content = (
        <>
          <ErrorMessage error={state.error} />
          <RetryButton onClick={handleSubmit} />
        </>
      );
      break;
  }
 
  return (
    <form onSubmit={handleSubmit}>
      {content}
    </form>
  );
}

代码虽然看起来不一样,但原理是相同的。组件抽象强制了边界,让你确信页面上没有其他代码会干扰它的 DOM 或状态。组件化有助于降低 Bug-O。

实际上,如果你在 React 应用的 DOM 中发现任何值不对,只需要沿着 React 组件树逐层向上查找相关组件的代码即可。无论应用多大,追踪渲染值的 Bug-O 都是 🐞(树高)。

**下次你看到关于 API 的讨论时,不妨想想:在它里面,常见调试任务的 🐞(n) 是多少?**你非常熟悉的那些 API 和原则呢?Redux、CSS、继承——它们都有各自的 Bug-O。

Pay what you like

Edit on GitHub