转载

Objective-C 与 Runtime:为什么是这样?

Objective-C 与 Runtime:为什么是这样?

笔者非常高兴能为Objective-C写写自己的理解和总结,不仅仅因为是笔者是Objective-C多年的重度开发者,更是因为这是一门 有独特想法的,有创造性的,有优美语法的,有历史地位的编程语言 。如果说对本文有什么预期的话,笔者希望能把一些类似“为什么是这样”的问题说清楚。

Objective-C发明于上世纪80年代,Objective-C的作者——Brad Cox和Tom Love,在接触到SmallTalk语言之后,一方面受到SmallTalk的启发,另一方面也是看好C语言有着巨大影响力和广阔前景,因此选择在C语言的基础上 引入SmallTalk语言面向对象和消息派发的概念 。最初的版本以C语言的扩展的形式实现的,在C编译器中编写支持Objective-C的预处理模块,预处理会先将Objective-C语法代码转化为C代码,再继续C代码的编译过程。1988年,以企业为目标客户的NeXT公司购买Objective-C的使用授权,接着扩展著名开源编译器GCC,使其支持Objective-C,并且开发了AppKit和FoundationKit等基础库,Objective-C成为了NeXTSTEP系统(工作站)上“标准”的应用程序开发语言。1996年,Apple公司收购了NeXT公司,NeXTSTEP/OPENSTEP系统成为 Apple新一代操作系统OS X的研发基础 。 2005年,Apple引入了Chris Lattner以及他的LLVM技术团队,Objective-C新特性和编译优化第一次得到高水平编译器最高优先级的支持,先从后端的代码优化和生成开始,逐步扩展到前端的语法解析(Clang)。如今(2015),Objective-C已经 拥有GCC之外更为适合更为优异的编译器套装选择——LLVM编译器 ,LLVM包括完整的前后端模块,最新版本6.1(2015)。

Objective-C是面向对象的,这是Objective-C最基本的的概念。关于面向对象, 把一定的算法(函数)和数据(变量)因某种内在的联系绑定在一起,形成最基本的程序结构单元 ,这些结构单元即是经常谈及的对象,加上抽象二字,我们称呼它为抽象对象,术语简称类;通过对变量的赋值(笔者认为不仅是变量,逻辑运算如闭包也是可以用于赋值)则会构成实体对象,术语简称对象(Objective-C一般也称作实例)。对象和对象之间不是完全独立的,通过巧妙的方式,它们之间能建立紧密的联系,比如继承、派生,对事物的抽象以及对代码的复用有着微妙而重大的价值。Brad Cox和Tom Lov出版的第一本正式Objective-C著作,书名即为《Object-Oriented Programming, An Evolutionary Approach》。那么,为什么要对象,为什么要面向对象?这是个好问题,观察人类普遍的思维,我们理解这个世界使用最多的概念就是物体,我们擅长把感知到的一切抽象为一个个的物体,通过了解物体的构成,以及物体之间的作用关系,实现对这个世界的认知和作用的目的。这一直是非常奏效的! 面向对象就是把人类的思维的天赋和积累的思想财富应用于编程 ,这样,程序对于增强生产能力/提高生活品质的效率和能力方面会大大提高。

Objective-C 与 Runtime:为什么是这样?

/* 上图为FoundationKit中支持的集合对象——(不可变)数组,继承于根类NSObject,支持实现NSCopying在内的一系列协议(接口),count代表着有一个只读变量,- (id)objectAtIndex:(NSUInteger)index等表示数组支持的可供使用的方法(函数) */

消息派发是Objective-C函数(Objective-C实际称方法)调用的模式,前文亦有提及,概念继承于Smalltalk。Objective-C的对象相互调用函数,被看做是向目标对象传递消息,消息的发送者称作sender,消息的接收者称作receiver,消息中间传递的字符串称作selector(选择子)。

Objective-C 与 Runtime:为什么是这样?

/* 上图的代码表示至少有两个明显receiver,self.view为其中一个消息接收者,传递的消息(字符串/选择子)为 “setBackgroundColor:“,UIColor表示一个类,类也是可以作为消息的接收者,字符串/选择子为 “yellowColor” */

消息的处理就是需要先确定实际执行的方法然后跳转过去并执行,我们理解为这是对该消息的回应,编译期间,单从一句”派发消息”的语法是无法确定实际执行的结果。 只有在程序运行期间,实际执行的结果才能得到确定 。这种在运行期间才确定实际执行的方法,Objective-C称为动态绑定。消息派发这种工作机制明显区别另一著名面向对象编程语言——C++。C++调用对象的函数, 函数与对象之间的关系,在编译期间就必须严格确定 ,如果car里面没有定义函数名为fly的函数,编译器不会通过,而是会报错。Objective-C如果向car发送字符串为”fly”的selector,即使car没有实现fly方法,编译器依然能够通过,但是运行期间则会因为获取不到实际执行的方法而抛出异常。这也就是说,消息派发的设计使得编译期间Objective-C非常包容对象所属的类。如上述,相同对象有相同的定义,称为类,类本身还可以看作对象——“类”对象,可以对“类”对象进行“类”的定义,比如比较运算,哈希,描述,类名等, 总之一切皆为对象 。C++里面我们可以基于称之为模板的方式实现对“类”的自定义,Objective-C通过统一基类比如NSObject(不仅仅只有NSObject,还可以是各类根协议)方式对所有类新增定义。你可以向任何包括空指针nil在内的对象发你想发的消息。消息派发的机制使得在不重新编译的情况下, 在运行期间,干预或者说hook原来的target(方法、变量等)变得更易于实现,更有实际应用价值 。这个是需要依赖于消息派发和动态绑定的实现机制——Runtime,但是Runtime并不仅仅为消息派发和动态绑定而work,它也是Objective-C面向对象、内存模型等特性的实现者。

在正式介绍Runtime之前,我们先继续介绍Objective-C的另外一个重要概念,笔者要说是Objective-C内存管理模型,程序运行时,创建一个对象总是要占用内存的,而内存总大小总归有限, 所以当一个对象不再被需要时,应当及时回收它所占用的内存资源用于新的对象, Objective-C的内存管理原理,简单说就是“引用计数”机制。如果有模块需要引用一个对象,引用时会让对象统计用的引用计数值加1,并记录在对象的结构信息当中,当模块不再需要该对象的时候则减1,而当该对象的引用计数值为0时,就可以认为该对象不再被需要,及时销毁释放内存(回收资源)。 Objective-C对象的内存空间仅分配在“堆空间”(heap space)中,肯定是不会分配在“栈”(stack)上。 我们知道,“栈”的占用和回收是有严格的数据操作规则,简称“先入后出”。函数执行时,传入的变量(当然包括对象变量)会按照确定的序列规则自动压入“栈”(占有内存资源),函数执行结束时,这些变量又会按照相反的序列规则自动弹出(释放内存资源)。因此,我们可以看出,“栈”其实是无法实施“引用计数”机制的,Objective-C否定使用“栈”存储对象的设想。在语法上,Objective-C也无法像C++那样直接声明并创建一个对象变量,更无法直接操作该对象,Objective-C都是需要以类似C语言申请堆内存块的语法(alloc)那样创建一个对象变量,并且必须通过对象指针作为访问句柄,这跟C语言申请堆内存块非常类似。Objective-C这一“任性”的设计,也使得对象嵌套(一个对象作为另一个对象的成员变量)时,对象基于引用计数机制,其成员变量也必须递归地遵循引用计数机制。因为成员变量实际都是一枚枚对象指针,很可能是与其它对象共享同一个对象(指针都指向同一块内存),引用计数机制正是适合用于支持这种“共享”内存的管理。需要特别说明,如果可以 像C++那样创建一个对象变量做成员变量,那么该成员变量会被存储在该对象所在的一块连续内存块 ,该对象销毁时能够自动把成员变量的占有的内存块全部释放收回,这与引用计数的机制并不太符合,所以,在Objective-C中对象变量不被支持也进一步得到理解。

Objective-C 与 Runtime:为什么是这样?

Objective-C 与 Runtime:为什么是这样?

/* 上图的接口(方法)是Objective-C中内存管理相关的接口 (方法)*/

Runtime(component)译名一般称为运行期组件,一个纯C语言写成的基础库(lib),Objective-C编写出来的程序必须得到Runtime的运行才能正常work, 在Java、PHP或者Flash之类的编程语言当中,大家对于Runtime并不会太陌生,Objective-C的Runtime其实也是一回事 。正是Runtime实现了Objective-C许多的特性,Objective-C面向对象、消息派发、动态绑定和内存管理都与Runtime的息息相关。那么,在Objective-C当中,对象、类、函数(方法)都是如何被构造并发挥作用的?前文提及,面向对象中的类,被看作抽象了的对象,Runtime也是秉持这一理念。Runtime是纯C写成,用struct结构体来描述对象(实体对象)和类(抽象对象)。

Objective-C 与 Runtime:为什么是这样?

Objective-C 与 Runtime:为什么是这样?

对象的struct比较简单,用*id作为结构体objc_object的指针别名,首个struct成员isa是Class类型的指针变量,正是该变量确定了对象所属的类。Class类型也是struct,是结构体objc_class的指针别名,用于描述类构成的struct,首个成员isa也是Class类型的指针变量 (由两个结构体的首个成员均为Class类型的指针变量的设计使得我们能进一步体会到Runtime中,类的确有着和对象相同的看待) ,类的isa会指向称之为metaclass(元类)的struct,metaclass抽象了类的特性,metaclass的首个成员自然也是isa的Class类型的指针变量,不同的是元类的isa最终指向的是它自身,由此我们可以观察到, 类struct是一种递归嵌套的设计,它正体现了面向对象无限抽象的理念,最终实现上指向自己则是实际工程处理的需要 。一般我们还认为objc_class这个struct存放类的metadata(元数据),例如类的实例方法、类的实例变量以及类的超类指针等。

