在多模块架构中,Typed Error 是一把双刃剑。它提供了极强的类型安全性,但由于它是“强契约”,一旦你向作为 Typed Error 的枚举中添加一个新成员(Case),所有依赖该模块的代码都会因为 switch 语句不再穷举(Non-exhaustive)而编译失败。
要在保证向后兼容的同时利用 Typed Error,可以采用以下防御式设计策略:
1. 使用 @frozen 枚举(仅限 Library Evolution 模式)
如果你在构建一个开启了 Library Evolution 的二进制框架(如 XCFramework),Swift 要求明确区分枚举是否会改变。
@frozen:承诺永远不会增加、删除或更改 Case。编译器会进行极限优化。- 非冻结枚举(默认) :如果你不加
@frozen,依赖方在switch时必须处理@unknown default。
Swift
@frozen public enum AuthError: Error {
case invalidPassword
case userNotFound
// 未来无法增加新 Case,否则破坏二进制兼容性
}
2. “预留槽位”与包装模式 (The Wrapper Pattern)
这是最通用的做法。不要将具体的业务细节全部定义为枚举成员,而是定义一个通用的错误分类,并利用关联值携带具体信息。
Swift
public enum ModuleError: Error {
case serviceUnavailable
case validationFailed(reason: String) // 使用 String 或结构体,而不是枚举
case other(Error) // 预留捕获所有未知情况的槽位
}
- 兼容性优势:当你增加一种新的验证失败逻辑时,只需改变
reason字符串,而不需要改变ModuleError的定义,依赖方的switch逻辑不会被破坏。
3. 错误分层:从特定到通用
当一个模块被广泛依赖时,不要在 Public 接口中抛出最底层的特定错误,而是抛出该模块的抽象根错误。
-
内部层:使用极其详尽的
InternalDetailError。 -
公共层:将内部错误映射为
PublicModuleError。- 即使内部增加了一百种报错原因,只要它们都归属于“网络异常”,公共层的枚举就无需变动。
4. 利用 Protocol 隐藏具体类型
这是最解耦的方案。虽然函数签名仍然使用 throws(any ModuleErrorProtocol),但通过协议定义行为。
Swift
public protocol NetworkErrorProtocol: Error {
var isRetryable: Bool { get }
}
// 模块内部可以随时增加实现该协议的新结构体,而不会破坏 API
public func fetchData() throws(any NetworkErrorProtocol) { ... }
5. 结构体替代枚举 (Struct-based Errors)
枚举之所以容易破坏兼容性,是因为它的“穷举性”。如果改用结构体模拟错误,则具有极佳的扩展性。
Swift
public struct TransferError: Error, Equatable {
public let code: Code
public let message: String
public struct Code: Equatable {
public static let insufficientFunds = Code(rawValue: 1)
public static let invalidAccount = Code(rawValue: 2)
private let rawValue: Int
}
}
- 兼容性优势:增加一个新的
static let常量不会导致调用方的代码编译失败(因为调用方通常使用if error.code == .insufficientFunds而不是switch)。
总结:防御式兼容性选择表
| 场景 | 推荐方案 | 兼容性级别 | 缺点 |
|---|---|---|---|
| 底层核心库 | @frozen 枚举 | 极高(不可变) | 无法增加新错误 |
| 业务逻辑模块 | 包装枚举 (关联值) | 高 | 失去部分类型细分优势 |
| 插件式架构 | 基于 Protocol 的错误 | 极高 | 无法直接进行 switch 匹配 |
| 快速迭代模块 | 结构体 (Static Constants) | 高 | 无法强制调用方处理所有情况 |