
我在刚接触Objective-C开发圈里习惯简称OC的时候最不适应的不是语法而是它的思维方式。多数教程上来就是怎么建类、怎么写方法、怎么调方法好像OC的面向对象就是把Java那套搬到苹果生态里。但等我真正用OC写了几个项目又去补了一圈运行时源码之后才意识到OC的面向对象底层逻辑和你之前见过的静态语言完全不是一回事。它表面上写着[person eat]这样一句“方法调用”实际执行的是向对象“发送消息”这一个小小的差异牵动了继承、多态、协议、分类等一系列OC特有的设计。这篇文章是“OC面向对象”的上篇我会从消息机制、类与对象、封装继承多态再到分类、协议和属性内存管理把OC面向对象的骨架讲清楚。适合刚接触iOS开发的人也适合那些已经会写OC但只是“照着模板复制”的开发者回头补底层逻辑。1. 从消息机制说起OC面向对象的真正入口1.1 方法调用不是“调用”是“发送消息”很多从C或C转过来的开发者第一次看到OC的方法调用都会有个错觉[person eat]差不多就是person.eat()的换皮写法。这个错觉会让你在后面理解分类、消息转发、Method Swizzling的时候一头雾水。关键差异在于C里调用一个虚函数编译器会帮你查虚函数表然后生成一条直接跳转的指令这个动作在编译期基本就确定了。OC不是这样。编译器看到[person eat]会把它转换成这样一段等价代码objc_msgSend(person, selector(eat));也就是说[person eat]本质上不是“调用person对象的eat函数”而是“给person这个对象发送一条名为eat的消息”。至于person收到消息之后怎么处理是立即执行自己的eat方法还是从父类里找到eat方法甚至干脆不认识这条消息选择转发给别人都是在程序运行那一刻才决定的。这就是OC的核心哲学静态的类结构负责存储方法动态的消息机制负责查找方法。查找的过程发生在运行期而不是编译期。这个机制也解释了为什么OC里可以选择器selector这个概念会这么重要。selector(eat)不是一个函数指针它只是一个方法名的唯一标识。你可以像传递参数一样把一个选择器传给其他对象也可以把选择器保存起来之后再用performSelector:触发。这在静态语言里是做不到的。1.2 消息发送失败之后会发生什么理解了“发消息”这个动作之后自然会问一个问题如果给一个对象发它听不懂的消息会发生什么在很多语言里这就是运行时报错或者编译器直接拒绝。OC比较特殊它给了开发者在崩溃前三次“抢救机会”整套机制叫消息转发。第一次机会是动态方法解析。对象收到未识别消息时运行时先调用resolveInstanceMethod:你可以在这个方法里调class_addMethod动态添加方法让这个对象“临时学会”处理该消息。第二次机会是快速转发。如果动态方法解析也不处理运行时调用forwardingTargetForSelector:你在这里返回另一个对象这条消息就会被转交给那个对象去处理。第三次机会是完整转发。前两步都没接住的话运行时会把消息封装成NSInvocation调用forwardInvocation:你可以在这里修改参数、换方法、甚至随意处理。- (void)forwardInvocation:(NSInvocation *)anInvocation { if ([self.backupObject respondsToSelector:anInvocation.selector]) { [anInvocation invokeWithTarget:self.backupObject]; } else { [super forwardInvocation:anInvocation]; } }我实际调试场景里最常用到的是第三次机会用来做消息代理一个对象不想公开实现某个协议就通过forwardInvocation把消息转给内部持有的另一个对象。这套机制在C或Java里想做会很麻烦但在OC里是语言原生支持的。这也是为什么很多热修复方案和AOP框架都建立在这个特性之上。2. 类与对象先搞清楚new出来的到底是什么2.1 isa指针、类对象与元类OC里每个对象背后都跟着一串隐形的类结构如果你只把“类”理解成“模板”那是不够的。在OC运行时中所有对象都有同一个起点isa指针。一个实例对象的isa指针指向它的类对象类对象是一份真正存在的结构体里面存放着方法列表、属性列表、协议列表这些meta信息。也就是说类在OC里本身也是一个对象它也有自己的isa指针这个指针指向元类MetaClass元类里存放的是类方法的列表。这个结构很多人第一次听会觉得绕但你只需要记住一个结论调用实例方法是沿着对象 - 类对象 - 父类对象这条链去找实现调用类方法是沿着类对象 - 元类 - 父类元类这条链去找实现。NSObject的class方法和-class方法之所以看起来都能返回正确的类就是因为这条链最终通向同一个根类对象。当你写这样一段代码Person *p [[Person alloc] init];p这个变量的值其实是一个指向一块内存的指针这块内存开头是一个isa后面才是你定义的各种成员变量。运行时要判断“这个对象支持哪些方法”并不是遍历整块内存而是直接看isa指向的那个类对象。这也是为什么OC里不能随便把对象指针做类型强转去调用方法方法是否可用是由对象真实的isa决定的不是由你声明的静态类型决定的。2.2 创建对象的完整链路很多人用[[Person alloc] init]用了几百遍却没问过为什么要拆成alloc和init两步。其实这里藏着OC对象生命周期的第一段关键逻辑。alloc负责给对象分配内存并把isa指向正确的类结构。这一步完成之后你已经拥有了一块类型正确的、可用的对象内存但实例变量还没有被初始化成合理的业务值。比如你有一个name属性此时它可能是任意值。init负责让对象进入一个可用状态它会对成员变量做默认设置返回一个“初始化完成”的对象。implementation Person - (instancetype)init { self [super init]; if (self) { _name ; } return self; } end注意这里的self [super init]不是一句可以省略的仪式。OC规定初始化方法必须把父类初始化结果赋值给self因为父类的初始化过程可能返回一个和原指针不同的对象。如果不接住这个返回值后续所有写在if (self)块里的初始化代码实际上是在操作一个已经失效的对象。当你想快速创建一个什么都不用配置的对象时可以直接用[Person new]它底层就是alloc init的封装。但遇到自定义初始化方法时要手动走alloc和initWithXxx:。对很多习惯Java构造函数的开发者来说OC的“两段式创建”初看很繁琐但它在统一资源分配和初始化逻辑方面非常清晰也和后端提到的内存管理机制紧紧绑定在一起。3. 封装、继承、多态在OC里的落地方式3.1 封装除了property还有访问控制OC的封装思路和C、Java相似但表达方式更简洁也更容易被人忽略。先看实例变量。默认情况下写在interface大括号里的成员变量访问权限是protected。这意味着子类可以直接访问这些变量但类外部不能直接用点语法或箭头访问。这已经提供了一层最基本的封装。方法层面的封装和Java差别很大。OC没有private、public这种修饰方法来控制访问它的做法简单粗暴写在.h头文件里的方法外界能看到只写在.m实现文件、而不写在.h里的方法外界看不到自然也调用不了编译期就报错。很多人把它当成“伪私有”其实在工程项目里这样做的效果已经足够好。后来有了类扩展class extension后真正的“私有”方法可以声明在.m文件的扩展里从语义上更明确。// Person.h interface Person : NSObject property (nonatomic, copy) NSString *name; - (void)sayHello; end // Person.m interface Person () - (void)secretMethod; end implementation Person - (void)sayHello { ... } - (void)secretMethod { ... } end从面向对象的设计角度看封装的意义不只是“不让外部改数据”更重要的是把“对象内部状态的变化”隐藏起来。OC里的property自动生成的getter和setter就是一种典型的封装外部通过属性访问内部可以随时调整存储方式和读写逻辑。3.2 继承super并不神秘OC只支持单继承这一点和Java一致类的继承层次非常直观。但你真正写代码时会发现super这个词很容易被误解。super并不是指向父类实例的一个指针。在运行时self始终是当前对象而super只是告诉消息查找机制从父类的方法列表开始找实现。看一个例子implementation Student - (void)study { [super study]; // 调用父类的study方法 }super在编译时会被翻译成objc_msgSendSuper这个函数的第一个参数不是一个对象而是一个objc_super结构体。运行时会从这个结构体里指定的父类开始查找方法但最终执行方法时self指向的还是当前这个Student对象。所以你在子类方法里通过super调用父类实现然后在父类方法里判断self的类型你会发现self依然是Student。这个特性在自定义初始化方法里尤其重要[super init]内部可能调用了某些方法但那些方法里的self仍然是你正在创建的子类对象所以如果父类的初始化方法里调用了子类重写过的方法可能触发子类的实现。这在实践中也算一个经典的初始化陷阱。继承在OC里的用处不只是复用代码更重要的是一层层给对象“贴标签”。一个Student对象可以被当成Person对象来用因为它本质上也是一个Person。3.3 多态一种接口多种行为OC的多态和它的消息机制结合得非常自然。看这个场景NSArrayPerson * *people [student, teacher, worker]; for (Person *p in people) { [p work]; // 每个对象有自己的实现 }数组的静态元素类型是Person但运行时每个元素会根据自己的真实类型找到对应的work实现。Student调用Student的workTeacher调用Teacher的work。这就是动态派发带来的多态。这种多态在OC里实现成本极低因为消息查找本身就是运行期行为。这也是为什么OC可以很自然地让一个数组同时装多种不同的对象再向它们发送相同消息。如果你要去设计一个方法接收一个Person类型的参数那你实际上是在依赖“Person这个类型能响应某些消息”这个约定。多态的核心不是让你少写几个if而是让你能面向稳定的抽象编程把具体行为交给运行时去选择。4. 分类、协议与延展OC比“纯面向对象”多出来的东西4.1 分类在不改原类的情况下加方法OC的面向对象和教科书式面向对象最大的不同是它提供了“分类”这个横向扩展机制。假设你不想继承NSString只是想给所有字符串加一个isAllDigits的判断方法。正统面向对象思路是写一个子类然后再把使用处全部替换成子类类型。这个做法在庞大工程里很不现实。分类可以更优雅地解决// NSStringValidate.h interface NSString (Validate) - (BOOL)isAllDigits; end // NSStringValidate.m implementation NSString (Validate) - (BOOL)isAllDigits { if (self.length 0) return NO; for (NSUInteger i 0; i self.length; i) { unichar c [self characterAtIndex:i]; if (c 0 || c 9) return NO; } return YES; } end这样写完之后全项目任意一个NSString对象都能直接调用isAllDigits不需要改造原有类。但分类有几个限制值得记住。第一分类不能直接添加实例变量。如果你想给类关联额外状态需要用objc_setAssociatedObject。第二分类可以重写原类的方法但我不建议这么做。分类方法会覆盖主类中的同名方法而且多个分类里如果有相同的方法名调用哪个取决于加载顺序这会让行为变得极难预测。分类在实际开发中最大的用途是把一个类按模块拆分。比如某类有两个主题相关的方法可以拆成两个分类文件提交和审查时都更清晰。4.2 协议面向接口编程在OC里的实现协议可以说是OC“面向接口编码”的直接载体。如果你是从Java转过来可以把OC协议理解成“接口”加一点C纯虚类的混合体但它和Java接口有两个明显的不同。第一个不同协议可以声明属性。Java接口里你只能声明方法OC协议可以直接声明property实现协议的对象要自己提供配套的实例变量和访问器。第二个不同协议里的方法不全是required。OC协议方法声明可以标记optional可选方法意味着遵循协议的对象不实现它也不会报错。调用方在使用时通常需要用respondsToSelector:判断一下。实际开发里最常见的协议用法是delegate模式protocol NetworkManagerDelegate NSObject required - (void)networkManager:(NetworkManager *)manager didFinishWithData:(NSData *)data; optional - (void)networkManager:(NetworkManager *)manager didFailWithError:(NSError *)error; end使用协议的关键是定义方不知道也不关心最终是谁来处理这些事件只要对方遵循协议就能建立协作关系。这样就把两个对象间的耦合降到了最小也让单元测试变得更容易。4.3 延展与匿名分类和分类名字很像、但作用完全不同的是“延展”也就是常说的class extension。延展的语法是interface ClassName ()括号里不写名字看起来像一个匿名分类。它最大的特点是可以在.m文件里补充私有属性和私有方法声明而且编译器要求实现文件里必须真正实现这些方法。比如你要在类的实现文件里维护一个内部用的计数器interface NetworkManager () property (nonatomic, assign) NSInteger requestCount; end这样requestCount对于外部完全不可见但类自己可以正常用点语法访问。这和把属性写在.h里再标readonly是两条不同的路线延展更适合只给自己用的状态。延展还能把readonly属性从外部变成内部可写。你在.h里声明property (nonatomic, readonly) NSInteger count;在.m的延展里再写property (nonatomic, readwrite) NSInteger count;外部读内部读写这是OC属性封装中很实用的一招。5. 属性、内存管理与面向对象不得不说的关系5.1 属性修饰符的选择逻辑OC里每个对象属性的定义都会附带一组内存管理语义修饰符。很多新手把strong、weak、assign、copy当成背下来的口诀其实它们的本质都围绕一个问题这个属性在管理对象生命周期时扮演什么角色。修饰符适用场景底层的对象关系strong默认的对象属性持有方式持有对象引用计数加1weak避免循环引用如delegate不持有对象对象释放后自动置nilassign基本数据类型及C结构体不做内存管理只做简单赋值copy需要用不可变副本的属性拷贝一份新对象后持有最让我觉得新手上路时容易选错的是copy和strong的区别。如果一个属性是NSString *你只用strong那么一个外部可变字符串NSMutableString被赋值进来后这个对象还是原来那个可变对象。之后外部改了它的内容你的属性值也跟着变了。而copy会把可变字符串拷贝成不可变字符串切断了外部对这个值的后续影响。这是面向对象设计中“封装状态”很重要的一环属性对外暴露时要尽量减少外界对内部状态的意外影响。不是所有对象属性都要用copy但值类型性质的属性比如字符串、数组、字典最好都用copy或专门的不可变策略。5.2 对象生命周期从alloc到deallocOC面向对象还有一个很独特的维度每个对象都有明确的出生和死亡对象之间有引用计数这个机制直接决定了你在写面向对象代码时怎么组织对象关系。在ARC时代你不需要手动写retain、release但依然要理解“引用计数”这个概念。一个对象被strong指针引用时计数加1strong指针被置nil或重新赋值时计数减1当计数变为0系统调用dealloc对象内存被回收。这里最常见的问题是循环引用。比如两个对象互相持有对方的strong属性它们的引用计数就永远不会降到0两个对象就都泄漏了。解决方法是把其中一边的引用关系改成weak从“相互持有”变成“一个持有、一个旁观”。开发中最典型的例子就是delegateproperty (nonatomic, weak) idSomeDelegate delegate;如果delegate也声明成strong很容易出现两个控制器互相持有退出页面后内存不释放最终在Leaks工具里看到一堆红色。ARC下的对象释放也是一个链式过程。当一个对象的引用计数归零ARC会自动向它的对象属性发送释放操作再调用dealloc。你不需要在dealloc里手动清理属性但如果有NSNotificationCenter观察者或者定时器还是要在这里移除否则对象虽然碎了系统回调可能还指向这块已经回收的内存。6. OC面向对象和JavaScript、Python的本质差异6.1 OC是编译期静态类型运行期动态派发聊完OC自身的面向对象结构我把视角拉高一点和你常见到的其他语言做个对比。OC和Java、C一样有明确的编译期类型检查。你声明一个Person *变量就不能在编译期把它当成Student来用除非做类型转换。这一点保证了代码的健壮性编译器能拦住一大批低级错误。但OC的方法派发又保留了强大的动态性。前面讲的消息发送机制就是一个例子只要对象能响应对应的选择器不管你变量声明的静态类型是什么消息都能正常送达。运行期动态派发和编译期静态类型同时存在这是OC区别于多数主流语言的设计。这种混合风格可以说成“外冷内热”。外层是静态类型给你的安全感内层是运行时的灵活机制。所以OC能安全地写大型工程又能承接一些动态性很强的功能比如热修复和AOP切面编程。6.2 和JavaScript、Python的动态特性对比看到这里你可能想到了JavaScript里的Proxy或者Python里的__getattr__。确实OC的消息转发在行为上很像Python对象拦截未定义属性访问的机制。Python里一个对象可以在运行期通过__getattr__动态响应任何属性的读取OC里一个对象可以通过resolveInstanceMethod:动态添加方法或者通过forwardInvocation:把消息转发出去。两者背后的思想都是“方法不存在这件事本身是可以被接管和处理的”。但差别在于Python几乎把“对象属性查找”完全动态化了你可以在运行期任意给一个对象绑新方法OC虽然允许分类、动态方法解析但每个对象的方法还是正式保存在类对象中运行时查找有固定规则。OC的动态更像“结构化框架里留出的几扇窗”Python的动态则是“整面墙都可以重砌”。6.3 JavaScript与OC的实际协作场景现在iOS开发里确实经常遇到“OC和JavaScript互相调用”的场景尤其是用WKWebView加载H5页面、或者用JavaScriptCore搭桥的时候。从OC调用JavaScript很简单通过WKWebView的evaluateJavaScript:completionHandler:把一段JS代码投进去执行。反过来让JavaScript调用OC方法则复杂一些常见做法是把一个OC对象暴露给JS上下文JavaScript拿到这个对象后调用它的方法相当于在JS里向OC对象发送了一条消息。这套交互在底层也是利用了两边的动态机制。JS本身以动态著称OC又具备消息转发能力两边对接时方法和参数的匹配通常通过字符串形式的名字完成而不是严格的函数指针。写过这种桥接代码的开发者应该能感受到OC面向对象理解得越深处理这类跨语言调用就越不容易糊涂因为你已经习惯了“方法其实是消息”的思维方式。再提一个大家常看到的“Python面向对象”热搜词。Python的面向对象也是动态派发但它的“类属性”和“实例属性”用起来比OC更随意也没有OC这种严格区分“类对象”“元类”的公开概念。OC面向对象给我的感觉是它想把动态语言的灵活性装进静态语言的安全壳里所以它有很多边界清晰、概念成体系的机制需要你一个个理解不偷懒。理解了这些之后再去对比JS或Python的面向对象反而会觉得一通百通。结尾这篇文章从消息机制一路讲到属性内存管理算是把OC面向对象的上半部分骨架搭完了。我个人在带团队时经常告诉新人一句话不要急着背property的修饰符组合也不要一上来就研究Runtime源码先把“方法就是消息”这个念头立住。所有的面向对象特性继承、多态、分类、协议最终都能追溯到“给对象发消息让对象响应”这个核心动作上。理解了它你写出来的OC代码会自然多一分“OC味”而不是Java味或者C味。最后分享一个我实际调程序时的小技巧在Xcode里遇到一个对象调用了不存在的方法崩溃别急着猜类型在异常断点暂停后看一眼调用栈凡是能看到objc_msgSend字样的栈帧说明消息查找已经失败了或正在转发。顺着这条栈往上找基本都能看到你给那个对象发消息的原始位置。把断点变成习惯OC的面向对象会有一种突然变得具象的感觉。