最近我一直在思考错误处理——不是语法争论或"哪种语言做得更好"的辩论,而是更深层次的问题。是什么让某种方法在架构上优于其他方法?
有一个原则为这些决策提供了客观基础:独立变化原则(PIV)【Principle of Independent Variation】。该原则指出:独立变化的事物应该分离,共同变化的事物应该组合。看似简单,但它能告诉你哪种设计在技术上更优越——尽管具体场景可能仍需权衡。

让我通过将其应用于四种错误处理策略来阐明我的观点。
四种处理方式
返回码(C风格)
int read_file(const char* path, char** content) {
if (!path) return -1;
if (!exists(path)) return -2;
return 0;
}
受检异常
public String readFile(String path) throws IOException, SecurityException {
// 编译器强制你处理这些异常
}
非受检异常(Python、C#、Java RuntimeException)
def read_file(path):
# 可能引发 FileNotFoundError、PermissionError...
# 函数签名不会告知这些信息
Result单子(Rust、Haskell;可通过库在Java、C#、Python中使用)
fn read_file(path: &str) -> Result<String, FileError> {
// 不处理Result就无法访问值
}
变化驱动因素是什么?
PIV首先要问:这段代码可能变化的独立原因有哪些?
对于错误处理,我认为有三类:
- 错误类型演变(出现新的失败模式,旧的被移除)
- 错误处理逻辑变化(不同的恢复策略)
- 业务逻辑变化(与错误无关)
这些变化确实是独立的。添加新错误类型不应涉及业务逻辑。改变网络故障的恢复方式不应影响存在的错误类型。
为什么错误处理策略必须独立变化
这并非空谈。考虑相同的业务操作——"获取用户资料"——及其在不同上下文中处理失败的不同方式:
恢复策略:
- 退避重试 — 瞬时网络故障,再次尝试
- 使用缓存/陈旧数据 — 可接受的降级
- 返回默认值 — 必须继续运行
- 快速失败 — 如果失败则无需继续
可观测性策略:
- 记录日志并继续 — 非关键问题,仅记录
- 通知值班人员 — 需要人工立即关注
- 增加指标 — 为SLO仪表板跟踪
- 追踪关联 — 附加到分布式追踪
传播策略:
- 吸收 — 不让调用方知晓
- 转换 — 转换为领域特定错误
- 原样传播 — 让其向上冒泡
- 熔断 — 停止调用故障服务
在微服务架构中,这点更加有趣。相同的下游故障可能需要:
- 服务A:重试3次,然后返回缓存数据(面向用户,延迟敏感)
- 服务B:立即失败,将消息加入死信队列(异步作业,正确性至关重要)
- 服务C:记录警告,返回部分结果(聚合器,尽力而为)
业务逻辑——"获取用户资料"——是相同的。错误处理根据操作上下文、SLA和各服务的角色而变化。这些关注点因不同原因在不同时间变化。PIV指出:它们必须是可分离的。
四种方法对比
| 变化 | 返回码 | 受检异常 | 非受检异常 | Result |
|---|---|---|---|---|
| 添加错误类型 | 涉及所有调用方 | 更新调用栈上的每个throws子句 | 无影响 | 编译器对不完整匹配发出警告 |
| 移除错误类型 | 静默—常量闲置 | 编译错误(好!) | 无影响 | 编译错误(好!) |
| 更改处理策略 | 分散在各调用方 | 重构try-catch块 | 可集中处理 | 交换一个组合子 |
| 更改业务逻辑 | 与错误检查纠缠 | 埋在try块中 | 清晰—直接更改 | 清晰—直接更改 |
详细分析变化驱动因素
变化驱动因素1:错误类型演变
当你添加新错误类型时会发生什么?
受检异常创建耦合——throws子句具有传染性:
// 你向此方法添加DatabaseException...
void saveUser(User u) throws IOException, DatabaseException
// ...现在这个方法需要更新...
void processRegistration(Form f) throws IOException, DatabaseException
// ...还有这个...
void handleRequest(Request r) throws IOException, DatabaseException
// ...一直波及整个调用栈
我见过团队通过在每层捕获和包装来应对——这完全违背了初衷。或者更糟,到处声明throws Exception。
返回码静默失败。你添加新常量-3,但现有调用方仍只检查-1和-2。新错误作为垃圾数据传播。
非受检异常完全隐藏变化。调用方直到运行时崩溃才知道新异常存在。
Result类型像非受检异常一样传播——?操作符向上传递错误而无需中间层签名更改。但与非受检异常不同,错误在类型中可见。当你添加新变体时,编译器标记每个决策点(你匹配的地方),而不是整个调用链。两全其美:轻量级传播,显式处理。
变化驱动因素2:处理策略变化
这里差异最明显。Result类型提供组合子——在不展开Result的情况下转换它们的小函数。这让你可以在组合时插入不同策略。
相同业务逻辑,不同恢复策略:
// 策略1:快速失败
let user = fetch_user(id)?;
// 策略2:失败时返回缓存数据
let user = fetch_user(id)
.or_else(|_| get_cached_user(id))?;
// 策略3:退避重试
let user = fetch_user(id)
.or_else(|_| { sleep(100); fetch_user(id) })
.or_else(|_| { sleep(500); fetch_user(id) })?;
// 策略4:默认值
let user = fetch_user(id)
.unwrap_or_else(|_| User::anonymous());
相同业务逻辑,不同可观测性:
// 记录日志并继续
let user = fetch_user(id)
.map_err(|e| { log::warn!("获取失败: {e}"); e })?;
// 增加指标
let user = fetch_user(id)
.map_err(|e| { metrics::increment("user_fetch_error"); e })?;
// 通知值班人员(针对关键路径)
let user = fetch_user(id)
.map_err(|e| { pagerduty::alert("用户获取失败"); e })?;
相同业务逻辑,不同传播:
// 吸收错误,返回部分结果
let profile = fetch_user(id).ok()
.map(|u| Profile::from(u))
.unwrap_or(Profile::empty());
// 转换为领域错误
let user = fetch_user(id)
.map_err(|e| DomainError::UserUnavailable(e))?;
// 熔断模式
let user = circuit_breaker.call(|| fetch_user(id))?;
业务逻辑——fetch_user(id)——从不改变。处理策略围绕它组合,而不是与它交织。你可以在不触及核心操作的情况下交换策略。
现在用异常实现相同的四种策略:
// 策略1:快速失败
User user = fetchUser(id); // 抛出,调用方处理
// 策略2:失败时返回缓存数据
User user;
try {
user = fetchUser(id);
} catch (IOException e) {
user = getCachedUser(id);
}
// 策略3:退避重试
User user;
int retries = 0;
while (true) {
try {
user = fetchUser(id);
break;
} catch (IOException e) {
if (++retries >= 3) throw e;
Thread.sleep(100 * retries);
}
}
// 策略4:默认值
User user;
try {
user = fetchUser(id);
} catch (IOException e) {
user = User.anonymous();
}
每种策略都是不同的控制流结构。重试需要while循环。回退需要try-catch。默认值需要带赋值的try-catch。它们不能组合——你需要从头重建。
组合策略更糟(重试然后回退到缓存):
User user;
int retries = 0;
while (true) {
try {
user = fetchUser(id);
break;
} catch (IOException e) {
retries++;
if (retries >= 3) {
try {
user = getCachedUser(id);
break;
} catch (CacheException ce) {
throw new UserFetchException("所有策略都失败", e);
}
}
Thread.sleep(100 * retries);
}
}
与Result比较:fetch_user(id).or_else(retry).or_else(retry).or_else(cache)。fetchUser调用在两者中都没有被触及,但使用异常时它被埋在20行嵌套控制流中。
使用返回码(相同策略):
User* user = NULL;
int result, retries = 0;
while (retries < 3) {
result = fetch_user(id, &user);
if (result == 0) break;
retries++;
sleep_ms(100 * retries);
}
if (result != 0) {
result = get_cached_user(id, &user);
}
if (result != 0) {
log_error("所有策略都失败");
return ERR_USER_FETCH;
}
同样情况:fetch_user未被触及,但埋在条件判断中。每个策略更改都意味着重写围绕它的if检查。如果你忘记某个检查,错误就会作为垃圾数据静默传播。
差异不仅仅是语法。Result策略线性组合——你可以链式调用它们。异常策略层次嵌套——try-catch内部嵌套try-catch。返回码策略条件分散——到处都是if检查。
注意在任何方法中都不变的是:各个操作(fetchUser、getCachedUser)保持不变。问题在于组合方式。使用异常和返回码时,你组合操作的方式与处理它们失败的方式纠缠在一起。想添加第三个回退?需要重构整个代码块。想更改重试次数?编辑定义操作序列的相同代码。
使用Result时,组合和策略是独立的关注点。fetch_user(id).or_else(f).or_else(g)读起来像管道,每个组合子都是独立的。将.or_else(cache)换为.unwrap_or(default)而无需触及其他部分。
变化驱动因素3:业务逻辑变化
当你需要更改核心操作本身时会发生什么——比如fetch_user现在需要一个额外参数,或者你要用fetch_user_v2替换它?
返回码迫使你在错误检查条件语句中导航以找到实际调用。业务逻辑与错误处理纠缠:
// 实际操作在哪里?埋在这里:
while (retries < 3) {
result = fetch_user(id, &user); // <-- 在杂音中找到这个
if (result == 0) break;
retries++;
sleep_ms(100 * retries);
}
受检异常将你的逻辑埋在try块中。低内聚——业务操作与其错误处理交织:
try {
user = fetchUser(id); // <-- 实际工作
break;
} catch (IOException e) {
// 10行恢复逻辑
}
非受检异常和Result类型都保持业务逻辑清晰。操作独立存在:
let user = fetch_user(id)?; // 清晰显示发生了什么
user = fetch_user(id) # 同样清晰
区别在于:使用Result时,你知道错误处理存在于链中的某个地方。使用非受检异常时,你相信某个地方的某人会处理它。
评分卡
每种方法在多大程度上允许你独立变化这三个关注点?
| 变化驱动因素 | 返回码 | 受检异常 | 非受检异常 | 结果 |
|---|---|---|---|---|
| 错误类型演变 | ❌ 静默失败 | ❌ 传染性签名 | ⚠️ 不可见 | ✅ 穷尽匹配 |
| 处理策略变化 | ❌ 重写条件判断 | ❌ 重构try-catch | ⚠️ 可集中但隐式 | ✅ 交换组合子 |
| 业务逻辑变化 | ❌ 与检查纠缠 | ⚠️ 埋在try块中 | ✅ 清晰 | ✅ 清晰 |
应用PIV
Result类型胜出不是因为语法或时尚,而是因为它们尊重变化的实际运作方式。成功和失败是独立的关注点——它们来自不同的利益相关者,在不同的时间线上演变,响应不同的压力。PIV指出:分离它们。
更深刻的见解是PIV基于业务现实。变化驱动因素不是技术抽象——它们是产品需求、合规更新、操作事件、扩展需求。业务不关心你的try-catch嵌套。它关心的是你能在下一次中断前添加熔断器,或者无需两周重构就能交换重试策略。
唯一不变的是变化本身。与这一现实抗争的软件会积累摩擦。拥抱它的软件——通过分离独立变化的事物——保持可塑性。
要自行应用PIV:
-
识别变化驱动因素。这段代码可能变化的独立原因是什么?不是抽象类别——真实的力量。谁要求这些变化?频率如何?在什么时间线上?
-
追踪耦合。对于每个驱动因素,还有什么必须改变?如果触及一个关注点会波及另一个,你就耦合了本应独立变化的事物。
-
检查内聚性。相关代码是否分散?如果共同变化的代码存在于五个不同文件中,你就碎片化了本应组合的内容。
-
比较设计。最小化跨关注点耦合同时保持每个关注点内聚的设计就是PIV推崇的。这不是观点——这是变更成本更低的设计。
【注】本文译自:Error Handling Through the Lens of the Principle of Independent Variation

