通过覆写方法看 Swift 方法派发
在 Swift 开发中,我们常常利用 extension
和 // MARK:
来划分代码逻辑,比如划分出 “Config”、“Life cycle” 等模块。然而,在使用扩展时却常常会遇到方法覆写的问题,尤其是在定义基类的“框架方法”后,子类无法正确覆写的问题。
本文将通过覆写方法,深入探讨 Swift 中的方法派发机制,并讨论如何在代码设计时规避常见坑点。
本文基于 Swift 5 与 Xcode 16.2 编写。
何为 Swift 方法派发
首先,为不了解 “方法派发” 的读者做个简短的概念解释:
方法派发简单地讲,指的是在调用一个方法时,如何执行该方法的机制。也可以理解为 “去哪里找到该方法的实现” 的机制。
广义上说 Swift 的方法派发分以下两种:
派发类型 | 定义 | 底层机制 |
---|---|---|
静态派发 | 在编译期就确定调用目标 | 直接内联调用,不参与虚函数表 |
动态派发 | 在运行时根据对象的实际类型确定调用目标 | 依赖虚函数表(vtable)或 Objective‑C 消息发送机制 |
方法覆写与方法派发
在很多项目中,我们会在基类中定义一些“框架方法”,由子类覆写这些方法来实现统一的代码结构。比如下面的类型:
open class BaseCollectionViewCell: UICollectionViewCell {
override public init(frame: CGRect) {
super.init(frame: frame)
config()
}
public required init?(coder: NSCoder) {
super.init(coder: coder)
config()
}
}
extension BaseCollectionViewCell {
open func config() { // ⚠️ Non-'@objc' instance method in extensions cannot be overridden; use 'public' instead
addSubviews()
addInitialLayout()
}
open func addSubviews() { } // ⚠️ Non-'@objc' instance method in extensions cannot be overridden; use 'public' instead
open func addInitialLayout() { } // ⚠️ Non-'@objc' instance method in extensions cannot be overridden; use 'public' instead
}
config()
、addSubviews()
和 addInitialLayout()
被我称为 “框架方法”,用来约束子类,统一管理某一类的代码。
写完方法后,Xcode 给了我们三个警告:Non-'@objc' instance method in extensions cannot be overridden; use 'public' instead
。提示我们在 extension
中定义的非 @objc
方法不能被覆写的,所以我们使用的 open
是没有意义的,建议我们换成 public
。
这是为什么呢?
Swift 中的方法覆写
我们先来了解一下 Swift 方法覆写的实现原理:在 Swift 中,方法覆写本质上是依赖 “动态派发机制” 来实现的。
对于一个纯 Swift 类而言,在类的主要声明部分定义的方法会被加入到虚函数表(vtable table)中。这意味着在运行时,根据对象的实际类型可以找到正确的实现,从而支持子类覆写。
在 Swift 中,虚函数表被称为 “witness table”,下文将会使用 ““witness” 这个单词代表 “虚函数表”。
而在 extension
中定义的方法,默认采用的是 “静态派发”,静态派发会在编译时直接绑定调用目标,不会被加入到 witness table 里,也不暴露在 Objective‑C 运行时中。
上一节中提到,子类覆写方法需要依靠虚函数表或 Objective‑C 运行时,所以定义在 extension
中的方法无法被子类继承。
改写
知道错误原因的你可能想到了两种解决方案:
- 可以将方法移动到主要声明部分,也就是通过虚函数表实现方法覆写。
- 或者按照警告的提示,添加
@objc
,借助 Objective-C 运行时实现方法覆写。
我们先采用第一种方案,看看如何实现:
open class BaseCollectionViewCell: UICollectionViewCell {
...
open func config() { ... }
...
}
class SubCollectionViewCell: BaseCollectionViewCell { }
extension SubCollectionViewCell {
override func config() { } // ❌ Non-@objc instance method 'config()' declared in 'BaseCollectionViewCell' cannot be overridden from extension
}
当我们尝试继承 BaseCollectionViewCell
去定义 SubCollectionViewCell
时,问题更严重了,Xcode 报了个错误:
Non-@objc instance method 'config()' declared in 'BaseCollectionViewCell' cannot be overridden from extension
这其实也很好理解,根据虚函数表的原理,如果父类函数被覆写,那么表中只会保存被覆写之后的函数。所以尽管父类的 config()
方法在虚函数表中,但是子类的方法不在,这样在运行时就无法使用子类的实现替代父类的实现。
既然第一条路走不通,那么第二条路呢?比如下面这样:
open class BaseCollectionViewCell: UICollectionViewCell {
...
@objc
open func config() { ... }
...
}
class SubCollectionViewCell: BaseCollectionViewCell { }
extension SubCollectionViewCell {
override func config() { } // ❌ Cannot override a non-dynamic class declaration from an extension
}
很好,报错变了,证明有效,但不完全有效。在这个例子中,我们已经使用 @objc
将父类方法暴露给 Objective-C,但是这实际上并不意味着方法派发方式改为了 “Objective-C 消息派发”。
在 Swift 3 时代存在 @objc
的隐式推断,而在发布 Swift 4 时候,这个隐式推断被取消了。开发者在类的主要声明(请先留意这一点,后文中还会提及)中,需要手动添加 dynamic
关键字,才能告诉编译器,将这个方法改为使用 “Objective-C 消息派发”。如果只添加 @objc
,那么方法仅仅是被暴露给 Objective-C 而已,实际上还是被添加到虚函数表中,最终使用函数表派发。
所以正确的做法是为父类的方法定义添加 dynamic
关键字,就像下面这样:
open class BaseCollectionViewCell: UICollectionViewCell {
...
@objc dynamic
open func config() { ... }
...
}
class SubCollectionViewCell: BaseCollectionViewCell { }
extension SubCollectionViewCell {
override func config() { } // ✅
}
好奇的你可能会注意到一个小细节:为什么子类方法光秃秃的,既不用添加 @objc
,又不用添加 dynamic
?
这是因为尽管 Swift 4 已经取消了 @objc
隐式推断,但是在继承时这一推断被保留了下来,参考:Constructs that (still) infer @objc。所以在这个例子中,为了保持一致性,子类自动继承了父类方法的修饰。
extension
狂人
好了,现在你跟我说你是 extension
的狂热粉丝,你想将父类的框架方法也移到 extension
去定义,于是你将代码改成了这样:
open class BaseCollectionViewCell: UICollectionViewCell { }
extension BaseCollectionViewCell {
@objc // ⚠️ 因为你的粗心,你遗漏了 `dynamic` 关键字。
open func config() { }
}
class SubCollectionViewCell: BaseCollectionViewCell { }
extension SubCollectionViewCell {
override func config() { } // ✅
}
让我们假设你是一个粗心的人(对不起),你在移动代码时丢失了 config()
方法上的 dynamic
关键字。此时你会发现子类的 config()
没有报错!等等,先别着急欢呼 “extension
万岁!”,让我们来看看为什么不需要 dynamic
?
答案是因为:在 extension
中定义方法并使用 @objc
修饰时,将自动转换为 Objective-C 消息派发。所以就不需要显式添加 dynamic
了。
再深入思考一下,“在 extension
中定义方法并使用 @objc
修饰时,将自动转换为 Objective-C 消息派发” 这么设计的原因是什么呢?
我觉得是因为 “extension
中的方法无法被加入虚函数表”。使用 @objc
修饰后肯定无法使用静态派发,所以最终只能使用 Objective-C 消息派发了。
再再刨根问底一下,为什么 “
extension
中的方法无法被加入虚函数表”?根据我目前的知识,这是 Swift 编译器做的硬性规定,并且与 Swift 的设计理念有关。本文就不展开讨论了。
纯 Swift 类
上面的示例代码中,所有的类都是继承自 NSObject
,那么如果是一个纯 Swift 类呢?比如说下面这个类型:
class Foo {
func config() {}
}
class SubFoo: Foo { }
extension SubFoo {
override func config() {} // 🩺 这里会报错吗?报什么错?
}
结论和上面一样。不论是纯 Swift 类,还是一个继承自 NSObject
的类,在本文所讨论的场景中行为是一致的。
有朋友会提到 Swift 代码跨平台时的表现:添加了
@objc
和dynamic
的纯 Swift 类在 Windows 或 Linus 平台上可以正常编译吗?答案肯定是否定的,Objective-C 运行时只存在于 Apple 平台,在上面两个平台中是没有的,所以代码无法通过编译。
总结
通过本文的讨论,相信你已经掌握了如何利用 extension
正确覆写父类的方法。
在实际开发中,虽然 Swift 的方法派发常被视作面试“八股文”般的基础知识,但是理解这些细微差别不仅能帮助你在设计类结构和方法定义时做出更明智的选择,还能在保证高效运行的同时,为后续的灵活扩展打下坚实的基础。
那么留一个课后思考题:
class Foo {
func config() {}
}
class SubFoo: Foo { }
extension SubFoo {
@objc override func config() {} // ❌ Non-@objc instance method 'config()' declared in 'Foo' cannot be overridden from extension
}
上面这个代码中,父类和子类中的 config()
方法分别是哪种派发方式呢?欢迎在评论区留下你的答案。
写在最后
其实本篇文章的场景 “extension
的设计初衷是为类添加新的功能,而非修改已有方法” 是不符合 Swift 语言的设计初衷的。官方文档中是这么说的:
Extensions can add new functionality to a type, but they can’t override existing functionality.
所以你可能会在看到文章开头时就说:“根本就不应该在 extension
中覆写方法!”。这也确实,Apple 并不推荐我们借助 ObjC 的方式在 extension
中覆写方法。
但是既然 Apple 允许开发者借助 ObjC 运行时的特性来实现这一功能,那么我们就姑且允许它存在吧,相信你有能力把握一个度,什么时候可以用这个功能,什么时候应尽量避免。