理解"属性"这一概念
"属性"(property)是 Objective-C 的一项特性,用于封装对象中的数据。Objective-C 对象通常会把其所需的数据保存为各种实例变量。实例变量一般通过"存取方法"来访问。其中,"获取方法"(getter)用于读取变量值,而"设置方法"(setter)用于写入变量值。开发者可以令编译器自动编写与属性相关的存取方法。此特性引入了一种新的"点语法",使开发者可以更为容易地依照类对象来访问存放与其中的数据。
在类接口的 public 区段中声明一些实例变量:
objectivec
@interface EOCPerson : NSObject {
@public
NSString* _firstName;
NSString* _lastName;
@private
NSString* _someInternalData;
}
@end
但是上面这种写法的问题是:对象布局在编译期就已经固定了。只要碰到访问 _firstName 变量的代码,编译器就把其替换为"偏移量",这个偏移量是"硬解码",表示该变量距离存放对象的内存区域的起始地址有多远。这样做目前来看没问题,但是如果在 _firstName 之前又多了一个实例变量:
objectivec
@interface EOCPerson : NSObject {
@public
NSDate* _dateOfBirth;
NSString* _firstName;
NSString* _lastName;
@private
NSString* _someInternalData;
}
@end
原来表示 _firstName 的偏移量现在却指向_dateOfBirth 了。把偏移量硬解码于其中的那些代码都会读取到错误的值。
- 对比在类中加入 _dateOfBirth 这一实例变量之前与之后的内存布局,其中假设指针为 4 个字节:
如果代码使用了编译期计算出来的偏移量,那么在修改类定义之后必须重新编译,否则就会出错。
Objective-C 的做法是,把实例变量当做一种存储偏移量所用的"特殊变量",交由"类对象"保管。偏移量会在运行期查找,如果类的定义变了,那么存储的偏移量也就变了,这样的话,无论何时访问实例变量,总能使用正确的偏移量。甚至可以在运行期向类中新增实例变量,这就是稳固的"应用程序二进制接口"(ABI)。ABI 定义了许多内容,其中一项就是生成代码时所应遵循的规范。有了这种"稳固的"的ABI,我们就可以在 "class-continuation 分类" 或实现文件中定义实例变量了。所以说,不一定要在接口中把全部实例变量都声明好,可以将某些变量从接口的 public 区段移走,以便保护与类实现有关的内部信息。
这个问题还有一种解决办法,就是尽量不要直接访问实例变量,而应该通过存取方法来做。这种就是使用属性了,例如:
objectivec
@interface EOCPerson : NSObject
@property NSString *firstName;
@property NSString *lastName;
@end
上面使用属性的语法其实相当于下面这样的语法:
objectivec
@interface EOCPerson : NSObject
- (NSString *)firstName;
- (void)setFirstName:(NSString *)firstName;
- (NSString *)lastName;
- (void)setLastName:(NSString *)lastName;
@end
访问属性使用"点语法"和直接调用存取方法之间没有丝毫差别:
objectivec
EOCPerson *aPerson = [Person new];
aPerson.firstName = @"Bob";
[aPerson setFirstName:@"Bob"];
如果使用了属性的话,那么编译器就会自动编写访问这些属性所需的方法,此过程叫做"自动合成"。但是这个过程由编译器在编译期执行,所以编辑器里面看不到这些"合成方法"的源代码。编译器还要自动向类中添加适当类型的实例变量,并且在属性名前面加下划线,以此作为实例变量的名字。用@synthesize 语法可以指定实例变量的名字,之前博客写过这里不赘述了。
存取方法也可以自己实现,如果你只实现了其中一个存取方法,那么另外一个还是会由编译器来合成。还有一种办法能阻止编译器自动合成存取方法,就是使用 @dynamic 关键字,它可以让编译器不自动创建实现属性所用的实例变量,也不为其创建存取方法。
在编译访问属性的代码时,即使编译器发现没有定义存取方法,也不会报错,它相信这些方法能在运行期找到。
属性的特质
在之前的博客里也写到过属性的特质,这里再完善一些
属性特质有:
- 原子性
在默认情况下由编译器所合成的方法会通过锁定机制确保其原子性(atomicity)。如果属性具有nonatomic特质,则不使用同步锁。尽管没有名为atomic的特质,如果某属性不具备nonatomic特质,那它就是atomic。
- 读/写权限
readwrite(读写)特质:属性拥有getter和setter方法
readonly(只读)特质:仅拥有getter方法,且只有当属性由@synthesize实现时,编译器才会为其合成getter方法 - 内存管理
assign:setter方法只会执行针对"纯量类型"的简单赋值操作
strong:为这种属性设置新值时,setter方法会先保留新值,并释放旧值,然后再将新值设置上去。
weak:为这种属性设置新值的时候,设置方法既不保留新值,也不释放旧值,但是在属性所指的对象摧毁时,属性值也会清空。
unsafe_unretained:语义和assign相同,但是适用于"对象类型"。当对象被摧毁时,属性值不会自动清空
copy:所属关系和strong类似,但是setter方法不保留新值,而是将其拷贝。当属性类型是NSString*时,经常用此特质来保护其封装性。 - 方法名
可通过如下特质来指定存取方法的方法名:
getter= :指定"获取方法"的方法名。如果某属性是 Boolean 型,而你想为其获取方法加上 "is" 前缀,那么就可以用这个方法来来指定。比如说,在 UISwitch 类中,表示"开关"(switch)是否打开的属性就是这样定义的:
setter= :指定"设置方法"的方法名。
通过上述特质,可以微调由编译器所合成的存取方法。不过需要注意: 若是自己来实现这些存取方法,那么应该保证其具备相关属性所声明的特质。
如果想在其他方法里设置属性值,那么同样要遵守属性定义中所宣称的语义。例如:
objectivec
@interface EOCPerson : NSManagedObject
@property (copy) NSString *firstName;
@property (copy) NSString *lastName;
- (instancetype)initWithFirstName:(NSString *)firstName lastName:(NSString *)lastName;
@end
在实现这个自定义的初始化方法时,一定要遵循属性定义中宣称的 "copy"语义,因为"属性定义"就相当于"类"和"待设置的属性值"之间所达成的契约。初始化方法的实现代码可以这样写:
objectivec
@implementation EOCPerson
- (instancetype)initWithFirstName:(NSString *)firstName lastName:(NSString *)lastName {
if (self = [super init]) {
firstName = [firstName copy];
lastName = [lastName copy];
}
return self;
}
@end
如果将这一套用到 EOCPerson 类上,那就等于说,其两个属性应该设为"只读"。用初始化方法设置好属性值之后,就不能再改变了。于是可以把属性的定义改成这样:
objectivec
@property (copy, readonly) NSString *firstName;
@property (copy, readonly) NSString *lastName;
- atomic 与 nonatomic 的区别是什么呢?前面说过,具备 atomic 特质的获取方法会通过锁定机制来确保其操作的原子性。这就是说,如果两个线程读写同一属性,那么不论何时,总能看到有效的属性值。若是不加锁的话(或者说使用 nonatomic 语义),那么当其中一个线程正在改写某属性值时,另外一个线程也许会突然闯入,把尚未修改好的属性值读取出来。发生这种情况时,线程读到的属性值可能不对。
- 开发 iOS 程序,会发现,其中所有的属性都声明为 nonatomic。这样做的历史原因是:在 iOS 中使用同步锁的开销较大,就会带来性能问题。一般情况下并不要求属性必须是"原子的",因为这并不能保证"线程安全"(thread safety),若要实现"线程安全"的操作,还需要采用更为深层的锁定机制才行。例如,一个线程在连续多次读取某属性值的过程中有别的线程在同时改写该值,那么即便将属性声明为 atomic,也还是会读取不同属性值。因此,开发 iOS 程序时一般都是会使用 nonatomic 属性。
在对象内部尽量直接访问实例变量
使用 "点语法",通过存取方法来访问相关实例变量:
objectivec
NSArray *components = [fullName componentsSeparatedByString:@" "];
self.firstName = [components objectAtIndex:0];
self.lastName = [components objectAtIndex:1];
不经由存取方法,而是直接访问实例变量:
objectivec
NSArray *components = [fullName componentsSeparatedByString:@" "];
_firstName = [components objectAtIndex:0];
_lastName = [components objectAtIndex:1];
这两种写法有几个区别:
由于不经过 Objective-C 的 "方法派发"步骤,所以直接访问实例变量的速度当然比较快。在这种情况下,编译器所生成的代码会直接访问保存对象实例变量的那块内存。
直接访问实例变量时,不会调用其"设置方法",这就绕过了为相关属性所定义的"内存管理语义"。比方说,如果在 ARC 下直接访问一个声明为 copy 的属性,那么并不会拷贝该属性,只会保留新值并释放旧值。
如果直接访问实例变量,那么不会触发"键值观察"(KVO)通知。这样做是否会产生问题,还取决于具体的对象行为。
通过属性来访问有助于排查与之相关的错误,因为可以给"获取方法"和/或"设置方法"中新增"断点",监控该属性的调用者及其访问机。
有一种合理的折中方案,那就是:在写入实例变量时,通过其"设置方法"来做,而在读取实例变量时,则直接访问之。此方法既能提高读取操作的速度,又能控制对属性的写入操作。之所以要通过"设置方法"来写入实例变量,其首要原因在于,这样做能够确保相关属性的"内存管理语义"得以贯彻。
但是第一个要注意的地方是,在初始化方法中应该如何设置属性值。这中情况下总是应该直接访问实例变量,因为子类可能会"覆写"(override)设置方法。
另外一个需要注意的问题是 "惰性初始化"(lazy initialization)。在这种情况下,必须通过"获取方法"来访问属性,否则,实例变量就永远不会初始化。
比如说,EOCPerson 类也许会用一个属性来表示人脑中的信息,这个属性所指代的对象相当复杂。由于此属性不常用,而且创建该属性的成本较高,所以,我们可能会在"获取方法"中对其执行惰性初始化:
objectivec
- (EOCBrain *)brain {
if (!_brain) {
_brain = [Brain new];
}
return _brain;
}
若没有调用 "获取方法"就直接访问实例变量,则会看到尚未设置好的 brain,所以说,如果使用了 "惰性初始化"技术,那么必须通过存取方法来访问 brain 属性。
- 在对象内部读取数据时,应该直接通过实例变量来读,而写入数据时,则应该通过属性来写。
- 在初始化方法及 dealloc 方法中,总是应该直接通过实例变量来读写数据。
- 有时会使用懒加载初始化技术配置某份数据,这中情况下,需要通过属性来读取数据。
理解"对象等同性"这一概念
以前的博客提过:
当用= =方法时,若️_①两个变量是基本类型的变量,️②两个变量都是数值型的变量(不一定要求数据类型严格相等),️③两个变量的值相等 _。则判断返回真,否则返回假。
而对于指针类型的变量,则要两个指针指向同一个对象,则返回真,否则返回假。
当使用==的两个类没有继承关系时,编译器会提示警告。
而isEqual方法不严格要求两个指针变量指向同一个对象。所有指针变量都可以调用该方法来判断是否与其他指针变量相等。它要求两个指针变量指向同一个对象才返回真。
一般来说,两个类型不同的对象总是不相等的(unequal)。某些对象提供了特殊的 "等同性判定方法"(equality-checking method),如果已经知道两个受测对象都属于同一个类,那么就可以使用这种方法。以下述代码为例:
objectivec
NSString *foo = @"Badger 123";
NSString *bar = [NSString stringWithFormat:@"Badger %i", 123];
BOOL equalA = (foo == bar);
BOOL equalB = [foo isEqual:bar];
BOOL equalC = [foo isEqualToString:bar];
大家可以看到 == 与等同性判断方法之间的差别。 NSString 类实现了一个自己独有的等同性判断方法,名叫 "isEqualToString:"。传递给该方法的对象必须是 NSString ,否则结果未定义。调用该方法比调用 "isEqual:"方法快,后者还要执行额外的步骤,因为它不知道受测对象的类型。
NSObject 协议中有两个用于判断等同性的关键方法。
objectivec
- (BOOL)isEqual:(id)object;
+ (NSUInteger)hash;
NSObject 类对这两个方法的默认实现是:当且仅当其"指针值"(可理解为内存地址) 完全相等时,这两个对象才相等。若想在自定义的对象中正确覆写这些方法,就必须先理解其约定。如果 "isEqual:"方法判定两个对象相等,那么其hash 方法也必须返回同一个值,但是,如果两个对象的 hash 方法返回同一值,那么 "isEqual:"方法未必会认为两者相等。
意思是如果两个对象通过isEqual:方法被认为相等,它们的hash值必须相等。但是,两个对象的hash相等并不意味着它们通过isEqual:也相等。
我们认为,如果两个 EOCPerson 的所有字段均相等,那么这两个对象就相等。于是 "isEqual:"方法可以完成:
objectivec
- (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"一词也叫做"杂凑"、"散列")也相等,但是两个哈希码相同的对象却未必相等。这是能否正确覆写 "isEqual:" 方法的关键所在。下面这种写法完全可行:
objectivec
- (NSUInteger)hash {
return 1337;
}
不过若是这么写的话,在 collection 中使用这种对象将产生性能问题。
hash 方法也可以这样来实现:
objectivec
- (NSUInteger)hash {
NSString *stringToHash = [NSString stringWithFormat:@"%@:%@:%lu", _firstName, _lastName, (unsigned long)_age];
return [stringToHash hash];
}
这次所用的办法是将 NSString 对象中的属性都塞入另一个字符串中,然后令 hash 方法返回该字符串的哈希码。还需要负担创建字符串的开销,所以比返回单一值要慢。把这中对象添加到collection 中时,也会产生性能问题。
最后一种计算哈希码的办法:
objectivec
- (NSUInteger)hash {
NSUInteger firstNameHash = [_firstName hash];
NSUInteger lastNameHash = [_lastName hash];
NSUInteger ageHash = _age;
return firstNameHash ^ lastNameHash ^ ageHash;
}
这种做法既能保持较高效率,又能使生成的哈希码至少位于一定范围之内,而不会过于频繁的重复。
特定类的等同性判定方法
- NSArray 与 NSDictionary 类也具有特殊的等同性判定方法,前者名为"isEqualToArray:",后者名为"isEqualToDictionary:"。如果和其比较的对象不是数组或字典,那么这两个方法会各自抛出异常。
如果经常需要判断等同性,那么可能会自己来创建等同性判定方法,在编写判定方法时,也应一并覆写"isEqual:"方法。后者的常见实现方式为:如果受测的参数与接收该消息的对象都属于同一类,那么久调用自己编写的判定方法,否则就交由超类来判断。例:
objectivec
- (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;
}
- (BOOL)isEqual:(id)object {
if ([self class] == [object class]) {
return [self isEqualToPerson:(EOCPerson *)object];
} else {
return [super isEqual:object];
}
}
等同性判定的执行深度
NSArray的检测方式是先看两个数组所含对象个数是否相同,若相同,则在每个对应位置的两个对象上调用其"isEqual:"方法,如果对应位置上的对象均相等,那么两个数组相等,这叫做"深度等同性判定"。
容器中可变类的等同性
在容器中放入可变类对象的时候,把某个对象放入 collection 之后,就不应该再改变其哈希码了。因为collection 会把各个对象按照其哈希码分装到不同的 "箱子数组"中。如果某对象在放入"箱子"之后哈希码又变了,那么其现在所处的这个箱子对它来说是"错误"的。要想解决这个问题,需要确保_哈希码不是根据对象的 "可变部分"计算出来的_,或是保证放入 collection 之后就不再改变对象内容了。
- 若想检测对象的等同性,请提供 "isEqual:" 与 hash 方法。
- 相同的对象必须具有相同的哈希码,但是这两个哈希码相同的对象却未必相同。
- 不要盲目地逐个检测每条属性,而是应该依照具体需求来制定检测方案。
- 编写 hash 方法时,应该使用计算速度快而且哈希码碰撞几率低的算法。
以"类族模式"隐藏实现细节
"类族"(类簇)是一种很有用的模式,可以隐藏 "抽象基类"。Objective-C 的系统框架中普遍使用此模式。该模式可以灵活应对多个类,将它们的实现细节隐藏在抽象基类后面,以保持接口简洁。用户无须自己创建子类实例,只需调用基类方法来创建即可。
创建类族
现在举例来演示如何创建类族:首先要定义抽象基类:
objectivec
typedef NS_ENUM(NSUInteger, EOCEmployeeType) {
EOCEmployeeTypeDeveloper,
EOCEmployeeTypeDesigner,
EOCEmployeeTypeFinance
};
@interface EOCEmployee : NSObject
@property (copy) NSString *name;
@property NSUInteger salary;
+ (EOCEmployee *)employeeWithType:(EOCEmployeeType)type;
- (void)doADayWork;
@end
+ (EOCEmployee *)employeeWithType:(EOCEmployeeType)type {
switch (type) {
case EOCEmployeeTypeDeveloper:
return [EOCEmployeeDeveloper new];
break;
case EOCEmployeeTypeDesigner:
return [EOCEmployeeTypeDesigner new];
break;
case EOCEmployeeTypeFinance:
return [EOCEmployeeTypeFinance new];
break;
}
}
- (void)doADayWork {
}
@end
每个"实体子类"都从基类继承而来:
objectivec
@interface EOCEmployeeDeveloper : EOCEmployee
@end
@implementation EOCEmployeeDeveloper
- (void)doADayWork {
[self writeCode];
}
@end
基类实现了一个"类方法",该方法根据待创建的类别分配好对应的类实例。这种"工厂模式"是创建类族的办法之一。
如果对象所属的类位于某个类族中,那么在查询其类型信息时,你可能觉得自己创建了某个类的实例,然而实际上创建的却是其子类的实例。在 Employee 这个例子中,[employee isMemberOfClass:[EOCEmployee class]] 似乎会返回 YES,但实际上返回的却是NO,因为 employee 并非 Employee 类的实例,而是其某个子类的实例。
cocoa 里的类族
大部分 collection 类都是某个类族中的抽象基类,例如 NSArray 与其可变版本 NSMutableArray。实际上有两个抽象基类,一个用于不变数组,另一个用于可变数组。尽管具备公共接口的类有两个,但仍然可以合起来算作一个类族。不可变的类定义了对所有数组都通用的方法,而可变的类型则定义了那些只适用于可变数组的方法。两个类共属同一类族,这意味着二者在实现各自类型的数组时可以共用实现代码,此外,还能够把可变数组复制为不可变数组,反之亦然。
在使用 NSArray 的 alloc 方法来获取实例时,该方法首先会分配一个属于某类的实例,此实例充当"占位数组"(placeholder array)。该数组稍后会转为另一个类的实例,而那个类则是 NSArray 的实体子类。
若想判断某对象是否位于类族中,不要直接检测两个"类对象"是否等同,而应该采用下列代码:
objectivec
id maybeAnArray = ... ;
if ([maybeAnArray isKindOfClass:[NSArray class]]) {
...
}
在 Employee 这个例子中,若是没有 "工厂方法"的源代码,那就无法向其中新增雇员类别了。然而对于 Cocoa 中 NSArray 这样的类族来说,还是有办法新增子类的,但是需要遵守几条规则:
子类应该继承自类族中的抽象基类。
若要编写 NSArray 类族的子类,则需令其继承自不可变数组的基类或者可变数组的基类。
子类应该定义自己的数据存储方式。开发者编写 NSArray 子类时,经常在这个问题上受阻。子类必须用一个实例变量来存放数组中对象。这似乎与大家预想的不同,我们以为 NSArray 自己肯定会保存那些对象,所以在子类中就无须再存一份了。但是大家要记住, NSArray 本身只不过是包在其他隐藏对象外面的壳,它仅仅定义了所有数组都需要具备的一些接口。对于这个自定义的数组子类来说,可以用 NSArray 来保存其实例。
子类应当覆写超类文档中指明需要覆写的方法。在每个抽象基类中,都有一些子类必须覆写的方法。比如说,想要编写 NSArray 的子类,就需要实现 count 及 "objectAtIndex:"方法。像 lastObject 这种方法则无须实现,因为基类可以根据前两个方法实现出这个方法。在类族中实现子类时所需要遵循的规范一般都会定义于基类的文档之中,编码前应该先看看。
- 类族模式可以把实现细节隐藏在一套简单的公共接口后面。
- 系统框架中经常使用 类族。
- 从类族的公共抽象基类中继承子类时要当心,若有开发文档,则应首先阅读。
在既有类中使用关联对象存放自定义数据
有时需要在对象中存放相关信息。这时我们通常从对象所属的类中继承一个子类,然后改用这个子类对象。然而并非所有情况下都能这么做,有时候类的实例可能是由某种机制所创建的,而开发者无法令这种机制创建出自己所写的子类实例。Objective-C 中有一项强大的特性可以解决此问题,这就是"关联对象"。
理解objc_msgSend的作用
在对象上调用方法,OC上叫"消息传递"。消息有"名称"(name)或"选择子"(selector),可以接受参数,而且可能还有返回值。
C 语言使用"静态绑定",也就是说,在编译期就能决定运行时所调用的函数。例如:
c
#import <stdio.h>
void printA(void) {
printf("A");
}
void printB (void){
printf("B");
}
void doTing(int type) {
if (type == 0) {
printA();
} else {
printB();
}
}
但是如果这样写:
c
#import <stdio.h>
void printA(void) {
printf("A");
}
void printB (void){
printf("B");
}
void doTing(int type) {
void(*func)(void);
if (type == 0) {
func = printA;
} else {
func = printB;
}
}
这时就得使用"动态绑定"了,因为所要调用的函数直到运行期才能确定。编译器在这种情况下生成的指令与刚才那个例子不同,在第一个例子中,if 与 else 语句里面都有函数调用指令。而在第二个例子中,只有一个函数调用指令,不过待调用的函数地址无法硬编码在指令之中,而是要在运行期读取出来。
在 Objective-C 中,如果向某对象传递消息,那就会使用动态绑定机制来决定需要调用的方法。在底层,所有方法都是普通的 C 语言函数,然而对象收到消息之后,究竟该调用哪个方法则完全于运行期决定,甚至可以在程序运行时改变,这些特性使得 Objective-C 成为一门真正的动态语言。
给对象发送消息可以这样来写:
objectivec
id returnValue = [someObject messageName:parameter];
在本例中,someObject 叫做"接收者",messageName 叫做"选择子"。选择子与参数合起来称为"消息"。编译器看到此消息后,将其转换为一条标准的 C 语言函数调用,所调用的函数乃是消息传递机制中的核心函数,叫做 objc_msgSend,其原型是:
c
void objc_msgSend(id self, SEL cmd, ...)
这是个可变参数函数,第一个参数代表接收者,第二个参数代表选择子,后续参数就是消息中的那些参数,编译器会把刚才那个例子中的消息转换为如下函数:
objectivec
id returnValue = objc_msgSend(someObject, @selectro(messageName:), parameter);
objc_msgSend 函数会依据接收者与选择子的类型来调用适当的方法。为了完成此操作,该方法需要在接收者所属的类中搜寻其"方法列表",如果能找到与选择子名称相符的方法,就跳至其实现代码。若是找不到,那就沿着继承体系继续向上查找,等找到合适的方法之后再跳转。如果最终还是找不到相符的方法,那就执行"消息转发"操作。
objc_msgSend 会将匹配结果缓存在"快速映射表"里面,每个类都有这样一块缓存,若是稍后还向该类发送与选择子相同的消息,那么执行起来就很快了
其他特殊情况则需要交由 Objective-C 运行环境中的另一个函数来处理:
-
objc_msgSend_stret:如果待发送的消息要返回结构体,那么可交由此函数处理。只有当 CPU 的寄存器能够容纳得下消息返回类型时,这个函数才能处理此消息。若是返回值无法容纳于 CPU 寄存器中(比如说返回的结构体太大了),那么就由另一个函数执行派发。此时,那个函数会通过分配在栈上的某个变量来处理消息所返回的结构体。
-
objc_msgSend_fpret:如果消息返回的是浮点数,那么可交由此函数处理。在某些架构的 CPU 中调用函数时,需要对 "浮点数寄存器"(floating-point register)做特殊处理,也就是说,通常所用的 objc_msgSend 在这种情况下并不合适。这个函数是为了处理 x86 等架构 CPU 中某些令人稍觉惊讶的奇怪状况。
-
objc_msgSendSuper:如果要给超类发消息,例如[super message:parameter],那么就交由此函数处理。也有另外两个与 objc_msgSend 和 objc_msgSend_fpret 等效的函数,用于处理发给 super 的相应消息。
-
消息由接收者、选择子及参数构成。给某对象"发送消息"(invoke a message)(invoke 也是"调用"的意思,此处为了与"call"相区隔,将其临时译为"发送",也可能理解为"激发","触发") 也就相当于在该对象上"调用方法"(call a method)。
-
发给某对象的全部消息都要由"动态消息派发系统"(dynamic message dispatch system)来处理,该系统会查出对应的方法,并执行其代码。
理解消息转发机制
在编译期向类发送了其无法解读的消息并不会报错,因为在运行期可以继续向类中添加方法,所以编译器在编译时还无法确知类中到底会不会有某个方法实现。当对象接收到无法解读的消息后,就会启动"消息转发"机制,程序员可经由此过程告诉对象应该如何处理未知消息。
开发者在编写自己的类时,可于转发过程中设置挂钩,用以执行预定的逻辑,而不使应用程序崩溃。
消息转发分为两大阶段。
第一阶段先看接收者所属的类能否动态添加方法,以处理当前"未知的选择子",这叫做"动态方法解析"。
第二阶段涉及"完整的消息转发机制"。如果运行期系统已经把第一阶段执行完了,那么接收者自己就无法再以动态新增方法的手段来响应包含该选择子的消息了。此时,运行期系统会请求接收者以其他手段来处理与消息相关的方法调用。这又细分为两小步。首先,请接收者看看有没有其他对象能处理这条消息。若有,则运行期系统会把消息转给那个对象,于是消息转发过程结束,一切如常。若没有"备援的接收者",则启动完整的消息转发机制,运行期系统会把与消息有关的全部细节都封装到NSInvocation对象中,再给接收者最后一次机会,令其设法解决当前还未处理的这条消息。
动态方法解析
对象在收到无法解读的消息后,首先将调用其所属类的下列类方法:
objectivec
+ (BOOL)resolveInstanceMethod:(SEL)selector
该方法的参数就是那个未知的选择子,其返回值为Boolean类型,表示这个类是否能新增一个实例方法用以处理此选择子。
使用这种办法的前提是:相关方法的实现代码已经写好,只等着运行的时候动态插在类里面就可以了。
备援接收者
当前接收者还有第二次机会能处理未知的选择子,在这一步中,运行期系统会问它:能不能把这条消息转给其他接收者来处理。与该步骤对应的处理方法如下:
objectivec
- (id)forwardingTargetForSelector:(SEL)selector
方法参数代表未知的选择子,若当前接收者能找到备援对象,则将其返回,若找不到,就返回nil。通过此方案,我们可以用"组合"来模拟出"多重继承"的某些特性。
请注意,我们无法操作经由这一步所转发的消息。若是想在发送给备援接收者之前先修改消息内容,那就得通过完整的消息转发机制来做了。
完整的消息转发
如果转发算法已经来到这一步的话,那么唯一能做的就是启用完整的消息转发机制了。首先创建NSInvocation对象,把与尚未处理的那条消息有关的全部细节都封于其中。此对象包含选择子、目标(target)及参数。在触发NSInvocation对象时,"消息派发系统"把消息指派给目标对象。
此步骤会调用下列方法来转发消息:
objectivec
- (void)forwardInvocation:(NSInvocation*)invocation
这个方法可以实现得很简单:只需改变调用目标,使消息在新目标上得以调用即可。然而这样实现出来的方法与"备援接收者"方案所实现的方法等效,所以很少有人采用这么简单的实现方式。比较有用的实现方式为:在触发消息前,先以某种方式改变消息内容,比如追加另外一个参数,或是改换选择子等等。
实现此方法时,若发现某调用操作不应由本类处理,则需调用超类的同名方法。这样的话,继承体系中的每个类都有机会处理此调用请求,直至NSObject。如果最后调用了NSObject类的方法,那么该方法还会继而调用"doesNotRecognizeSelector:"以抛出异常,此异常表明选择子最终未能得到处理。
消息转发全流程
- 若对象无法响应某个选择子,则进入消息转发流程。
- 通过运行期的动态方法解析功能,我们可以在需要用到某个方法时再将其加入类中。
- 对象可以把其无法解读的某些选择子转交给其他对象来处理。
- 经过上述两步之后,如果还是没办法处理选择子,那就启动完整的消息转发机制。
用"方法调配技术"调试"黑盒方法"
在运行期改变与给定的选择子名称相对应的方法,此方案经常称为 "方法调配"。
类的方法列表会把选择子的名称映射到相关的方法实现之上,使得"动态消息派发系统"能够据此找到应该调用的方法。这些方法均以函数指针的形式来表示,这种指针叫做 IMP,其原型如下:
objectivec
id (*IMP)(id, SEL, ...)
- 在运行期,可以向类中新增或替换选择子所对应的方法实现。
- 使用另一份实现来替换原有的方法实现,这道工序叫做"方法调配",开发者常用此技术向原有实现中添加新功能。
- 一般来说,只有调试程序的时候才需要在运行期修改方法实现,这种做法不宜滥用。
理解"类对象"的用意
"在运行期检视对象类型"这一操作也叫做"类型信息查询",这个强大而有用的特性内置于Foundation框架的NSObject协议里,凡是由公共根类继承而来的对象都要遵从此协议。在程序中不要直接比较对象所属的类,明智的做法是调用"类型信息查询方法"。
每个Objective-C对象实例都是指向某块内存数据的指针。所以在声明变量时,类型后面要跟一个"*"字符:
objectivec
NSString *pointerVariable = @"Some string";
对于通用的对象类型id,由于其本身已经是指针了,所以我们能够这样写:
objectivec
id genericTypedString = @"Some string";
上面这种定义方式与用NSString*来定义相比,其语法意义相同。唯一区别在于,如果声明时指定了具体类型,那么在该类实例上调用其所没有的方法时,编译器会探知此情况,并发出警告信息。
id类型本身定义在这里:
c
typedef struct objc_object {
Class isa;
} *id;
由此可见,每个对象结构体的首个成员是Class类的变量。该变量定义了对象所属的类,通常称为"isa"指针。
Class对象也定义在运行期程序库的头文件中:
c
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;
};
此结构体存放类的"元数据",例如类的实例实现了几个方法,具备多少个实例变量等信息。此结构体的首个变量也是isa指针,这说明Class本身亦为Objective-C对象。
结构体里还有个变量叫做super_class,它定义了本类的超类。类对象所属的类型(也就是isa指针所指向的类型)是另外一个类,叫做"元类"(metaclass),用来表述类对象本身所具备的元数据。
"类方法"就定义于此处,因为这些方法可以理解成类对象的实例方法。每个类仅有一个"类对象",而每个"类对象"仅有一个与之相关的"元类"。
在类继承体系中查询类型信息
可以用类型信息查询方法来检视类继承体系。
"isMemberOfClass:"能够判断出对象是否为某个特定类的实例。
"isKindOfClass:"能够判断出对象是否为某类或其派生类的实例。
由于Objective-C使用"动态类型系统",所以用于查询对象所属类的类型信息查询功能非常有用。从collection中获取对象时,通常会查询类型信息,这些对象不是"强类型的",把它们从collection中取出来时,其类型通常是id。如果想知道具体类型,那就可以使用类型信息查询方法。
也可以用比较类对象是否等同的办法来做。若是如此,那就要使用==操作符。应该尽量使用类型信息查询方法。
- 每个实例都有一个指向Class对象的指针,用以表明其类型,而这些Class对象则构成了类的继承体系。
- 如果对象类型无法在编译期确定,那么就应该使用类型信息查询方法来探知。
- 尽量使用类型信息查询方法来确定对象类型,而不要直接比较类对象,因为某些对象可能实现了消息转发功能。