目录
- 第二章:对象、消息、运行期
- 第6条:理解“属性”这一概念
- 第7条:在对象内部尽量直接访问实例变量
- 第8条:理解“对象等同性”这一概念
- 第9条:以“类族模式”隐藏实现细节
- 第10条:在既有类中使用关联对象存放自定义数据
- 第11条:理解objc_msgSend的作用
- 第12条:理解消息转发机制
- 第13条:用“方法调配技术”调试“黑盒方法”
- 第14条:理解“类对象”的用意
第二章:对象、消息、运行期
对象之间能够关联与交互,这是面向对象语言的重要特征。本章讲述这些特,并深入研究代码在运行期的行为。
Runtime就是为程序运行起来后提供相关支持的代码,它提供了一些使得对象之间能够传递消息的重要函数,并且包含创建类实例所用的全部逻辑。
第6条:理解“属性”这一概念
在对象的接口定义中,可以使用属性来访问封装在对象里的数据📊。可以把属性当作一种简称:编译器会自动写出一套存取方法,用以访问给定类型中具有给定名称的量。
用@property
来定义属性:
@interface EOCPerson : NSObject
@property NSString* firstName;
@property NSString* lastName;
@end
对于上述类的使用者,等效下面👇这种写法:
@interface EOCPerson : NSObject
- (NSString *)firstName;
- (void)setFirstName:(NSString * _Nonnull)firstName;
- (NSString *)lastName;
- (void)setLastName:(NSString * _Nonnull)lastName;
@end
可使用“点语法”(dot syntax)访问属性,编译器会把“点语法”转换为对存取方法的调用。因此,使用“点语法”和直接调用存取方法之间没有丝毫差别,两者等效:
EOCPerson* aPerson = [EOCPerson new];
aPerson.firstName = @"Jacky"; // Same as
[aPerson setFirstName: @"Jacky"];
NSString* lastName = aPerson.lastName; // Same as
NSString* lastName = [aPerson lastName];
从上例可以看到,如果使用了属性的话,那么编译器就会自动编写访问这些属性所需的方法,此过程叫做“自动合成”(autosynthesis),由编译器在编译期执行,所以编辑器里看不到这些“合成方法”(synthesized method)的源代码。
除了生成方法代码外,编译器还要自动向类中添加适当类型的实例变量,并且在属性名前加下划线_
,以此作为实例变量的名字。比如前例中,会生成_firstName和_lastName两个实例变量,可通过@synthesize
语法来指定实例变量的名字:
@implementation EOCPerson
@synthesize firstName = _myFirstName;
@synthesize lastName = _myLastName;
@end
通过@synthesize语法会将生成的实例变量命名为_myFirstName与_myLastName,而不再使用默认的名字。
使用@dynamic
关键字,会告诉编译器:不要自动创建实现属性所用的实例变量,也不要为其创建存取方法,即告诉编译器你要自己去实现。即使未手动实现存取方法,编译器也不会报错,它相信这些方法能在运行期找到。
@dynamic firstName, lastName;
属性的各种特质(attribute)设定也会影响编译器所生成的存取方法。下面这个属性指定了三项特质:
@property (nonatomic, readwrite, copy)NSString* firstName;
属性可以拥有的特质分为四类:
原子性(atomic / nonatomic)
在默认情况下,由编译器所合成的方法会通过锁定机制确保其原子性(atomicity),编译器会自动生成互斥锁,对 setter 和 getter 方法进行加锁,可以保证属性的赋值和取值的原子性操作是线程安全的,但不包括操作和访问。
比如说atomic
修饰的是一个数组的话,那么我们对数组进行赋值和取值是可以保证线程安全的。但是如果我们对数组进行操作,比如说给数组添加对象或者移除对象,是不在atomic的负责范围之内的,所以给被atomic修饰的数组添加对象或者移除对象是没办法保证线程安全的。
在并发编程中,如果某操作具备整体性,也就是说,系统其他部分无法观察到其中间步骤所生成的临时结果,而只能看到操作前与操作后的结果,那么该操作就是“原子的”,或者说该操作具备“原子性”。
如果属性具备nonatomic
特质,则不使用同步锁。一般属性都用 nonatomic 进行修饰,因为 atomic 非常耗时。
atomic与nonatomic的区别是什么呢? 具备atomic特质的getter方法会通过锁定机制来确保其操作的原子性。
也就是说,如果两个线程读写同一属性,那么不论何时,总能看到有效的属性值。
若是不加锁的话(或者说使用nonatomic语义),那么当其中一个线程正在改写某属性值时,另外一个线程也许会突然闯入,把尚未修改好的属性值读取出来。发生这种情况,线程读到的属性值可能不对。
读 / 写权限
- 具备readwrite(读写)特质的属性同时拥有setter和getter方法的声明和实现。
- 具备readonly(只读)特质的属性仅有getter获取方法。
内存管理语义
属性用于封装数据,而数据则要有“具体的所有权语义”(concrete ownership semantic)。下面这组特质仅会影响setter方法。
例如,用setter设定一个新值时,它是应该“保留”(retain)此值呢,还是只将其赋给底层实例变量就好?
如果自己编写存取方法,那么就必须同有关属性所具备的特质相符。
属性关键字 | 注意点 |
---|---|
assign:setter方法只会执行针对基本数据类型(纯量类型,scalar type,例如BOOL、CGFloat或NSInteger等)的简单赋值操作。 | 修饰对象类型时,不增加其引用计数,且会产生“悬垂指针”,意味着当被修饰的对象被释放后,指针仍指向原对象地址,这时如果继续通过该指针访问原对象的话,就可能导致程序崩溃 。 |
strong:setter方法会先保留新值,并释放旧值,再将新值设置上去。 | ARC下才可使用;修饰强引用,将指针原来指向的旧对象释放掉,然后指向新对象,同时将新对象的引用计数+1。 |
weak:setter方法既不保留新值,也不释放旧值。 | ARC下才可使用;修饰弱引用,不增加对象引用计数,主要可用于避免循环引用;属性所指对象在被释放后,会自动将指针置为nil,即不会产生悬垂指针。 |
copy:setter方法不保留新值,而是将其拷贝,并释放旧值。 | 用于NSString、block等类型。比如NSString*,此特质保护其封装性,因为传递给setter方法的新值有可能指向一个NSMutableString类的实例,此时若是不拷贝本身不可变的字符串,那么在设置完属性后,字符串的值就可能会在对象不知情的情况下遭人更改。 |
retain:原理同strong,但在修饰block时,strong相当于copy,而retain相当于assign。 | MRC下使用,ARC下常使用strong |
unsafe_unretained | MRC下经常使用,原理同weak,区别就在于unsafe_unretained会产生悬垂指针 |
方法名
可通过如下特质来指定存取方法的方法名:
- getter=< name >,指定“获取方法”的方法名。
- setter=< name >,指定“设置方法”的方法名。
在设置属性所对应的实例变量时,一定要遵从该属性所声明的语义。
注意:尽量不要在初始化方法和dealloc方法里调用setter / getter方法(🚩详见第7条)。
@property (nonatomic, copy)NSString* name;
- (instancetype)initWithName: (NSString *)name {
self = [super init];
if (self) {
_name = [name copy];
}
return self;
}
若是自己实现存取方法,也应保证其具备相关属性所声明的性质。
第7条:在对象内部尽量直接访问实例变量
直接访问实例变量和通过属性访问的区别:
- 由于不经过OC的“方法派发”(method dispatch,🚩参见第11条)步骤,直接访问实例变量的速度比较快,编译器所生成的代码就直接访问保存对象实例变量的那块内存。
- 直接访问实例变量,不会调用其setter方法,这就绕过了为相关属性所定义的“内存管理语义”。比如,如果在ARC下直接访问一个声明为copy的属性,那么并不会拷贝该属性,只会保留新对象释放旧对象。
- 直接访问实例变量不会触发“键值观测”(Key-Value Observing,KVO)通知,因为KVO时通过在运行时生成派生类并重写setter方法以达到通知所有观察者的目的。这样做是否会产生问题,取决于具体的对象行为。
- 通过属性来访问有助于排查与之相关的错误,因为可以在setter和getter方法中设置断点来调试。
有一种合理的折中方案:在写入实例变量时,通过setter来做;在读取实例变量时,则直接访问。
此办法既能提高读取操作的速度,又能控制对属性的写入操作。之所以要通过“设置方法”来写入实例变量,其首要原因在于,这样做能够确保相关属性的“内存管理语义”得以贯彻。
选用这种方案时需注意以下问题:
- 在初始化方法中不建议使用存取方法,应该直接访问实例变量。因为子类可能会重写setter方法。
假设EOCPerson有一个子类,叫做EOCSmithPerson,这个子类专门表示那些姓“Smith”的人。该子类可能会覆写lastName属性所对应的setter方法:
- (void)setLastName: (NSString *)lastName { if (![lastName isEqualToString: @"Smith"]) { [NSException raise: NSInvalidArgumentException format: @"Last name must be Smith"]; self.lastName = lastName; } }
在父类EOCPerson的默认初始化方法中,可能会将姓氏设为空字符串
self.lastName = @"";
。此时若是通过setter方法来设置字符串,那么调用的将会是子类的setter方法,导致抛出异常。
为什么会是调用子类的setter方法?因为在子类中调用[super init]先初始化父类的东西,根据super的原理,是从父类开始查找方法的实现,而消息接受者还是子类。也就是说在父类的init方法中调用[self setLastName]中self是子类对象,而又由于子类重写了该方法,故调用子类的,不会调用父类的。 - 但有些情况下必须在初始化方法中调用存取方法:
- 待初始化的实例变量声明在父类,此时我们就无法在子类中直接访问此实例变量,所以就只能调用setter方法:父类的初始化方法中直接访问实例变量,子类的初始化方法中通过 setter 方法访问。
- 属性是懒加载(lazy initialization)的,就必须通过getter来访问,否则实例变量就永远不会初始化:
若没有调用getter方法就直接访问实例变量,则会看到尚未设置好的brain。- (EOCBrain *)brain { if (!_brain) { _brain = [Brain new]; } return _brain; }
第8条:理解“对象等同性”这一概念
==
操作符比较的是两个指针本身,而不是其所指的对象,所以有时候结果并不是我们想要的。应该使用NSObject协议中声明的isEqual:
方法来判断两个对象的等同性。
一般来说,两个类型不同的对象总是不相等的,因为某些对象提供了特殊的“等同性判断方法”(equality-checking method),如果已经知道两个受测对象都属于同一个类,那么就可以使用此方法,以NSString为例:
NSString* foo = @"Badger 123";
NSString* bar = [NSString stringWithFormat: @"Badger %i", 123];
BOOL equalA = (foo == bar); //NO
BOOL equalB = [foo isEqual: bar]; //YES
BOOL equalC = [foo isEqualToString: bar]; //YES
调用isEqualToString:
方法比调用isEqual:
方法要快,因为后者还要执行额外的步骤,因为它不知道受测(比较)对象的类型。
NSObject协议中定义了两个判断同等性的关键方法:
这两个方法的默认实现是:当且仅当其指针值(内存地址)完全相等时,这两个对象才相等。
若想正确重写这两个方法,实现自定义对象的“等同性判定”,就必须遵守约定(contract):
如果isEqual:
方法判定两个对象相等,那么其hash
方法也必须返回同一个值。但是,如果两个对象的hash
方法返回同一个值,其isEqual:
方法未必会认为两者相等。
举例如下:
重写isEqual:
:
@interface EOCPerson : NSObject
@property (nonatomic, copy)NSString* firstName;
@property (nonatomic, copy)NSString* lastName;
@property (nonatomic, assign)NSUInteger age;
@end
- (BOOL)isEqual:(id)object {
//指针本身是一个,说明受测的实例对象相等
if (self == object) return YES;
//判断是否属于同一类
if ([self class] != [object class]) return NO;
/*有时我们可能认为,一个实例可以与其子类实例相等,
在继承体系中,判断同等性时,经常遭遇此问题
*/
//检测属性是否相等
EOCPerson* otherPerson = (EOCPerson *)object;
if ([_firstName isEqualToString: otherPerson.firstName]) return NO;
if ([_lastName isEqualToString: otherPerson.lastName]) return NO;
if (_age != otherPerson.age) return NO;
return YES;
}
重写hash
:
根据等同性约定,若两个对象相等,则其hash也相等,但是两个hash相等的对象却未必相等,所以下面这种写法完全可行:
- (NSUInteger)hash {
return 1337;
}
但是这么写,在collection(Array、Dictionary、Set等数据结构的总称,集合类)中将产生性能问题 collection在检索哈希表(hash table)时,会用对象的hash(哈希码)做索引。
假设某个collection是用set实现的,那么set可能会根据hash把对象分装到不同的数组(也称“箱子数组”,bin)中。在向set中添加新对象时,要根据hash找到与之相关的数组,依次检查其中各个元素,看数组中已有的对象是否和将要添加的对象相等。如果相等🟰,那就说明要添加的对象已经在set里面了。
由此可知,如果令每个对象都返回相同的hash码,那么在set中已有1000000个对象的情况下,若是继续向其中添加对象,则需将这1000000个对象全部扫描一遍,因为这些相同hash的对象都存在一个数组中。
hash
方法也可这样来实现:
- (NSUInteger)hash {
NSString* stringTohash = [NSString stringWithFormat: @"%@:%@:%i", _firstName, _lastName, _age];
return [stringTohash hash];
}
但这样做还需负担创建字符串的开销,所以比返回单一值还要慢。把这种对象添加进collection也会有性能问题,因为要想添加必须先计算其hash码。
最佳做法如下:
- (NSUInteger)hash {
NSUInteger firstNameHash = [_firstName hash];
NSUInteger lastNameHash = [_lastName hash];
NSUInteger ageHash = _age;
return firstNameHash ^ lastNameHash ^ ageHash;
}
这种做法既能保持较高效率,又能使生成的hash至少位于一定范围之内,而不会过于频繁地重复。此算法生成的hash码还是会碰撞(collision),不过至少可以保证hash有多种可能的取值。
编写hash
方法时,应该用当前的对象做做实验,以便在减少碰撞频度与降低运算复杂程度时之间取舍。
除上面提到的NSString之外,NSArray、NSDictionary类也具有特殊的等同性判定方法,isEqualToArray:
、isEqualToDictionary:
。由于OC编译期不做强类型检查(strong type checking),这样容易不小心传入类型错误的对象,因此开发者应该保证所传对象的类型是正确的。
也可以自己来创建等同性方法,好处是:
- 无需检测参数类型,大大提升检测速度。
- 自定义的方法名可以更美观、更易读。
编写自定义的等同性方法时,也应一并重写isEqual:
方法。该方法的实现通常如下:
- (BOOL)isEqual:(id)object {
if ([self class] == [object class]) {
return [self isEqualToPerson: (EOCPerson *)object];
} else {
//不属于同一个类,就交由父类(原本)的方法来完成
return [super isEqual: object];
}
}
- (BOOL)isEqualToPerson: (EOCPerson *)otherPerson {
if (self == otherPerson) return YES;
if (![_firstName isEqualToString: otherPerson.firstName]) return NO;
if (![_lastName isEqualToString: otherPerson.lastName]) return NO;
if (_age != otherPerson.age) return NO;
return YES;
}
创建等同性判定方法时,需要决定是根据整个对象来判断等同性,还是仅根据其中几个字段来判断 —— 等同性判定的执行深度。
NSArray的检测方式:
- 先看两个数组所含对象个数是否相同;
- 若相同,则在每个对应位置的两个对象上调用其
isEqual:
方法 - 如果对应位置上的对象均相等,那么这两个数组就相等。
这叫做“深度等同性判定”(deep equality)。
不过有时无须将所有数据逐个比较,只根据其中部分数据即可判明二者是否等同。
假设EOCPerson类的实例是根据数据库里的数据创建而来,那么其中就可能会含有另外一个属性,此属性是“唯一标识符”(unique identifier),在数据库中用作主键(primary key):
@property NSUInteger identifier;
在这种情况下,我们也许只会根据标识符来判断等同性,尤其是在此属性声明为
readonly
时更应如此。因为只要两者标识符相同,就肯定表示同一个对象,因而必然相等。这样的话,无须逐个比较EOCPerson对象的每条数据📊,只需标识符相同,就说明这两个对象就是由同一个数据源所创建的,据此我们能够断定,其余数据也必然相同。
上面的例子中,是根据标识符或者某些字段来判断等同性的,所以是否需要在等同性判定方法中检测全部字段取决于受测对象。
我们可以根据两个对象实例在何种情况下应判定为相等来编写等同性判定方法。
在容器(collection)中放入某个对象时,就不应该再改变其hash值 (不可变类对象),否则会有隐患。
因为collection会把各个对象按照其hash分装到不同的“箱子数组”中,如果某对象在放入箱子后hash又变了,那么其现在所处的箱子对它来说就是“错误”的。
所以要确保添加到容器中对象的hash不是根据对象的“可变部分”(mutable portion)计算出来的,或是保证之后不再改变对象内容。因此,我们最好不要往容器中添加NSMutableArray等可变对象。具体例子如下:
NSMutableSet* set = [NSMutableSet new];
NSMutableArray* arrayA = [@[@1, @2] mutableCopy];
[set addObject: arrayA];
NSLog(@"set = %@", set); //set = {((1, 2))}
NSMutableArray* arrayB = [@[@1, @2] mutableCopy];;
[set addObject: arrayB];
NSLog(@"set = %@", set); //set = {((1, 2))}
//待加入数组对象和set中已有的数组对象相等,所以set不会改变
NSMutableArray* arrayC = [@[@1] mutableCopy];
[set addObject: arrayC];
NSLog(@"set = %@", set); //set = {((1), (1, 2))}
[arrayC addObject: @2];
NSLog(@"set = %@", set); //set = {((1, 2), (1, 2))}
//改变arrayC的内容,令其和最早加入set的那个数组相等
/*
set中居然可以包含两个彼此相等的数组,根据set的语义是不允许出现这种情况的
然而现在却无法保证这一点了,因为我们修改了set中已有的对象
*/
//若是拷贝此set,就更糟了
NSSet* setB = [set copy];
NSLog(@"setB = %@", setB); // set = {((1, 2))};
把某对象放入collection之后改变其内容会造成无法预知的后果,如果真要这样做,就得注意其隐患,并用相应的代码处理可能发生的问题。
第9条:以“类族模式”隐藏实现细节
类族模式(class cluster,也叫”类簇“)是一种设计模式:定义一个抽象基类,使用”类族“把具体行为放在这个基类的子类中,将它们的实现细节隐藏在抽象基类后面,以保持接口简洁。
此设计模式的意义在于使用者无须关心创建出来的实例具体是什么类型,也不用考虑其实现细节,只需调用基类方法来创建即可。
系统框架中经常使用类族,UIButton就使用了类族,buttonWithType:
方法返回的button类型取决于传入的type。像这种“工厂模式”就是创建类族的办法之一。
创建类族
现在举例来演示如何创建类族。假设有一个处理雇员的类,每个雇员都有“名字”和“薪水”这两个属性,管理者可以命令其执行日常工作。
但是,各种雇员的工作内容却不同,经理在带领雇员做项目时,无须关心每个人如何完成其工作,仅需指示其开工即可。
首先定义抽象基类:
EOCEmployee.h
typedef NS_ENUM(NSUInteger, EOCEmployeeType) {
EOCEmployeeTypeDeveloper,
EOCEmployeeTypeDesigner,
EOCEmployeeTypeFinance
};
@interface EOCEmployee : NSObject
@property (copy) NSString* name;
@property NSUInteger salary;
//创建EOCEmployee对象
+ (EOCEmployee *)employeeWithType: (EOCEmployeeType)type;
//雇员们做各自的工作
- (void)doADaysWork;
@end
创建工厂方法employeeWithType:
,根据不同的EOCEmployeeType返回相应的EOCEmployee 子类实例:
EOCEmployee.m
#import "EOCEmployee.h"
#import "EOCEmployeeDeveloper.h"
#import "EOCEmployeeDesigner.h"
#import "EOCEmployeeFinance.h"
@implementation EOCEmployee
+ (EOCEmployee *)employeeWithType:(EOCEmployeeType)type {
EOCEmployee* employee = nil;
switch (type) {
case EOCEmployeeTypeDeveloper:
employee = [EOCEmployeeDeveloper new];
break;
case EOCEmployeeTypeDesigner:
employee = [EOCEmployeeDesigner new];
break;
case EOCEmployeeTypeFinance:
employee = [EOCEmployeeFinance new];
break;
}
return employee;
}
- (void)doADaysWork {
//抽象方法由子类们实现
}
@end
每个实体子类(concrete subclass,与“抽象基类”相对,意思是非抽象的、可实例化的),都从基类继承而来:
//EOCEmployeeDeveloper.h
@interface EOCEmployeeDeveloper : EOCEmployee
@end
//EOCEmployeeDeveloper.m
@implementation EOCEmployeeDeveloper
- (void)doADaysWork {
NSLog(@"As a developer, I am coding.");
}
@end
//EOCEmployeeDesigner.h
@interface EOCEmployeeDesigner : EOCEmployee
@end
//EOCEmployeeDesigner.m
@implementation EOCEmployeeDesigner
- (void)doADaysWork {
NSLog(@"As a designer, I am designing.");
}
@end
//EOCEmployeeFinance.h
@interface EOCEmployeeFinance : EOCEmployee
@end
//EOCEmployeeFinance.m
@implementation EOCEmployeeFinance
- (void)doADaysWork {
NSLog(@"As a finance employee, I am handling financial tasks.");
}
@end
在本例子中,基类实现了一个“类方法”,该类方法根据待创建的雇员类型分配好对应的雇员类实例。
由于OC这门语言是没办法指明某个基类是”抽象的“,所以开发者通常会在文档中写明类的用法,以告诉使用者这是类族的抽象基类,不能用init
等方法来实例化抽象基类,而是应该指定方法创建实例。
也可以在基类的doADaysWork
方法中抛出异常,来提示使用者这是类族的抽象基类,以确保使用者不会使用基类实例,不过这种做法相当极端,很少使用。这里简单介绍一下:
- (void)doADaysWork {
//抽象方法必须由子类们具体实现
NSAssert(NO, @"这是一个抽象基类,应由其子类们来实现!");
}
NSAssert
是一个断言(Assertion)语句,用于在运行时检查程序的状态。
用在这个方法里可确保其子类必须重写该方法,否则会在运行时终止应用程序并显示一条错误信息。
接收两个参数,第1个参数是一个表达式,一般用来判断条件是否满足,如果这个表达式的结果为NO,即条件不满足,那么断言会被触发,程序会以中断的方式终止,并输出第二个参数给定的错误信息。
这里未给EOCEmployeeTypeDeveloper
类实现doADaysWork
方法,运行后就会调用基类的断言语句:
如果对象所属的类位于某个类族中,那么在查询其类型信息(type introspection,某些面向对象语言可以在运行期检视对象类型与属性的一种功能)。你可能觉得自己创建了某个类的实例,然而实际上创建的却是其子类的实例。
在Employee这个例子中,[employee isMemberOfClass: [EOCEmployee class]]
似乎会返回YES,但实际上返回的却是NO,因为employee并非Employee类的实例,而是其某个子类的实例,所以应该使用isKindOfClass:
。
第10条:在既有类中使用关联对象存放自定义数据
当需要在对象中存放相关信息,而通过继承在子类的实例中去存储值这条路行不通时(有时类的实例可能是由某种机制所创建的,而开发者无法令这种机制创建出自己所写的子类实例),可以通过OC中一项强大的特性解决此问题,即“关联对象”(Associated Object)。
通过创建对象所属类的分类,并在分类中通过关联对象来存储值。
可以给某对象关联许多其他对象,这些对象通过“键”来区分。
存储对象值的时候,可以指明“存储策略”(storage policy),用以维护相应的“内存管理语义”。
存储策略由名为objc_AssociationPolicy
的枚举所定义,下表列出了该枚举的取值,同时还列出了与之等效的@property属性":
关联类型 | 等效的@property属性 |
---|---|
OBJC_ASSOCIATION_ASSIGN | assign |
OBJC_ASSOCIATION_RETAIN_NONATOMIC | nonatomic, retain |
OBJC_ASSOCIATION_COPY_NONATOMIC | nonatomic, copy |
OBJC_ASSOCIATION_RETAIN | retain |
OBJC_ASSOCIATION_COPY | copy |
关联对象相关API:
-
以给定的键和策略为某对象设置关联对象值:
void objc_setAssociatedObject(id object, void* key, id value, objc_AssociationPolicy policy)
-
根据给定的键从某对象中获取相应的关联对象值:
id objc_getAssociatedObject(id object, void* key)
-
移除指定对象的全部关联对象:
void objc_removeAssociatedObjects(id object)
可以把关联对象object
想象成NSDictionary,于是存取关联对象值就相当于在NSDictionary对象上调用[object setObject: value forKey: key]
与[object objectForKey: key]
方法。
然而两者之间有个重要差别:设置关联对象时用的键(key)是个“不透明指针”(opaque pointer,其所指向的数据结构不局限于某种特定类型的指针,通常用于隐藏实际数据类型和实现细节,使得程序员无法直接访问其指向的数据,而无法像普通指针一样对数据进行操作)。
如果在两个键上调用isEqual:
方法的返回值是YES,那么NSDictionary就会认为二者相等;然而在设置关联对象值时,若想令两个键匹配到同一个值,则二者必须时完全相同的指针才行。鉴于此,在设置关联对象时,通常使用静态全局变量做键。
关联对象用法举例:
默认情况下,由于分类底层结构的限制,不能直接给Category添加成员变量(声明一个属性后,只生成setter、getter方法的声明,不会生成setter和getter的实现,以及下划线成员变量),但是可以通过关联对象间接实现给Category添加成员变量的效果。
#import "Person.h"
@interface Person (Test)
//定义height的setter和getter方法
@property (nonatomic, assign)int height;
@end
#import "Person+Test.h"
//关联对象所需要的头文件,因为关联对象是在运行时动态添加的
#import <objc/runtime.h>
@implementation Person (Test)
- (void)setHeight:(int)height {
//将height属性关联到当前类别的实例self上
objc_setAssociatedObject(self, @selector(height), [NSNumber numberWithInt: height], OBJC_ASSOCIATION_ASSIGN);
}
- (int)height {
//获取关联到self上的height属性值
return [objc_getAssociatedObject(self, @selector(height)) intValue];
}
@end
这样就实现了在Person类的Test类别中动态添加一个名为 height 的属性,通过关联对象来实现属性的存储和访问。这种技术可以让我们在不修改原有类定义的情况下,给现有类添加属性。
第11条:理解objc_msgSend的作用
C语言的函数调用方式是使用“静态绑定”(static binding)。在编译期就能决定运行时所应调用的函数。以下列代码为例:
#import <stdio.h>
void printHello(void) {
printf("Hello, world!\n");
}
void printGoodbye(void) {
printf("Goodbye, world!\n");
}
void doTheThings(int type) {
if (type == 0) {
printHello();
} else {
printGoodbye();
}
}
如果不考虑“内联”(inline),那么编译器在编译代码的时候就已经知道程序中有哪些函数了,于是会直接生成调用这些函数的指令,而函数地址实际上是硬编码在指令之中的。如果将上例代码写成下面这样,会如何呢?
#import <stdio.h>
void printHello(void) {
printf("Hello, world!\n");
}
void printGoodbye(void) {
printf("Goodbye, world!\n");
}
void doTheThings(int type) {
void (*func) (void);
if (type == 0) {
func = printHello;
} else {
func = printGoodbye;
}
func();
}
在 C 语言中,
inline
是一个关键字,用于提示编译器在调用函数时以内联的方式展开函数代码,而不是通过普通的函数调用进行处理。
使用inline
关键字修饰一个函数时,编译器会尝试在每个调用该函数的地方直接展开函数的代码,而不是创建一个真正的函数调用。这样可以减少函数调用的开销,提高程序的执行效率。内联函数通常是短小简单的函数,经常被频繁调用。需要注意的是,使用
inline
关键字只是一个提示,编译器可以选择性地忽略这个提示,具体是否内联取决于编译器的实现和优化策略。另外,如果函数定义过于复杂或包含了复杂的控制流,编译器可能不会选择内联该函数。
简而言之,inline
关键字在 C 语言中用于提示编译器将函数以内联的方式进行展开,以提高程序的执行效率,但具体是否生效取决于编译器的决定。
这时就得使用“动态绑定”(dynamic binding)了,因为所要调用的函数直到运行期才能确定。第一个例子中,if与else语句里都有函数调用指令,而在第二个例子中,只有一个函数调用指令,不过待调用函数(printHello
、printGoodbye
)地址无法硬编码在指令之中,而是要在运行期读取出来。
OC中,在对象上调用方法用专业术语来讲,叫:“传递消息”(pass a message)。如果向某对象传递消息,那就会使用动态绑定机制来决定需要调用的方法。在底层,所有方法都是普通的C语言函数,然而对象收到消息之后,究竟该调用哪个方法则完全于运行期决定,甚至可以在程序运行时改变,这些特性使得OC成为一门真正的动态语言。
给对象发送消息的语法为:
id returnValue = [someObject messageName: parameter];
在这里,someObject叫做“接收者
”(receiver),messageName叫做“名称”(name)或“选择子
”(selector),也叫“选择器”、“选取器”,可以接受参数,而且可能还有返回值,选择子与参数合起来称为“消息
”。编译器看到此消息后,会将其转换为一条标准的C语言函数调用,所调用的函数乃是消息传递机制中的核心函数objc_msgSend
,其原型(prototype)如下:
void objc_msgSend(id self, SEL cmd, ...);
该函数参数个数可变,能接受两个或两个以上参数。前两个参数“self 消息接收者”和“cmd 选择子”(SEL 是选择子类型),即为OC方法的两个隐式参数,后续参数就是消息中的那些参数(也就是方法显式参数)。OC中的方法调用在编译后会转换成该函数调用,比如以上方法调用后转换为:
id returnValue = objc_msgSend(someObject, @selector(messageName:), parameter);
理解objc_msgSend
函数执行流程:
- objc_msgSend函数会依据接受者与选择子的类型来调用适当的方法。
为了完成此操作,该方法需要在接受者所属的类中搜寻其“方法列表”(list of methods)。
如果能找到与选择子名称相符的方法,就跳至其实现代码。若是找不到,那就沿着继承体系继续向上查找,等找到合适的方法之后再跳转。如果最终还是找不到相符的方法,那就执行“消息转发”(message forwarding,🚩详见12条)操作。 - 匹配到的方法被调用后,会被缓存在“快速映射表”(fast map)里,每个类里都有这样一块缓存,若是稍还向该类发送与选择子相同的消息,那么这样查找方法的速度就很快了。
上面只描述了部分消息的调用过程,其他“边界情况”(edge case,特殊情况)则交由OC运行环境中的另一些函数来处理:
objc_msgSend_stret
:待发送的消息返回的是结构体。objc_msgSend_fpret
:待发送的消息返回的是浮点数。objc_msgSendSuper
:给父类发消息,[super message: parameter];
“尾调用优化” 技术:编译器会生成调转至另一函数所需的指令码,而且不会向调用堆栈中推入新的“栈帧”。
当函数最后一个操作仅仅是调用其他函数而不会将其返回值另作他用时,才能执行此技术。
如果不这么做的话,那么每次调用OC方法前,都需要为调用objc_msgSend函数准备“栈帧”,在“栈踪迹”中,可以看到这种栈帧。此外,若是不优化,还会过早地发生“栈溢出”现象。
第12条:理解消息转发机制
当对象在“消息发送”阶段无法处理消息(找不到方法实现)时,就会进入“消息转发”阶段,开发者可以在此阶段处理未知消息。
消息机制可分为“消息发送”、“动态方法解析”、“消息转发”三大阶段。本书将“动态方法解析”归并到了“消息转发”阶段中。
消息转发分为两大阶段:
- 动态方法解析:先征询接收者所属的类,看其是否能动态添加方法,以处理当前这个“未知的选择子”。 如果运行期系统已经把第一阶段执行完了,那么接收者自己就无法再以动态新增方法的手段来响应包含该选择子的消息了。
- 真正的消息转发,此阶段又分为Fast和Normal两个阶段:
- Fast:找一个备用接收者,尝试将未知消息转发给备用接受者去处理。
- Normal:若没有“备援的接收者”(replacement receiver),则启动完整的消息转发机制,运行期系统会将消息有关的全部细节都封装到NSInvocation对象中,再给接收者最后一次机会去处理未知消息。
动态方法解析
对象在收到无法解读的消息后,根据是实例方法还是类方法,实现以下方法启用“动态方法解析”,通过动态添加方法实现,来处理未知消息:
+ (BOOL)resolveInstanceMethod: (SEL)selector;
+ (BOOL)resolveClassMethod: (SEL)selector;
方法参数即为未知消息的选择子,返回值表示这个类是否已经动态添加方法实现来处理(实际上该返回值只是用来判断是否打印信息,影响不大,不过还是要规范编写)。
使用此方法的前提是,相关方法的实现代码已经写好,只等着运行的时候动态插在类里面就可以。常用来实现@dynamic
属性,在运行时动态添加属性setter和getter方法的实现:
id autoDictionaryGetter(id self, SEL _cmd);
void autoDictionarySetter(id self, SEL _cmd, id value);
//
#import "objc/runtime.h"
+ (BOOL)resolveInstanceMethod:(SEL)sel {
NSString* selectorString = NSStringFromSelector(sel);
if ( /* selector is from a @dynamic property */ ) {
if ([selectorString hasPrefix: @"set"]) {
class_addMethod(self, sel, (IMP)autoDictionarySetter, "v@:@");
} else {
class_addMethod(self, sel, (IMP)autoDictionaryGetter, "@@:");
}
return YES;
}
return [super resolveInstanceMethod: sel];
}
首先检测选择子是setter还是getter方法,不管哪种情况,都会把该选择子的方法加到类里面,所添加的方法使用纯C函数实现的,C函数可能会用代码来操作相关的数据结构,类之中的属性数据就存放在那些数据结构里面。
消息转发
-
Fast - 找备用接收者:
+/- (id)forwardingTargetForSelector: (SEL)selector;
方法参数即为未知消息的选择子,返回值为备用接收者,若找不到这个备援接收者,则返回nil。
通过此方案,我们可以用“组合”(composition)来模拟出“多重继承”的某些特性。在一个对象内部,可能还有一系列其他对象,该对象可经由此方法将能够处理某选择子的相关内部对象返回,这样的话,在外界看来,好像是该对象亲自处理来这些消息似的。
请注意,在此阶段无法修改未知消息的内容,如果需要,请在Normal阶段去处理。 -
Normal - 完整的消息转发:
+/- (NSMethodSignature *)methodSignatureForSelector: (SEL)aSelector;
通过实现以上方法返回一个适合该未知消息的方法签名,Runtime会根据这个方法签名,创建一个封装了未知消息的全部内容(选择子selector、目标target及参数argument)的
NSInvocation
对象。然后调用以下方法并将该NSInvocation
对象作为参数传入。+/- (void)forwardInvocation:(NSInvocation *)anInvocation;
该方法中只需改变调用目标target即可,但一般不这样做,因为这样做不如在Fast阶段就处理。比较有用的实现方式是:改变消息内容,比如改变选择子,追加一个参数等,再转发给其他对象处理。
实现以上方法时,不应由本类处理的未知消息,应该调用父类同名方法的实现,这样继承体系中的每个类都有机会处理此调用请求,直至NSObject。
NSObject的默认实现时最终调用doesNotRecognizeSelector:
以抛出家喻户晓的异常unrecognized selector send to instance/class
,表明未知消息最终未能得到处理。
消息转发全流程
- 以上几个阶段中,接收者均有机会处理消息,但处理消息的时间越早,性能就越高。
- 最好在“动态方法解析”阶段就处理完,这样Runtime就可以将此方法缓存,稍后这个实例在接收到同一消息时就无须再启动消息转发流程。
- 如果“消息转发”阶段只是单纯想将消息转发给备用接收者,那么最好在Fast阶段就完成。否则还得创建并处理NSInvocation对象。
完整的动态方法解析例子
这里设计一个功能性的类,思路是:由开发者来添加属性定义,并将其声明为@dynamic
,而类会自动处理相关属性值的存放setter与获取getter操作。
EOCAutoDictionary.h
#import <Foundation/Foundation.h>
@interface EOCAutoDictionary : NSObject
@property (nonatomic, copy)NSString* string;
@property (nonatomic, strong)NSNumber* number;
@property (nonatomic, strong)NSDate* date;
@property (nonatomic, strong)id opaqueObject;
@end
EOCAutoDictionary.m
#import "EOCAutoDictionary.h"
#import "objc/runtime.h"
@interface EOCAutoDictionary ()
@property (nonatomic, strong)NSMutableDictionary* backingStore;
@end
@implementation EOCAutoDictionary
@dynamic string, number, date, opaqueObject;
- (instancetype)init
{
self = [super init];
if (self) {
_backingStore = [NSMutableDictionary new];
}
return self;
}
// 传入的是被首次访问或写入的属性的setter或getter方法
// 比如要写入opaqueObject属性,那么传入的参数就是 @selector(setOpaqueObject:);在读取该属性时,传入的选择子是 @selector(opaqueObject:)
+ (BOOL)resolveInstanceMethod:(SEL)sel {
//选择子的名称
NSString* selectorString = NSStringFromSelector(sel);
if ([selectorString hasPrefix: @"set"]) {
//向类中动态添加方法
class_addMethod(self, sel, (IMP)autoDictionarySetter, "v@:@");
} else {
class_addMethod(self, sel, (IMP)autoDictionaryGetter, "@@:");
}
return YES;
}
id autoDictionaryGetter(id self, SEL _cmd) {
//从对象中拿到backingStore
EOCAutoDictionary* typedSelf = (EOCAutoDictionary *)self;
NSMutableDictionary* backingStore = typedSelf.backingStore;
//getter方法名作为key
NSString* key = NSStringFromSelector(_cmd);
return [backingStore objectForKey: key];
}
void autoDictionarySetter(id self, SEL _cmd, id value) {
EOCAutoDictionary* typedSelf = (EOCAutoDictionary *)self;
NSMutableDictionary* backingStore = typedSelf.backingStore;
NSString* selectorString = NSStringFromSelector(_cmd);
NSMutableString* key = [selectorString mutableCopy];
//删除:
[key deleteCharactersInRange: NSMakeRange(key.length - 1, 1)];
//删除set
[key deleteCharactersInRange: NSMakeRange(0, 3)];
//将首字母变成小写
NSString* lowercaseFirstChar = [[key substringToIndex: 1] lowercaseString];
[key replaceCharactersInRange: NSMakeRange(0, 1) withString: lowercaseFirstChar];
if (value) {
[backingStore setObject: value forKey: key];
} else {
[backingStore removeObjectForKey: key];
}
}
@end
class_addMethod方法:
- 第三个参数autoDictionarySetter、autoDictionaryGetter为处理选择子的函数指针,指向待添加的方法。
- 第四个参数表示待添加方法的“类型编码”(type encoding)。本例中,编码开头的表示方法的返回值类型,后续字符则表示其所接受的各个参数。
以下为OC类型编码对照表:(来源Apple官网:Type Encodings)
此类的使用:
EOCAutoDictionary* dict = [EOCAutoDictionary new];
dict.string = @"Dynamic Method Resolution";
NSLog(@"%@", dict.string);
// 输出:Dynamic Method Resolution
其他属性的访问方式与string类似,要想添加新属性,只需用@property来定义,并将其声明为@dynamic即可。
第13条:用“方法调配技术”调试“黑盒方法”
通过Runtime动态交换方法实现(也叫方法调配、方法混合或方法调和,method swizzling
),我们既不需要修改源代码,也不需要通过继承子类来覆写方法就能改变这个类本身的功能。
类的方法列表会把选择子的名称映射到相关的方法实现之上,使得“动态消息派发系统”能够根据此找到应该调用的方法。这些方法均以函数指针的形式来表示,这种指针叫做IMP
,其实就是SEL
到IMP
的映射,其原型如下:
id (* IMP)(id, SEL, ...)
NSString类可以响应lowercaseString
、uppercaseString
、capitalizedString
等选择子,下面这张映射表中的每个选择子都映射到了不同的IMP之上:
OC运行期系统提供的几个方法都能够用来操作张表,开发者可以向其中新增选择子、改变选择子所对应的方法实现或交换两个选择子所映射到的指针:
上述修改均无须编写子类,只要修改了“方法表”的布局,就会反映到程序中所有的NSString实例之上。
像上面修改后的方法表一样,仅仅交换已经实现得很好的方法意义并不大。实际应用中,可用过这一手段来为既有的方法实现增添新功能。
比如,想要在调用lowercaseString
时记录某些信息,可通过方法交换来达成这一目的。我们新编写一新方法,在此方法中实现所需的附加功能,并调用原有实现。新方法可添加至NSString的一个分类(Category)中:
// NSString+EOCMyAdditions.h
@interface NSString (EOCMyAdditions)
//新方法
- (NSString *)eoc_myLowercaseString;
@end
// NSString+EOCMyAdditions.m
@implementation NSString (EOCMyAdditions)
- (nonnull NSString *)eoc_myLowercaseString {
//看似会进入调用死循环,实际交换后已经是lowercaseString方法了
NSString* lowerCase = [self eoc_myLowercaseString];
NSLog(@"%@ => %@", self, lowerCase);
return lowerCase;
}
@end
通过下列代码来交换两个方法实现:
//去除方法实现
Method originalMethod = class_getInstanceMethod([NSString class], @selector(eoc_myLowercaseString));
Method swappedMethod = class_getInstanceMethod([NSString class], @selector(lowercaseString));
//交换方法实现
method_exchangeImplementations(originalMethod, swappedMethod);
执行完上述代码之后,只要在NSString实例上调用lowerString方法即可达到为其添加新功能的目的:
NSString* exchangeString = [aString lowercaseString];
NSLog(@"%@", exchangeString);
通过方法交换,开发者可以为那些“完全不知道具体实现的”(completely opaque,“完全不透明的”)黑盒方法增加日志记录功能,这非常有助于程序调试。但是,很少有人在调试程序之外的场合用上述“方法调配技术”来永久改动某个类的功能,应该合理使用这个方案,若是滥用,反而会令代码变得不易读懂且难于维护。
第14条:理解“类对象”的用意
id
类型能指代任意的OC对象类型。一般情况下,应该指明接收者放入具体类型,这样如果给该对象发送无法解读的消息,编译器就会给出警告。而id
类型对象不会,因为编译器假定它能响应所有消息。
先来看看OC对象的本质,描述OC对象所用的数据结构定义在运行期程序库的头文件里,id
类型本身也定义在这里,一个指向objc_object
结构体的指针:
typedef struct objc_object {
Class isa;
}* id;
由此可见,每个对象结构体的首个成员是Class类的变量。该变量定义了对象所属的类,通常称为“is a
”指针,该指针描述了实例所属的类。例如,所用的对象“是一个”(is a)NSString,所以其“is a”指针就指向NSString。Class对象也定义在运行期程序库的头文件中:
typedef struct objc_class* Class;
struct objc_class {
Class isa;
Class super_class;
const char* name;
long version;
long info;
long instance_size;
struct objc_ivar_list* ivars;
struct objc_method_list** methodLists;
struct objc_cache* cache;
struct objc_protocol_list* protocols;
};
此结构体存放类的 “元数据”(metaclass),例如类的实例实现了几个方法,具备多少个实例变量等信息。
此结构体的首个变量也是isa
指针,这说明Class本身亦为Objective-C对象。
结构体里还有个变量叫做super_class
,它定义了本类的超类,该指针确立了继承关系。
类对象所属的类型(isa指针指向的类型)是另外一个类,叫做 “元类”(metaclass),用来表述类对象本身所具备的元数据。
“类方法” 就定义此处,因为这些方法可以理解成类对象的实例方法。
每个类仅有一个“类对象”,而每个“类对象”仅有一个与之相关的“元类”。
假设有个名为SomeClass的子类从NSObject中继承而来,则其继承体系如下:
每个OC对象的底层结构都为objc_object
结构体,类和元类对象的底层结构都为objc_class
结构体,其继承自objc_object
,他们之间通过is a
指针联系。
OC是动态运行时语言,对象类型并非在编译期就绑定好了,而是要在运行期查找,编译器无法确定某类型对象到底能解读多少种选择子、能不能处理未知消息,因为运行期还可向其中动态新增。
即便这样,编译器也觉得应该能在某个头文件中找到方法原型的定义,据此了解完整的“方法签名(Type Encoding)”,并生成发送消息所需的正确代码。
在类继承体系中查询类型信息
“在运行期检视对象类型”这一操作也叫做 “类型信息查询”(introspection,“内省”) ,这个强大而有用的特性内置于Foundation框架的NSObject协议里,凡是由公共根类(common root class,即NSObject与NSProxy) 继承而来的对象都要遵从此协议。
用类型信息查询方法来检视类继承体系:
-/+ (BOOL)isMemberOfClass:(Class)aClass;
:判断当前instance/class对象的isa
指向是不是class/metaclass对象类型(也就是判断当前对象是否为某个类的实例)。-/+ (BOOL)isKindOfClass:(Class)aClass;
:判断当前instance/class对象的isa
指向是不是class/metaclass对象或者它的子类类型(也就是判断当前对象是否为某个类或其子类的实例)。
从collection(Array、Dictionary、Set)中获取对象时:
-
通常会查询类型信息,因为这些对象取出来时通常是
id
类型而不是“强类型的”(strongly typed),查询类型信息可以避意外地调用了该类型对象响应不了的方法而导致Crash。 -
也可以通过比较类对象是否等同。直接使用
==
操作符,不必使用isEqual:
方法,就可以精确地判断出对象是否属于某类。原因在于,类对象是“单例”(Singleton),在应用程序范围内,每个类的Class仅有一个实例,而使用isEqual:
方法只会产生不必要的开销。id object = /* .. */; if ([object class] == [EOCSomeClass class]) { // object时EOCSomeClass的一个实例 }
即便能这样做,在程序中也尽量不要直接比较对象所属的类,而是调用“类型信息查询方法”来确定对象类型。因为后者可以正确处理那些使用了消息传递机制的对象:
除NSObject外,还有一公共根类NSProxy,它的子类对象叫做代理(proxy),这种对象可能会把它所接收的选择子都转发给另外一个对象。
举一例子,HTProxy继承于NSProxy,其将消息都转发给一个叫HTPerson的实例,现有一个HTProxy的实例proxy:
//通常情况下,在此种代理对象上调用class方法,返回的是代理对象本身
Class aClass = [proxy class];
aClass为HTProxy(代理对象)而非HTPerson(接受代理的对象),若改用isKindOfClass:
这样的类型信息查询方法,那么代理对象就回吧这条消息转发给“接受代理的对象”(proxied object):
BOOL res = [proxy isKindOfClass: [HTPerson class]];
// 输出YES
因此,以上两种方法所查询处理的对象类型不同。