对象、消息、runtime
Objective-C 语言中,“对象”是“基本的构造单元”,在对象之间传递数据并执行任务,这个过程叫做消息传递
。为其提供相关支持的代码叫做Objective-C runtime
,它提供了一些使得对象间能够传递消息的重要函数。
第6条:理解属性这一概念
属性
是 Objective-C 中用于封装对象的数据。在 Objective-C 语言中,很少像 C++、Java 那样在接口内部声明实例变量,例如:
1 | @interface EOCPerson : NSObject |
不像 C++、Java 那样,在这里可以定义实例变量的作用域。在 Objective-C 中,这种写法的问题是:对象布局在编译期间就已经固定了
。只要访问_firstName
,编译器就把其替换为偏移量(这个偏移量是硬编码,表示该变量距离存放对象的内存区域的起始地址有多远)。如果在 _firstName
前面又多添加一个实例变量 NSDate *_dateOfBirth;
,这样 _firstName
偏移量就会改变,指向 _dateOfBirth
,偏移量硬编码于其中就会读到错误的值。此时内存的布局如图所示:
如果代码使用了编译期计算出来的偏移量,那么在修改类定义之后必须重新编译,否则就会出错。然而这种将实例变量声明在@interface
接口中暴露出类的接口,更好的方式是通过@property
语法来实现。可以像以下代码来声明属性:
1 | @interface EOCPerson : NSObject |
使用@property
属性,编译器会在编译期
自动做以下几件事情:
- 自动合成这些属性的 getter、setter 方法,开发者并不可见这些合成方法的源代码。
- 向类中添加适当类型的实例变量,并在属性名前加
_
,作为实例变量的名字。
如果你不喜欢以_
开头的实例变量名,可以通过@synthesize
语法来指定实例变量的名字:
1 | @interface EOCPerson |
上面会将实例变量命名为_myFirstName
和_myLastName
。
若不想令编译器自动合成存取方法,也可以自己实现。通过使用@dynamic
关键字,告诉编译器:不要自动创建属性所用的实例变量,也不要为其创建存取方法
。而且,在编译期间访问属性代码时,即使编译器没有定义存取方法,也不会报错,它相信这些方法能在运行期找到。比如 Core Data 中 NSManagedObject的子类
。
属性特质
- atomic:修饰属性会给属性的设置方法加同步锁,iOS 同步锁开销大,会影响性能。若自定义方法,应该遵守与属性特质相符的原子性。然而这并不能保证其线程安全,需要更深层的锁机制才行。
- nonatomic:不使用同步锁,
- readwrite:编译器生成对应的
getter
、setter
方法。 - readonly:编译器只生成
getter
方法,可以在class continuation
中将其定义为 readwrite 属性,保持属性在外部是 readonly 的。 - assign:只针对基本“纯量类型”(scalar type):例如 CGFloat、NSInteger
- strong:属性为拥有关系,设置新值时,会 retain 新值,release 旧值,然后再将新值设置设置上去。
- weak:属性为非拥有关系,既不 retain 新值,也不 release 旧值,对象销毁的时候,属性值会清空(置 nil)
- unsage_unretained:与 weak 相似,但是对象销毁的时候,修饰的属性并不自动清空,所以是不安全的。
- copy:与 strong 类似,设置方法是将其 copy。用此方法保持属性的封装性。
- getter=name:指定 getter 的方法名,例如 UISwitch 中,
@property (nonatomic, getter=isOn) BOOL on
; - setter=name:指定 setter 方法名,不常见。
第7条:在对象内部尽量直接访问实例变量
- 在对象内部读取数据时,应该直接通过实例变量来读,而写入数据,则应通过属性来写。
- 在初始化方法及 dealloc 方法中,总是应该直接通过实例变量来读写数据。
- 在使用惰性初始化时,通过属性来获取数据
第8条:理解”对象同等性”
1 | NSString *foo = @"Badge 123"; |
在判断对象是否相等,需要覆写 isEqual
和 isEqualToString
方法:
- 检测对象同等性,需要提供
isEqual
与hash
方法。 - 相同的对象必须具有相同的 hash 码,而两个相同 hash 码的对象不一定相同。
第9条:以“类簇模式”隐藏实现细节
- 使用类簇可以把公共的接口隐藏在父类里面,比如
buttonWithType:
方法,根据类型返回不同的 button 实例,其类型是隐藏在类簇的公共接口后面的某个内部类型。也可以称为门面模式
。 - 系统的常用框架经常使用类簇,比如 典型的有 UIButton,collection 类等。
第10条:在既有类中使用关联对象存放自定义数据
Objective-C 可以通过关联对象
给某对象关联许多其他对象,这些对象通过key
来区分,还可以指明存储策略,用来维护相应的内存管理语句。其由objc_AssociationPolicy
的枚举所定义。可以把关联对象理解为一个 NSDictionary,拥有对应的存取值方法,与之不同的是,存取关联对象的值是个不透明的指针
。
- 可以通过关联对象将两个对象连接起来。
- 定义关联对象可以指定其内存管理语句,用来模仿定义属性时所采用的
拥有关系
与非拥有关系
。 - 关联对象之间的关系并没有正式定义,其内存管理语句是在关联的时候才定义的,使用时要小心。
第11条:理解 objc_msgSend 的作用
在 C 语言中,大部分程序是静态绑定的。也就是说程序在编译期间就能得到运行时所调用的函数。然而当 C 程序中存在函数指针的时候,编译期就无法得知该函数的定义,直到运行时才能决定。这就是动态绑定。在 Objective-C 中就使用动态绑定机制来决定调用的方法。在底层,所有的方法都是普通的 C 函数实现,调用哪个函数都是由运行时来改变。这种特性使得 Objective-C 称为动态的语言。发送消息可以这样写:
1 | // someObject:消息的接收者 |
编译器看到消息后,将其转换为一条标准的 C 语言函数调用,其原型如下:
1 | // objc_msgSend:是可变参数的函数 |
上面的函数经过编译器转换后变成:
1 | id returnValue = objc_msgSend(someObject, @selector(messageName:), paramter); |
objc_msgSend
会根据接收者与选择自的类型调用适当的方法。该方法会在接受者所属的类中搜索其方法列表(list of method)
,如果能找到与 selector 对应的方法,就跳至其实现代码。如果找不到,就沿着继承体系继续向上寻找,等找到匹配的方法后再跳转。如果还是找不到,就执行消息转发
操作。
执行消息查找需要很多步骤,所幸的是 objc_msgSend
会将匹配的结果缓存到快速映射表(fast map)
当中,下次查找执行就会很快。过程看起来很耗时,但是实际上,消息派发(message dispatch)
并不是应用程序瓶颈所在。
上面只是将消息调用过程,当然还有一些特殊情况:
objc_msgSend_stret
:如果带发送的消息返回结构体,可交由此函数处理。(这并不是绝对的,只有 CPU 寄存器能容纳下消息返回类型是,该函数才能处理此消息。若返回值无法容纳与 CPU 寄存器中,比如返回的结构体太大,就交个另一个函数进行派发。此时,函数通过分配在栈上的某个变量来处理消息返回的结构体。)objc_msgSend_fpret
:消息返回浮点数,则交个此函数处理,这是针对某些架构的 CPU 中(比如 x86)做出特殊处理,这种情况下使用objc_msgSend
并不合适。objc_msgSend_Super
:给父类发送消息,如[super message:parameter]
,另外有两个与:objc_msgSend_stret
、objc_msgSend_fpret
等效的函数,用于处理法给 super 的相应消息。
上面提到,objc_msgSend
一旦找到相应函数的实现,就会进行跳转。能这样做的原因是,Objective-C 对象每个方法都可以认为是简单的 C 函数,其原型如下:
1 | <return_type> Class_selector(id self, SEL _cmd, ...) |
其工作原理是:每个类中都要有一张表格,其中的指针指向这种函数,selector 作为查找表格所用的 key。这里要注意:原型与 objc_msgSend
函数很像,这是为了利用尾递归优化
技术,这项优化非常关键,如果不这么做,在查找函数的过程当中就会频繁的调用堆栈,插入新的栈帧,造成栈溢出。而尾递归优化可以避免这一现象。
第12条:理解消息转发机制
在编译期间向类发送器无法解读的消息,并不会报错,因为在运行时可以继续向类中添加方法,所以编译器此时无法确定该类中到底会不会有某个方法实现。如果某个对象收到无法解读的消息,runtime 就会触发消息转发
机制。我们已经遇到过消息转发流程所处理的消息了,比如这样:
1 | -[__NSCFNumber lowercaseString]: unrecongnized selector sent to instance 0x87 |
上面这段异常就是 NSObject 的 doesNotRecongnizeSelector:
方法所抛出的,表明 NSNumber 没有 lowercaseString
方法。__NSCFNumber
是为了实现桥接而使用的内部类。我们在编写程序的过程中,可以在消息转发的过程中设置钩子,用以执行预定的逻辑,不应该使程序崩溃。
消息转发分为两个阶段:
- 1.动态方法解析(dynamic method resolution):先问消息接收者,所属的类,看其能否动态添加方法来处理当前未知的消息。
- 2.完整消息转发(full forwarding mechanism):此时第一阶段已经完成,无法执行动态方法解析。runtime 使用其他手段来处理消息。
上面的完整消息转发
又包括两步:
- a).首先,请消息接收者查看有没有其他对象能处理这条消息,若有 runtime 则执行消息转发给该对象,一切正常。反之则执行 b)
- b).没有备援的消息接收者,则启动完整的消息转发机制,runtime 会将消息封装到
NSInvocation
对象中,再给消息接收者最后一次机会,令其解决当前未处理的消息。
动态方法解析:
对象收到无法解读的消息,会调用其所属类的类方法:+ (BOOL)resolveInstanceMethod:(SEL)selector
。该方法表示该类能否新增实例方法来处理 SEL。继续执行转发机制之前,本类有机会新增一个处理此 selector 的方法。如果未实现的方法不是实例方法而是类方法,runtime 就会调用另外一个方法 resolveClassMethod:
。
备援接收者:
当前接收者有第二次机会处理未知的选择子,runtime 会询问能否将消息转给其他接收者来处理。该步骤通过以下处理方法:
1 | // 参数代表未知的 selector,若找到备援对象,将其返回,没有找到,返回 nil |
注意
:我们无法操作经由这一步所转发的消息。若想在发送给备援接收者之前先修改消息内容,就必须通过消息转发机制
来做。
完整的消息转发:
消息转发来到这一步,唯一能做的就是启动完整的消息转发机制。首先创建 NSInvocation
对象,把尚未处理的消息封装其中(包括 selector、target、参数),消息派发系统将消息派发给目标对象。此步骤调用下列方法来转发消息:
1 | - (void)forwardInvocation:(NSInvocation *)invocation |
此方法会按照继承体系来寻找,继承体系中每个类都有机会处理此调用请求,直到 NSObject。如果最后调用了 NSObject 的方法,该方法还会继而调用 doesNotRecongnizeSelector:
以抛出异常,表明此 selector 未被处理。
整个流程图如下:
第13条:method swizzling
类的方法列表会把 selector 的名称映射到相关方法的实现上,这样动态消息派发系统
就能根据此找到应该调用的方法。该方法用 IMP
指针来表示,其原型:
1 | id (*IMP)(id, SEL, ...) |
比如 NSString 类的部分方法表:
也可以把方法表中的 IMP 进行交换:
有以下方法的 API:
1 | // 交换两个方法的实现 |
举个例子,当调用 lowercaseString
的时候,输出日志:
1 | @interface NSString (EOCMyAdditions) |
交换后其方法表如下:
第13条:理解“类对象”
Objective-C 中有个特殊的类型叫做 id
,它只带任意的 Objective-C 对象类型。一般情况下,应该指名下次接收者的具体类型,这样向其发送无法解读的消息,那么编译器就会产生警告。而 id
类型则不然,编译器假定它能相应所有消息。比如:
1 | // 指定具体的类型的好处是:该类实例上调用其所没有的方法时,编译器会得知此情况并发出警告。 |
id
类型被定义在运行期程序库的头文件里:
1 | /// An opaque type that represents an Objective-C class. |
由此可见,每个对象的结构体的首个成员是 Class 类的变量,该变量定义了结构体所属的类,称为 is a
指针,如上面的例子中对象“是一个” (is a)NSString
指针,所以其 is a
指针就指向 NSString。Class 对象在 runtime.h
中可以找到:
1 | struct objc_class { |
此结构体存放类的元数据(metadata)
,其结构分析如下:
- isa:指向 Class 所属的类型,也就是 metaclass,用来表示类对象本身具备的元数据。“类方法”就定义在这里,因为这些方法可以理解成
类对象的实例方法
。每个类仅有一个类对象
,而每个“类对象”仅有一个与之相关的“元类”。没错,类可以
理解为metaclass
的实例。 - super_class:指向 Class 的父类。
- name:类名
- version:版本号
- info:存放额外的信息
- instance_size:Class 实例的大小。
- methodLists:上面提到的方法表。
- cache:方法缓存
- protocols:协议表
假如,有个名为 SomeClass
的子类从 NSObject
继承而来,继承结构如下图: