进行 iOS 开发时,总会遇到需要不断为图形或节点等多种类型的对象添加操作的情况。
你通常会用 if let、as?逐个检查类型;每增加一种类型,就要再次修改分支逻辑。
本文通过示例整理 Swift Visitor 模式,以及其核心双重分派究竟何时有必要。
Swift 选择方法时,只会查看接收者(接收消息的对象)的动态类型。
当“是哪种图形”和“是哪种操作”两个维度同时变化时,单次分派就不够了。双重分派通过两次调用,在运行时确定两个类型。
Swift 的方法分派为什么只靠一次还不够?
在 Swift 中调用 shape.draw() 时,会执行符合 shape实际类型的 draw()。
这就是单次分派:只根据接收者类型 ****选择方法。
问题会在操作有多个时出现。
图形包括圆形、矩形和三角形;操作包括绘制、计算面积和导出 JSON。两个维度相乘后,组合会迅速增加。
所以经常会写出这样的代码。
// 每增加一种图形操作,代码就会不断变长 if-else
func export(_ shape: Shape) -> String {
if let c = shape as? Circle { return drawCircle(c) }
if let r = shape as? Rect { return drawRect(r) }
return "" // 每次新增图形,都要再次修改这里
}
如果新增一个图形,就得打开这个函数,再插入一个分支。
有五个操作,就相当于有五个这样的函数。
什么时候需要双重分派?
我的判断标准很简单。
当方法的行为同时依赖两个类型时,就是需要双重分派的时候。
前面的例子正是如此:要执行的代码同时取决于图形类型和操作类型。
Swift 的默认分派只查看接收者类型,因此另一个维度只能通过 as?这类类型检查手动处理。
这种手动分支就是代码坏味道。
双重分派连第二个类型的决定也交给语言的重载机制,开发者无需再手动检查类型。
反过来说,如果只有一个操作,或者类型不会继续增加,就没必要使用这个模式。
如何实现 Swift Visitor 模式?
关键是把调用拆成两次,所以才叫双重分派。
protocol Shape { func accept(_ v: Visitor) -> String }
struct Circle: Shape { func accept(_ v: Visitor) -> String { v.visit(self) } }
struct Rect: Shape { func accept(_ v: Visitor) -> String { v.visit(self) } }
protocol Visitor {
func visit(_ c: Circle) -> String // 图形类型在这里确定
func visit(_ r: Rect) -> String // 操作通过重载确定
}
我们来跟着流程看一遍。
第一次调用 shape.accept(v)会确定 图形的实际类型。运行时会选择调用 Circle的 accept,还是 Rect的版本。
第二次调用 v.visit(self)时,self的静态类型已经确定为 Circle或Rect。因此可以正确选择重载的 visit。
两次调用衔接起来,就能准确确定两个类型。
想添加新操作时,只需创建一个采用 Visitor 的新结构体即可。现有图形代码完全不用修改。
Visitor 模式要不要用?优缺点对比
没有完美的模式。下面看看它的取舍。
| 分类 | 类型分支(as? / switch) | Visitor 模式 |
|---|---|---|
| 添加操作 | 修改各处的分支 | 添加一个新的 Visitor |
| 添加类型 | 添加一行分支 | 需要修改所有 Visitor |
| 代码可读性 | 分支增多后变复杂 | 按操作清晰分离 |
| 初始编写成本 | 低 | 相对较高 |
如表所示,Visitor 模式在操作经常增加、类型保持稳定时最能发挥作用。
相反,如果类型(图形)持续增加,反而会更费事,因为所有 Visitor 都要修改。
常见问题(Q&A)
Q. Swift 有泛型,但仍然需要 Visitor 吗?
泛型在编译时确定类型时很强大。但当实际类型在运行时混杂在[Shape]数组中时,双重分派仍然有用。
Q. 用 enum 和 switch 也可以吧?
可以。如果类型固定,enum + switch 反而更简洁。操作不断增加时,Visitor 更合适。
遇到同时依赖两个类型的行为时,就是该想起双重分派和 Swift Visitor 模式的时候。
如果现在有 as?分支不断增加的代码,可以参考今天的示例尝试重构,结构会更易维护 🙂

