Swift方法派发

摘要

  • 很多 Objective-C 开发者转 Swift 后,语法很快就能上手,但对 Swift 方法调用的理解往往还停留在 obj.foo() 这一层。真正决定你是"会写 Swift",还是"理解 Swift"的分水岭,不在语法,而在方法派发模型。Swift 并不是只有一种"消息发送"机制,它同时存在直接派发、vtable 派发、witness table 派发,以及 Objective-C Runtime 消息派发。理解这四种机制,才能真正看懂 Swift 的性能、多态、协议扩展和 Runtime 边界。

正文

做了很多年 Objective-C,再转到 Swift,最容易出现一种"熟悉的错觉"。

你会觉得这门语言并不陌生。

一样有对象,一样有方法,一样可以写页面、网络、组件化,甚至在混编项目里还能继续和 NSObjectSelector、KVO 打交道。表面上看,只是把以前的写法换了一套更现代的语法糖。

但真正写一段时间 Swift 后,你会慢慢发现,很多问题已经不能再用 Objective-C 那套单一 Runtime 视角去解释了。

比如:

  • 为什么 final 经常被说成有性能收益?
  • 为什么协议扩展里的方法"看起来重写了",但调用结果却不符合预期?
  • 为什么同样是多态,类继承和协议抽象的底层机制完全不一样?
  • 为什么有些 Swift 方法能被 KVO、Swizzling 干预,有些却完全不行?
  • 为什么中高级 Swift 面试几乎都会追问 vtablewitness tabledynamic

这些问题表面不同,但底层其实都在指向同一个主题:Swift 方法到底是怎么被调用的。

如果用一句话概括这篇文章的核心结论,那就是:

对一个多年 Objective-C 开发者来说,转 Swift 最重要的认知升级,不是语法升级,而是从"单一消息发送思维"升级到"多派发模型思维"。


一、Objective-C 的经验,为什么在 Swift 里会失效一半

Objective-C 开发者对方法调用这件事,通常有一套非常统一的认知。

当你写:

objc 复制代码
[obj doSomething];

你很自然就会想到背后那条经典链路:

  • 对象持有 isa
  • isa 指向类对象
  • 类对象维护方法列表、缓存和父类链
  • 调用本质是 objc_msgSend(receiver, selector)

这套模型很强,也很优雅,因为它非常动态。

它允许你做很多强 Runtime 能力的事情:

  • Method Swizzling
  • 动态添加方法
  • 消息转发
  • respondsToSelector
  • KVC / KVO
  • 动态替换实现

所以做久了 Objective-C,很容易形成一种潜意识:

方法调用,本质上就是消息发送。

这句话在 Objective-C 语境下没问题,但放到 Swift 里,只对了一部分。

因为 Swift 的目标从来不只是"更现代的 Objective-C"。

Swift 还要同时追求这些东西:

  • 更强的类型安全
  • 更强的编译期优化
  • 更高的执行效率
  • 更好的泛型能力
  • 更弱的 Runtime 依赖
  • 更好的跨平台能力

这就决定了 Swift 不可能把所有方法调用都默认交给 objc_msgSend

Swift 的底层策略更像是:

  • 编译期能确定的,就尽量在编译期确定
  • 必须支持多态时,再引入表派发
  • 必须依赖 Objective-C Runtime 时,才走消息派发

也就是说,Swift 不是没有"消息发送",而是它把"调用目标如何被解析"做了更细的分层。


二、先别急着背概念,先理解"派发"到底在解决什么问题

无论是 Swift 还是 Objective-C,方法调用底层都在解决一个核心问题:

这次调用,最终到底应该跳到哪段函数实现?

当你写下这行代码:

swift 复制代码
obj.foo()

编译器和运行时真正关心的,不是点语法本身,而是这几个问题:

  • obj 的真实类型是什么
  • foo 的实现到底在哪里
  • 能不能在编译期就把目标确定下来
  • 如果不能,是不是要查某张表
  • 如果需要动态能力,是不是要交给 Runtime

所以,"派发"这个词本质上就是:

