什么是防御性编程?
很多开发者第一次听到"防御性编程"时,会以为这是一种悲观主义——假设一切都会出错。但实际上,防御性编程是一种工程纪律:在代码中主动设置防线,让错误尽早暴露,而不是在生产环境里炸响。
Linus Torvalds 说过,好的程序员不是聪明到能写出没有 bug 的代码,而是谨慎到知道自己的代码可能有 bug。
本文整理六个实用的防御性编程习惯,每个都配有代码示例,可以直接应用到你明天的 commit 里。
习惯一:永远不要信任输入
第一条规则最经典,也最容易偷懒跳过。无论是函数参数、API 请求体、配置文件还是数据库查询结果——在验证之前,它们都是不可信的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
# ❌ 假设输入总是正确的
def transfer(user_id: str, amount: float):
db.execute(f"UPDATE account SET balance = balance - {amount} WHERE id = {user_id}")
# ✅ 验证后再用
from decimal import Decimal
def transfer(user_id: str, amount: float):
if not user_id or not user_id.isalnum():
raise ValueError("无效的用户ID")
amt = Decimal(str(amount))
if amt <= 0 or amt > Decimal("100000"):
raise ValueError("转账金额超出允许范围")
db.execute(
"UPDATE account SET balance = balance - %s WHERE id = %s",
(amt, user_id) # 参数化查询,防 SQL 注入
)
|
注意三个防御点:类型转换(float → Decimal 避免浮点精度问题)、范围校验(防止负数转账或天价转账)、参数化查询(防注入)。这不是多疑,这是基本功。
习惯二:Fail Fast,别让错误潜伏
错误发现得越早,修复成本越低。一个 None 在第一层被忽略,到第十层变成 NullPointerException,排查时间从 5 分钟变成 5 小时。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
# ❌ 静默吞掉异常,问题潜伏到下游
def get_user_config(user_id):
try:
return config_service.fetch(user_id)
except Exception:
return None # 调用方拿到 None,不知道是"配置不存在"还是"服务挂了"
# ✅ 快速失败,明确区分
class ConfigNotFound(Exception):
pass
def get_user_config(user_id):
if not user_id:
raise ValueError("user_id 不能为空")
try:
config = config_service.fetch(user_id)
except ConnectionError as e:
raise RuntimeError(f"配置服务不可用: {e}") from e
if config is None:
raise ConfigNotFound(f"用户 {user_id} 无配置记录")
return config
|
关键原则:不要返回 None 来表示错误。用异常或 Result 类型让调用方无法忽略错误。
习惯三:用类型系统当第一道防线
动态类型语言的灵活性是双刃剑。善用类型标注和静态检查工具,能在运行前拦住大量 bug。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
from typing import Literal, NewType
from pydantic import BaseModel, field_validator
# 用 NewType 创建语义类型,防止混用
UserId = NewType("UserId", str)
OrderId = NewType("OrderId", str)
class Order(BaseModel):
id: OrderId
user_id: UserId
status: Literal["pending", "paid", "shipped", "cancelled"]
total: float
@field_validator("total")
@classmethod
def total_must_be_positive(cls, v):
if v < 0:
raise ValueError("订单金额不能为负")
return v
# mypy 会报错:类型不匹配
# process_order(user_id=UserId("u1"), order_id=OrderId("o1")) # 参数顺序搞反了
|
Go 语言在这方面天生更强:
1
2
3
4
5
6
7
8
9
10
11
|
// 用自定义类型防止混用
type UserID string
type OrderID string
func GetOrder(uid UserID, oid OrderID) (*Order, error) {
if uid == "" || oid == "" {
return nil, fmt.Errorf("empty ID: uid=%s, oid=%s", uid, oid)
}
// 编译器保证不会把 UserID 和 OrderID 传反
return repo.Find(oid)
}
|
习惯四:默认不可变,变更需显式
可变状态是 bug 的温床。默认使用不可变数据结构,只在确实需要修改时才创建可变版本。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
from dataclasses import dataclass, field
from typing import List
# ❌ 可变默认值——经典陷阱
@dataclass
class BadCart:
items: List[str] = field(default_factory=list) # 好在 dataclass 强制用 default_factory
# ✅ 用 frozen 冻结,变更返回新实例
from functools import cached_property
@dataclass(frozen=True)
class Cart:
items: tuple = () # 不可变
def add(self, item: str) -> "Cart":
return Cart(items=self.items + (item,))
@cached_property
def total_count(self) -> int:
return len(self.items)
cart = Cart()
cart = cart.add("键盘") # 返回新对象,原对象不变
|
函数式思维的核心好处:不可变数据天然线程安全,不需要锁,不会出现竞态条件。
习惯五:边界条件不是事后想起来的
大多数 bug 不在正常路径里,而在边界上:空列表、最大值、负数、空字符串、时区边界。写代码时主动想三个边界:空、满、极值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
def paginate(items: list, page: int, size: int) -> list:
# 边界:page 为 0、负数、超大值;size 为 0、负数、超大值
if not items:
return []
if page < 1 or size < 1:
raise ValueError("分页参数必须为正整数")
start = (page - 1) * size
if start >= len(items):
return [] # 超出范围返回空列表,不报错
end = min(start + size, len(items))
return items[start:end]
# 测试边界
assert paginate([], 1, 10) == [] # 空列表
assert paginate([1,2,3], 1, 10) == [1,2,3] # size 超过总数
assert paginate([1,2,3], 100, 10) == [] # page 超出范围
assert paginate([1,2,3], 0, 10) or True # page=0 应抛异常
|
把边界测试写在代码旁边,形成"防御 + 验证"的双重保险。
习惯六:给未来的自己留路标
防御性编程不只是写代码,还包括写好注释和日志,让未来的调试不至于盲人摸象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
import logging
logger = logging.getLogger(__name__)
def sync_orders(last_sync: datetime) -> int:
"""从上游同步订单到本地。
Args:
last_sync: 上次同步时间,UTC
Returns:
新增订单数量
Raises:
RuntimeError: 上游 API 连续失败 3 次
"""
logger.info("开始同步订单, since=%s", last_sync)
orders = fetch_from_upstream(last_sync)
if not orders:
logger.info("无新订单, 跳过同步")
return 0
count = 0
for order in orders:
try:
save_order(order)
count += 1
except Exception as e:
# 记录上下文,不只是异常消息
logger.error(
"订单保存失败 order_id=%s status=%s error=%s",
order.get("id"), order.get("status"), e
)
# 决策:跳过还是中断?这里选择跳过并记录
continue
logger.info("同步完成, 新增 %d 条订单", count)
return count
|
日志要记录业务上下文(order_id、status),不只是 “something went wrong”。当凌晨三点被叫醒排查问题时,你会感谢白天写日志的自己。
总结
| 习惯 |
核心原则 |
收益 |
| 不信任输入 |
先验证后使用 |
防注入、防脏数据 |
| Fail Fast |
错误立即暴露 |
缩短排查链路 |
| 类型系统 |
让编译器帮你查 |
运行前拦截 bug |
| 默认不可变 |
变更需显式 |
消除竞态条件 |
| 边界优先 |
测空、满、极值 |
覆盖最易出错场景 |
| 留路标 |
好日志 + 好注释 |
可调试、可维护 |
防御性编程不是给代码加锁加到跑不动。它是一种思维习惯:你写的每一行代码,都是在和未来的 bug 赛跑。 花五分钟加一个校验,可能省下五小时的线上排查。
从明天提交的代码开始,试试在这六个习惯里挑一个实践。不需要一次全用上,但每一个都用上之后,你会发现——代码真的会变得更不容易出错。