Objective-C 与 Runtime:为什么是这样?

Runtime还允许我们通过标准的接口(C函数) 对所有Objective-C类的变量、方法、属性以及协议等等作查询和动态扩展, 从而达到我们丰富项目中语言和类库特性的目标。

Objective-C 与 Runtime:为什么是这样?

/* 上图的通过标准的Runtime API(C函数)打印UIKit中UIView的所有变量、属性以及方法*/

Runtime的另外一个重要的特性实现即为消息派发,objc_msgSend是消息派发最核心最基础的入口函数,除此之外还有objc_msgsend_stret,objc_msgSend_fpret,objc_msgSendSuper等函数,然而它们的重要性和作用远不及objc_msgSend。 objc_msgSend函数会依据receiver和selector的来调用适当的方法 。为了完成此操作,该函数需要在recevier所属的类中搜寻其“方法列表”,如果能找到与selector字符串名称相符的方法,就跳转至该方法。若是找不到,那就沿着继承体系继续向上查找,等找到合适的方法之后再跳转。如果最终还是找不到相符的方法,那就执行“消息转发”操作。由此,我们可以看到,调用一个方法似乎需要相当的步骤。每一个步骤都是开销,是否会导致Objective-C有性能问题?所幸obj_msgSend会将匹配到得结果缓存在“快速映射表”(fast map),每个类都有这样一块缓存,若是后面还需要向该类发送和相同的selector消息,执行起来将会快许多。当然,这种“快速执行路径”(fast path)还是不如“静态绑定函数调用”(statically bound function call)那样快,不过通过汇编等优化技术,映射表的查询开销已非常小,可以说, 即使相比较C++的静态绑定,Objective-C的消息派发机制已经不是性能瓶颈所在 。如果说以上的消息派发机制就是Objective-C动态绑定的全部内容,其实并不完全。 当对象查询不到相关的方法,消息无法正确回应时,还会启动“消息转发”机制 。是的,在支持“动态增加和替换”的方法列表之外,我们还能够提供其它的正常响应方式。消息转发还分为几个阶段,第一,先询问receiver或者说是它所属的类,看其是否能动态添加方法,以处理当前这个“未知选择子”(unkonwn selector),这叫做“动态方法解析”(dynamic method resolution),Runtime会通过回调一个类方法来寻求动态添加方法的支持。如果Runtime完成动态添加方法的询问之后,receiver仍然无法正常响应,则Runtime会继续向receiver询问是否有其它对象即其它receiver能处理这条消息,若返回能够处理的对象,Runtime会把消息转给返回的对象,消息转发流程也就结束。若无对象返回,Runtime会把消息有关的全部细节都封装到NSInvocation对象中,再给receiver最后一次机会,令其设法解决当前还未处理的这条消息。消息转发的流程可以归纳到以下图表:

Objective-C 与 Runtime:为什么是这样?

由图表可以看出, receiver在每一步中均有机会处理消息,步骤越往后,处理消息累计开销就越大 。所以,最好能在第一步就处理完,这样的话,Runtime还可以把方法进行缓存,在一步到位的同时进一步降低首次查询这样的开销。需要注意的是在最后一个阶段,需要由两个接口一起完成,先要通过- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector接口返回格式化的方法对象,下一个接口- (void)forwardInvocation:(NSInvocation *)anInvocation中传入参数NSInvocation对象对此方法对象是有依赖,第一步的NSMethodSignature对象返回nil,则消息转发流程即告结束。

最后利用消息转发机制,我们实现一个让NSString类支持NSArray实例方法的例子,这对于降低程序的Crash率很有帮助:

我们先实现一个方法替换的接口swizzle method,帮助我们在不需要继承的情况下,实现对父类方法的代码注入

Objective-C 与 Runtime:为什么是这样?

通过swizzle方式(class_addMethod、class_replaceMethod、method_exchangeImplementations),在NSString类的resolveInstanceMethod:中,动态方法解析的方式注入3个NSArray的实例方法:

Objective-C 与 Runtime:为什么是这样?

测试用例:

Objective-C 与 Runtime:为什么是这样?

测试结果:

Objective-C 与 Runtime:为什么是这样?

结尾,笔者用了很大的篇幅和代码片段尝试去解释Objective-C最基本的一些概念, 包括面向对象、消息派发、内存管理等等,并且也讨论了这些概念在Rumtime上的实现 ,这当中还不包括属性、分类、类族、协议等Objective-C中同样重要的feature,也没有深入阐述其中的一些编码细节(有关编码,通过搜索引擎,总能获取许多令人满意的答案)。笔者更多地是希望在有限的篇幅中帮助读者快速理解Objective-C, 理解它为什么是这样而不是那样 ,并且对于想进一步学习和使用Objective-C的开发者和工程师能有所帮助。

参考链接(部分):

正文到此结束
Loading...