解析方法实现地址的方式。

理解了这句话,再去看 Swift 的四种派发方式,就会非常清楚:

  • 直接派发:编译期就知道地址
  • vtable 派发:类多态,运行时按虚表查地址
  • witness table 派发:协议多态,运行时按协议实现表查地址
  • objc 消息派发:运行时发消息,由 Runtime 决定最终实现

三、Swift 最重要的默认路径:直接派发

先看最简单的例子:

swift 复制代码
struct Counter {
    func increment() {
        print("increment")
    }
}

let counter = Counter()
counter.increment()

这段代码为什么快?

因为编译器几乎没有歧义。

  • Counter 是值类型
  • 值类型没有继承
  • increment() 不存在 override
  • 目标实现是确定的

既然目标实现在编译期就能确定,那编译器就完全没必要把这个调用拖到运行时再去解析。它可以直接生成对目标函数的调用,甚至继续做更多优化,比如内联。

再看一个类的例子:

swift 复制代码
final class Dog {
    func run() {
        print("run")
    }
}

let dog = Dog()
dog.run()

这里虽然是类,但因为 Dogfinal 修饰,run() 不可能再被子类重写,调用目标依然稳定。

这也是为什么 Swift 里 final 的价值远不只是"不能继承"。

更准确地说,它是在告诉编译器:

  • 这里不需要为多态保留额外动态性
  • 你可以放心做更激进的优化

对一个从 Objective-C 转来的开发者来说,这是第一个非常重要的认知切换:

Swift 里的很多方法调用,根本不是"发消息",而是编译期直接定死。


四、类继承下的多态,靠的是 vtable,不是默认 objc_msgSend

来看经典多态:

swift 复制代码
class Animal {
    func speak() {
        print("animal")
    }
}

class Dog: Animal {
    override func speak() {
        print("dog")
    }
}

let value: Animal = Dog()
value.speak()

这里左边变量类型是 Animal,但真实对象是 Dog

如果编译器只按左边类型去决定调用目标,那结果就会错误地变成 Animal.speak()

所以这里必须支持运行时多态。

Swift 在这类原生类多态场景下,通常依赖的是 vtable,也就是虚函数表。

你可以把它理解成:

类元数据上的一张方法槽位表。

调用过程可以粗略想象成这样:

  1. 拿到对象实例
  2. 找到它的真实类型元数据
  3. 找到对应类的 vtable
  4. 按方法槽位拿到最终实现地址
  5. 再完成调用

子类一旦 override 某个方法,本质上就是把对应槽位替换成自己的实现。

所以 vtable 派发的核心价值是:

  • 支持类继承体系里的动态多态
  • 比 Objective-C 完整消息查找链路更偏静态
  • 性能通常比全面 Runtime 消息派发更可控

这个点很关键,因为很多人会把 Swift 类方法的动态调用直接等同于 objc_msgSend

其实不是。Swift 原生类模型有自己的一套多态分发机制。


五、真正拉开 Swift 和 Objective-C 差距的,是 witness table

如果说 vtable 是类多态的底层支撑,那 witness table 就是协议多态的底层支撑。

先看代码:

swift 复制代码
protocol Runner {
    func run()
}

struct Person: Runner {
    func run() {
        print("person run")
    }
}

func start(_ runner: Runner) {
    runner.run()
}

这里 start(_:) 只知道参数符合 Runner 协议,但并不知道它到底是:

  • Person
  • Robot
  • Cat
  • 还是别的实现类型

也就是说,编译器知道一定存在一个 run(),但它不知道最终应该调用哪个具体实现。

怎么办?

Swift 的做法是:

除了值本身,还额外携带一份"这个类型如何满足该协议"的映射信息。

这份映射信息,就是 witness table

你可以把 witness table 理解成:

某个具体类型对某个协议的实现清单。

它记录了:

  • 这个类型确实遵守该协议
  • 协议要求的方法,对应到这个类型上的哪个函数实现

所以协议调用的底层逻辑不是"沿着类继承树去查",而是:

  • 拿到具体类型信息
  • 拿到该类型针对协议的 witness table
  • 从表里找到目标方法实现
  • 再去调用

