让高级工程师的工作更值得信赖的九种代码层面习惯
探讨了九种具体的编码实践——从保护性代码到严格的数据建模——这些做法能让代码在高压环境下更具弹性、更易阅读且更便于调试。
很长一段时间里,人们似乎认为团队中经验最丰富的工程师与其他人之间的差距在于纯粹的知识储备——某种鲜为人知的框架技巧、隐藏的API,或是还没人发现的捷径。
通过结对编程、代码审查以及故障处理会议近距离观察经验丰富的工程师,会发现他们并非一定更聪明。他们只是编写出不易出现意外故障的代码,这样一旦出现问题也更容易修复。
有九种特定的习惯出现频率极高,值得我们刻意养成。
1. 优先使用防护条款而非灾难金字塔结构
一个常见的初期错误是将验证逻辑层层嵌套,直到形成类似楼梯状的缩进结构:
function processWithdrawal(account, amount) {
if (account.isActive) {
if (amount > 0) {
if (account.balance >= amount) {
return debit(account, amount);
}
}
}
throw new Error("Withdrawal failed");
}
这种方法虽然可行,但超过第三个条件后就难以阅读了,而且实际的提款逻辑被嵌在三层深度之中。更成熟的处理方式是反过来:立即拒绝无效情况,然后将真正的逻辑放在最顶层、不缩进的形式下执行:
function processWithdrawal(account, amount) {
if (!account.isActive) throw new AccountInactiveError();
if (amount <= 0) throw new InvalidAmountError();
if (account.balance < amount) throw new InsufficientFundsError();
return debit(account, amount);
}
这里并没有什么高明的技巧。它只是在压力之下仍保持可读性,而这正是最需要的时候——在发生故障时、凌晨一点、半睡半醒之际,试图弄清楚是五个嵌套条件中的哪一个在误导你。
2. 以业务逻辑命名,而非数据类型
如果仅根据变量的形状而非含义来命名——比如 data、res、obj、list——在单个文件中倒也还能看懂。但一旦代码库中有四十个文件,每个文件都给 data 定义不同的含义,那就变得极其混乱。
data = fetch(order_id)
if data["status"] == "done":
process(data)
与之相比:
order = fetch_order(order_id)
if order.is_fulfilled:
archive_order(order)
第二种命名方式能明确说明该对象代表什么以及哪些条件才是关键,无需再追溯其来源函数。虽然多花了几个字符,但却能让后续调试此代码的人不必在三个无关的文件中反复查找。
3. 代码与外部世界之间的单一接口
第三方 API 是不可靠的信息来源。字段名称会更改,嵌套结构也会变化,可选字段有时会变成必填项,反之亦然。如果让原始的 API 响应直接进入业务逻辑层,那么每一次变化都会变成在代码库中寻找答案的麻烦事。
// scattered everywhere
const price = apiResponse.line_items[0].unit_price_cents / 100;
更好的方法是就在数据边界处对响应进行一次精确转换:
function toLineItem(raw) {
return {
label: raw.description,
priceInDollars: raw.unit_price_cents / 100,
};
}
如果供应商后来将 unit_price_cents 更名为 price,只需修改一个函数即可。后续的所有代码都不会受到影响,也不会察觉到任何差异。
4. 不会说谎的数据模型
由十几个可选字段构成的类型,其实已经不再试图准确反映现实了:
type Ticket = {
id?: string;
assignee?: string;
resolvedAt?: Date;
resolution?: string;
};
这种结构允许你创建一个没有解决方案的“已解决”工单,或是一个没有负责人的“已分配”工单——这些本应不可能出现的情况却能正常编译。根据实际状态对类型进行区分,就能避免这类错误:
type OpenTicket = { id: string; assignee?: string };
type ResolvedTicket = { id: string; assignee: string; resolvedAt: Date; resolution: string };
现在,用于发送解决方案汇总邮件的函数可以明确要求传入ResolvedTicket参数,编译器会确保不会有任何未完成的状态传递给该函数。
5. 将问题与命令分离
当业务规则和副作用混在一起时,测试就会变得极其困难——而一旦测试变得困难,这些规则就根本不会再被测试了:
def promote_employee(employee_id):
emp = get_employee(employee_id)
if emp.tenure_months < 12:
raise Error("Not eligible yet")
if emp.current_rating < 3:
raise Error("Rating too low")
give_raise(emp)
notify_hr(emp)
log_promotion(emp)
将资格检查提取为独立的函数后,就可以使用普通对象来测试该规则,而无需依赖数据库或邮件服务:
def promotion_eligibility(emp):
if emp.tenure_months < 12:
return Ineligible("Not enough tenure")
if emp.current_rating < 3:
return Ineligible("Rating too low")
return Eligible()
一旦验证规则的成本变得很低,人们就会真正去执行它,这样当有人调整任职期限要求时,各种边缘情况就不会在数月后仍被忽视。
6. 注释应解释原因,而非内容
仅仅重复代码已有内容的注释只会增加冗余:
// increment the counter
counter++;
能够体现决策背后逻辑的注释才是值得保留的:
// Retry once — the vendor's webhook occasionally arrives before
// the payment record finishes committing on their end.
retryOnce(processWebhook, payload);
经验丰富的工程师写的注释通常明显少于新手——并非出于懒惰,而是因为他们已经意识到大多数注释的存在是为了弥补那些无法自我解释的代码。那些保留下来的注释,都是用来传达代码本身无法表达的信息:设计思路、权衡考量,或是对那些乍看之下不明显的问题的提醒。
7. 能指引有用信息的错误
像"无效输入"这样的错误信息对后续的开发者毫无帮助。哪里无效?是哪个输入?有用的错误信息应包含足够详细的说明,以便他人能够据此采取行动:
{
"code": "INVALID_DATE_RANGE",
"message": "End date must be after start date.",
"field": "endDate"
}
常见的情况是,前端代码会通过检查错误信息来决定显示何种提示,比如 if (err.message.includes("date"))。这种设计本质上就存在缺陷——一旦后端修改了错误信息的表述,UI逻辑就会立即失效。代码的存在是为了让机器能够根据其进行分支判断,而消息的存在则是为了让人类能够阅读。将两者分开处理,就能避免其中一方不得不勉强承担另一方的功能。
8. 一个拉取请求,一个想法
那些被标记为“修复计费相关问题”、涉及数十个文件且包含六项无关更改的拉取请求,几乎根本无法被认真审查。负责审查的人要么不加仔细检查就直接通过,要么要花费一小时时间去弄清楚是哪项更改导致了何种效果。
这种有条不紊的做法看起来可能过于谨慎:在第一个拉取请求中重命名字段,第二个拉取请求中引入新的验证机制,第三个拉取请求中将其集成到流程中。这样操作当下似乎效率较低,但实际上审查起来要快得多;而且当生产环境出现问题时,git log能立即给出答案,无需费力地查看长达400行的差异对比。
9. 将初稿视为草稿
最后一个习惯与代码本身关系不大,更多关乎自负心理。职业起步阶段的开发者常常把能运行的第一个版本当作最终产品——既然它能运行,那就直接上线。而经验更丰富的工程师在编写第一个版本时,就已经考虑到会在其接近生产环境之前以批判的眼光重新审视它。
在第二次审查时,嵌套的条件语句会被简化为防护性条款,含义不清的名称会被重新命名,那些看似“不可能”出现的状态也会在客户遇到之前就被发现。这只是一个简单的习惯——停下来,重新阅读,思考如果没有上下文背景的人是否会感到困惑——但正是这个习惯让其他八个习惯能够在实践中真正落实。
共同的核心
所有这些做法背后其实都遵循着同一个原则:把那些否则会留在他人脑海中的复杂问题,立即以可见的形式呈现出来——通过名称、边界、类型或简短而具体的差异来体现。这并不需要非凡的技能,只需要始终牢记:下一个阅读这段代码的人应该有真正理解它的机会。
相关阅读
- 十种会悄悄损害代码库的常见 JavaScript 习惯 — 阐述了从松散相等比较到状态修改等十种常见的 JavaScript 和 TypeScript 潜在问题,并展示了替代每种问题的更安全编程方式。
- 会悄悄导致代码出错的常见 JavaScript 和 TypeScript 潜在问题 — 解释了从 NaN 比较到异步定时处理以及类型强制转换等那些看似正确实则会引发错误的微妙问题。