Contents

防御性编程的六个习惯:写出不容易出错的代码

什么是防御性编程?

很多开发者第一次听到"防御性编程"时,会以为这是一种悲观主义——假设一切都会出错。但实际上,防御性编程是一种工程纪律:在代码中主动设置防线,让错误尽早暴露,而不是在生产环境里炸响。

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 注入
    )

注意三个防御点:类型转换(floatDecimal 避免浮点精度问题)、范围校验(防止负数转账或天价转账)、参数化查询(防注入)。这不是多疑,这是基本功。

习惯二: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 赛跑。 花五分钟加一个校验,可能省下五小时的线上排查。

从明天提交的代码开始,试试在这六个习惯里挑一个实践。不需要一次全用上,但每一个都用上之后,你会发现——代码真的会变得更不容易出错。