这就是 Swift 面向协议编程真正成立的基础。

很多开发者会说,Swift 更推崇协议而不是继承。

这句话如果没有底层理解,很容易流于口号。

只有真正理解了 witness table,你才知道:

Swift 不是"语法上鼓励协议",而是底层真的为协议多态提供了一套完整的运行机制。


六、协议扩展为什么是 Swift 最容易踩坑的地方

看下面这段代码:

swift 复制代码
protocol P {
    func foo()
}

extension P {
    func foo() {
        print("P.foo")
    }

    func bar() {
        print("P.bar")
    }
}

struct S: P {
    func foo() {
        print("S.foo")
    }

    func bar() {
        print("S.bar")
    }
}

let p: P = S()
p.foo()
p.bar()

很多人第一次看到结果都会愣一下:

  • p.foo() 输出 S.foo
  • p.bar() 却输出 P.bar

为什么?

因为:

  • foo() 是协议要求的方法
  • bar() 不是协议要求的方法,它只是协议扩展新增的方法

这两者的派发语义不一样。

foo() 作为协议要求,会通过 witness table 去找到具体类型 S 的实现。

bar() 不属于协议要求,对协议类型变量来说,很多时候是静态绑定到扩展实现,而不是动态转发到 S.bar()

这个细节非常能体现 Swift 和 Objective-C 思维的差异。

Objective-C 开发者更容易默认认为:

只要同名实现存在,运行时应该就能"找到更具体那个"。

但 Swift 不是这么设计的。

Swift 更强调"协议要求"和"扩展附加实现"的边界。

也正因为如此,协议扩展成为了 Swift 最高频、也最值得深挖的派发面试题之一。


七、Swift 什么时候才真正回到 Objective-C 的消息发送世界

Swift 并不是抛弃了 Runtime,而是把它从默认主路径降级成了一种按需启用的能力。

比如下面这段代码:

swift 复制代码
import Foundation

class Student: NSObject {
    @objc dynamic func study() {
        print("study")
    }
}

这里的 @objc dynamic 其实是在告诉编译器:

  • 这个方法要暴露给 Objective-C Runtime
  • 不要再按 Swift 更静态的方式优化
  • 这里要保留 Objective-C 风格的动态派发能力

进入这条路径之后,底层逻辑就回到我们熟悉的那套:

  • receiver
  • selector
  • objc_msgSend
  • cache 查找
  • method list 查找
  • 父类链查找
  • 消息转发

所以在 Swift 里,objc 消息派发常见于这些场景:

  • @objc
  • dynamic
  • NSObject 继承体系
  • KVC / KVO
  • Target-Action
  • Method Swizzling
  • Objective-C Runtime 桥接能力

这时候,一个从 Objective-C 转过来的人会突然"感觉很熟"。

但你要注意一个边界:

这已经不是 Swift 的默认路径,而是 Swift 为了兼容 Runtime 动态能力而打开的一条特殊通道。


八、为什么这件事不只是面试题,而是架构能力分水岭

很多人第一次接触这套知识,是因为面试官问:

  • Swift 方法调用有哪些方式?
  • final 为什么有性能收益?
  • dynamic 做了什么?
  • 协议扩展为什么有坑?

但如果你只把这些当作"八股",就低估它的工程价值了。

理解方法派发,至少会直接影响五件事:

第一,影响你如何做性能优化。

你会知道哪些地方适合用 final,哪些调用更容易被编译器去虚拟化,哪些抽象层会引入额外分发成本。

第二,影响你如何做架构设计。

你会更清楚什么时候用继承,什么时候用协议,什么时候需要 Runtime 动态能力,什么时候应该避免过度动态化。

第三,影响你如何定位问题。

很多"为什么没走到子类实现""为什么协议扩展输出不对""为什么 KVO 不生效"的问题,底层本质都是派发规则没理解清楚。

第四,影响你如何做混编。

