一、从一道面试题说起:方法调用和函数调用到底差在哪
做iOS开发的朋友大概率都经历过这样一个场景:面试官笑眯眯地问"OC的方法调用本质上是什么?",然后你背出"消息发送"四个字,他再追问"消息发送具体是怎么走的?"空气就安静了。我当年也是从这个地方开始真正啃起Objective-C的运行时机制,后来在工作中调试KVO、处理performSelector崩溃、实现AOP埋点,才发现这个东西不是面试用的死知识,而是实实在在影响你写代码姿势的底层逻辑。
先说一个最常见的对比:C语言的函数调用和OC的方法调用,编译器处理方式完全不同。C语言里写func(10, 20),编译阶段就会确定调用哪个函数地址,这叫静态绑定,也叫早绑定。整个过程在编译期就锁死了,运行时不参与任何决策。OC里写[obj doSomething],编译器会把它改写成objc_msgSend(obj, @selector(doSomething))这个函数调用,真正的查找和跳转发生在运行时。这就是动态绑定,也叫晚绑定。
这个差别带来的影响非常深远。静态绑定的函数调用,性能上几乎零开销,CPU直接跳到固定地址执行。而OC的方法调用,每次都要经过复杂的查找流程,哪怕有缓存机制,理论上也比C函数调用慢一个量级。但是这种"慢"换来了极致的灵活性:方法可以在运行时动态添加、替换、转发,目标对象甚至可以临时决定把消息转给别人处理。这正是C语言给不了你的能力。
很多新手刚接触OC时会困惑:[obj method]这行代码,为什么反汇编以后变成了objc_msgSend?为什么给nil发送消息不崩溃,而是返回0或者nil?这些问题的答案全都藏在这套动态机制里。把方法调用的本质吃透,等于拿到了理解整个runtime体系的钥匙,后面再看KVO的实现原理、block的底层结构、Category为什么能"覆盖"方法,都会顺畅很多。
这篇文章不讲那种浅尝辄止的科普,把从objc_msgSend入口到方法查找、缓存、动态决议、消息转发的完整链路拆开揉碎,用代码验证每一步,最后聊几个日常开发中和这套机制强相关的实战场景。适合对OC有一定基础、想深入理解runtime的读者,也适合打算系统复习iOS底层知识的面试冲刺者。
二、objc_msgSend是总入口:编译器把方法调用翻译成了什么
写OC代码时,你用的方法是中括号语法[obj methodName]。但在编译阶段,LLVM会将这段代码翻译成一条C函数调用:
c复制objc_msgSend(obj, @selector(methodName))
如果你调用的方法带参数,比如[obj eatWithFood:rice count:2],翻译结果大概是:
c复制objc_msgSend(obj, @selector(eatWithFood:count:), rice, 2)
注意,objc_msgSend是可变参数函数,消息接收者、方法选择器、后续参数全都原封不动地传进去。这也就是说,你在代码里写得再优美的方法调用,到了汇编层次就是一个普通的C函数调用,只不过这个函数恰好是runtime做消息派发的入口。
2.1 为什么需要对空nil发送消息不崩溃
OC里面有个经典说法:"向nil对象发送消息不会崩溃"。日常开发中大量代码依赖这个特性,你可能已经习惯了,但它背后的逻辑其实很朴素。看objc_msgSend的汇编实现,第一步就是判断接收者是否为nil。如果是nil,直接返回一个空结果(整型返回0,对象类型返回nil,结构体返回全零),后续查找流程根本不会触发。
这个设计在底层实现上很优雅,也解释了为什么OC允许你写这种看起来有隐患的代码:
objc复制Person *p = nil;
[p walk]; // 不会崩溃,只是什么都不发生
NSString *s = [p getName]; // s为nil
对比C++或者Java,指针为null时调用成员函数大概率直接段错误或者抛出空指针异常。OC用"消息接收者可以不响应"的思路,从语言层面把空指针调用的风险降到了最低。不过要注意,消息是发出去了,但没人处理,如果你写的是[p walk]期望有个副作用执行,这段代码会静默失败,排查时容易让你找半天。这也是很多团队禁用"向nil发送消息"依赖的原因之一。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2.2 方法选择器SEL到底是什么
聊objc_msgSend之前还得先明确一个概念:SEL方法选择器。每次写@selector(walk),编译器会把这个方法名注册到一个全局字符串表里,生成一个唯一的SEL结构体指针。你可以把SEL理解成方法名的哈希化代表,它只关心方法叫什么,不关心实现在哪里。
@selector(walk)和@selector(walk:)是两个完全不同的SEL,因为冒号也是方法名的一部分,代表参数。这也是为什么OC方法名要把参数名也拼进去:initWithFrame:和initWithName:是不同方法,编译器靠SEL区分,而这个SEL是基于完整方法名生成的字符串。底层实现里,SEL本质是指向objc_selector结构体的指针,但在日常调试中,你可以简单把它当作一个方法名的唯一标识。
2.3 汇编实现的快速路径:第一道查缓存
objc_msgSend并不是一个普通的C函数,在苹果的runtime源码里,它的大部分逻辑是用汇编实现的,尤其是快速路径部分。为什么用汇编?因为方法调用是OC世界最高频的操作,每一个[obj method]都会经过这里,必须压榨出极致性能。
快速路径的核心逻辑如下:
- 检查接收者是否为nil,是则返回零值。
- 取出接收者的isa指针,找到所属类(或者元类)。
- 从类的缓存中查找方法对应的IMP。
- 如果找到IMP,直接跳转执行。
- 如果没有找到,跳转到C函数
lookUpImpOrForward走慢速查找流程。
也就是说,方法调用并不是每次都遍历方法列表,而是先查缓存。这个缓存在后面专门开一章讲。总之从汇编代码的优化力度就能看出,苹果在消息发送这条链路上下足了功夫。
三、方法查找的完整路径:从快速查找到慢速查找
快速路径找不到IMP时,runtime会进入慢速查找流程,由lookUpImpOrForward这个C函数处理。这里做的事情比快速路径复杂得多,涉及类继承链、方法列表、动态决
