“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