在 Objective-C 和 Swift 共存的工程里,如果你不知道一个 Swift 方法到底是不是走 Objective-C Runtime,就很容易在桥接、反射、动态替换这些场景里做错误设计。

第五,影响你如何表达自己的技术深度。

初级开发者会说"Swift 有实例方法和类方法"。

中级开发者会说"Swift 有直接派发和动态派发"。

真正有深度的表达,会把四种派发方式和各自解决的问题讲清楚。


九、从多年 OC 开发者转 Swift,我建议优先补这四个认知

如果你正在从 Objective-C 体系迁移到 Swift,我建议优先补齐这四件事。

第一,改掉"方法调用 = objc_msgSend"的单一路径认知。

Swift 不是只有一种调用模型。

第二,把四种派发方式在脑子里彻底分开。

不要混着记。

  • 直接派发:编译期确定目标
  • vtable:类继承多态
  • witness table:协议多态
  • objc 消息派发:Runtime 动态能力

第三,重点理解协议扩展。

因为它最容易暴露你到底有没有真正理解 Swift。

第四,学会把派发机制映射到工程判断里。

比如:

  • 这里为什么要加 final
  • 这里为什么适合协议抽象
  • 这里为什么必须 @objc dynamic
  • 这里为什么不能指望 Runtime 接管

真正的掌握,从来不是"记住名词",而是你能不能把这些机制变成日常决策能力。


十、结尾:Swift 的难点,从来不是语法,而是运行模型

对多年 Objective-C 开发者来说,Swift 最容易上手,也最容易误判。

因为语法层确实不难。

optionalenumprotocolextension,花一段时间都能写得很熟。

但如果你没有把方法调用背后的派发模型建立起来,你对 Swift 的理解就始终停留在"会写"的阶段,而不是"看懂了它为什么这样运行"。

Objective-C 的魅力,来自统一而强大的 Runtime。

Swift 的魅力,则来自它在性能、类型安全和动态能力之间做出的精细分层。

所以,从 Objective-C 走到 Swift,真正重要的不是把旧语法翻译成新语法,而是完成这次底层思维切换:

从"统一消息发送思维",切换到"静态优化优先、动态能力按需开启的多派发模型思维"。

当你真正理解了 直接派发vtablewitness tableobjc_msgSend 的边界时,你写的就不再只是能跑的 Swift,而是你真正理解了 Swift。


图 1:Swift 方法派发总览

图 2:类继承下的 vtable

flowchart LR A[变量: Animal] --> B[真实对象: Dog] B --> C[Dog 类型元数据] C --> D[Dog vtable] D --> E[speak 对应槽位] E --> F[Dog.speak 实现]

图 3:协议调用下的 witness table

flowchart LR A[变量: Runner] --> B[具体值: Person] B --> C[类型元数据 Person] C --> D[Person 对 Runner 的 witness table] D --> E[run 对应实现] E --> F[Person.run]

图 4:Objective-C 消息派发链路

相关推荐
末代iOS程序员华仔11 小时前
OPC + Flutter + Swift:跨平台应用上架与专业知识点获客实战指南
开发语言·flutter·swift
末代iOS程序员华仔1 天前
iOS上架海外工具类应用合规指南:避免封号与下架风险
flutter·ios·swift
大龄秃头程序员3 天前
Swift Concurrency 取消机制踩坑复盘:为什么很多人会误以为 Task 根本取消不了?
swift
软泡芙6 天前
【IOS】 Swift Package Manager (SPM) 指南
网络·ios·swift
东坡肘子6 天前
不是模型变慢了,是任务变大了 -- 肘子的 Swift 周报 #146
人工智能·swiftui·swift
大龄秃头程序员7 天前
Sendable 不是“能跑就行”:Swift Concurrency 下的实战踩坑与架构落地指南
swift
大熊猫侯佩8 天前
WWDC26 全新 SwiftData 的 .codable 宏选项:它不是泛型专用胶水
数据库·swift·apple
大龄秃头程序员8 天前
Swift 并发实战:从 Task 到 Actor 重入性,我掉进了“逻辑不一致”的坑
swift