对象:“对象”(object)就是“基本构造单元”(building block),开发者可以通过对象来储存并传递数据。
消息:在对象之间传递数据并执行任务的过程就叫做“消息传递”(Messaging)。
运行期:当应用程序运行起来以后,为其提供相关支持代码叫做“Objectivec- C运行期环境”(Objective-C runtime),它提供了一些使得对象之间能够传递消息的重要函数,并且包含创建类实例所用的全部逻辑。
属性
“属性”(property)是Objective-C的一项特性,用于封装对象中的数据。Objective-C对象通常会把其所需要的数据保存为各种实例变量。
偏移量
“偏移量”(offset)是“硬编码”(hardcode),表示该变量距离存放对象的内存区域的起始地址有多远。
例:
@interface EOCPerson : NSObject {
@public
NSString *_firstName;
NSString *_lastName;
@private
NSString *_someInternalData;
}
@end
假设指针为4个字节:
应用程序二进制接口(Application Binary Interface, ABI)
不兼容现象(incompatibility)
在Java或C++语言里可以定义实例的作用域,然而编写Objective-C代码时却很少这么做。这种写法的问题是:对象布局在编译期(compile time)就已经固定了。此时,如果又加了一个实例变量,那就麻烦了。比如说,假设在_firstName之前又多了一个实例变量:
@interface EOCPerson : NSObject {
@public
NSDate *_dateOfBirth;
NSString *_firstName;
NSString *_lastName;
@private
NSString *_someInternalData;
}
@end
数据布局图就会变成这样:
原来表示_firstName的偏移量现在却指向_dateOfBirth了。把偏移量硬编码于其中的那些代码都会读取到了错误的值。
如果代码使用了编译期计算出来的偏移量,那么在修改类定义之后必须重新编译,否则就会出错。某个代码库中的代码使用了一份旧的类定义。如果和其相链接的代码使用了新的类定义,那么运行时就会出现不兼容现象(incompatibility)。
ABI定义
Objective-C的做法是,把实例变量当作一种储存偏移量所用的“特殊变量”(special variable),交由“类对象”(class object)保管。偏移量会在运行期查找,如果类的定义变了,那么储存的偏移量也就变了,这样的话,无论何时访问实例变量,总能使用正确的偏移量。这就是稳固的应用程序二进制接口(Application Binary Interface, ABI)。
存取方法
Objective-C对象通常会把所需要的数据保存为各种实例变量。实例变量一般通过“存取方法”(access method)来访问。
- 获取方法:getter,用于读取变量值。
- 设置方法:setter,用于写入变量值。
自动创建存取方法
编译器可以自动写出一套存取方法,用以访问给定类型中具有给定名称的变量。例如下面这个类:
@interface EOCPerson : NSObject
@property NSString *firstName;
@property NSString *lastName;
@end
对于该类的使用者来说,上述代码写出来的类于下面这种写法等效:
@interface EOCPerson : NSObject
- (NSString*)firstName;
- (void)setFirstName:(NSString*)firstName;
- (NSString*)lastName;
- (void)setLastName:(NSString*)lastName;
@end
点语法
此特性引入了一种新的“点语法”(dot syntax),使开发者可以更为容易地依照类对象来访问存放与其中的数据。编译器会把“点语法”转换为对存取方法的调用,使用“点语法”的效果与直接调用存取方法相同。
例:
EOCPerson* aPerson = [[EOCPerson alloc] init];
aPerson.firstName = @"Bob"; //Same as:
[aPerson setFirstName:@"Bob"];
NSString* lastName = aPerson.lastName; // Same as:
NSString* lastName = [aPerson lastName];
属性特质
使用属性时,各种特质(attribute)设定也会影响编译器所生成的存取方法。
详情可参考:属性关键字
要点
- 可以通过@property语法来定义对象中所封装的数据。
- 通过“特质”来指定储存数据所需的正确语义。
- 在设置属性所对应的实例变量时,一定要遵传该属性所声明的语义。
- 开发iOS程序时应该使用nonatomic属性,因为atomic属性会严重影响性能。
在对象内部尽量直接访问实例变量
在对象之外访问实例变量时,总是应该通过属性来做,然而在对象内部访问实例变量时又该如何呢?
在实例变量的获取方法与设置方法中,通过存取方法和不经由存取方法访问实例变量有这几个区别:
- 由于不经过Objective-C的“方法派发”(method dispatch)步骤,所以直接访问实例变量的速度当然比较快。在这种情况下,编译器所生成的代码会直接访问保存对象实例变量的那块内存。
- 直接访问实例变量时,不会调用其“设置方法”,这就绕过了为相关属性所定义的“内存管理语义”。比方说,如果直接访问一个声明为copy的属性,那么并不会拷贝该属性只会保留新值并释放旧值。
- 如果直接访问实例变量,那么不会触发“键值观测”(Key-Value Observing, KVO)通知。这样做是否会产生问题,还取决于具体的对象行为。
- 通过属性来访问有助于排查与之相关的错误,因为可以给“获取方法”和/或“设置方法”中新增“断点”(breakpoint),监控该属性的调用者及其访问时机。
折中方案
在写入实例变量时,通过“设置方法”来做,而在读取实例变量时,则直接访问之。
优点:既能提高读取操作的速度,又能控制对属性的写入操作。
需要注意的问题
在初始化方法中应该如何设置属性值
这种情况下总是应该直接访问实例变量,因为子类可能会“覆写”(override)设置方法。
假设EOCPerson有一个子类叫做EOCSmithPerson,这个字类专门表示那些性“Smith”的人。该字类可能会覆写lastName属性所对应的设置方法:
- (void) setFirstName:(NSString *)firstName {
if (![firstName isEqualToString:@"Smith"]) {
NSLog(@"llllll");
}
self.firstName = firstName;
}
在基类EOCPerson的默认初始化中,可能会将姓氏设为空字符串。此时若是通过“设置方法”来做,那么调用的将会是字类的设置方法,从而抛出异常。
惰性初始化
对某些复杂又不常用,而且创建成本高的属性,我们可能会在“获取方法”中对其执行惰性初始化:
- (EOCBrain*)brain {
if (!_brain) {
_brain = [Brain new];
}
return _brain;
}
在这种情况下,必须使用存取方法来访问brain属性。
要点
- 在对象内部读取数据时,应该直接通过实例变量来读,而写入数据时,则应通过属性来写。
- 在初始化方法及dealloc方法中,总是应该直接通过实例变量来读写数据。
- 有时会使用惰性初始化技术配置某份数据,这种情况下,需要通过属性来读取数据。
理解“对象等同性”这一概念
根据“等同性”(equality)来比较对象是一个非常有用的功能。此时,我们需要使用NSObject协议中声明的“isEqual”:方法来判断两个对象的等同性。一般来说,两个类型不同的对象总是不相等的(unequal)。
以下述代码为例:
NSString* foo = @"Badger 123";
NSString* bar = [NSString stringWithFormat:@"Badger %i", 123];
BOOL equalA = (foo == bar); // < equalA = NO
BOOL equalB = [foo isEqual:bar]; // < equalB = YES
BOOL equalC = [foo isEqualToString:bar]; // < equalC = YES
大家可以看到 == 与等同性判断方法之间的差别。
用于判断等同性的关键方法
- (BOOL)isEqual:(id)object;
- (NSUInteger)hash;
NSObject类对这两个方法的默认实现是:当且仅当其“指针值”(pointer value)完全相等时,这两个对象才相等。
约定(contract)
- 如果“isEqual:”方法判断两个对象相等,那么其hash方法也必须返回同一个值。
- 如果两个hash方法返回同一个值,那么“isEqual:”方法未必会认为两者相等。
例:
@interface EOCPerson : NSObject
@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;
@property (nonatomic, assign) NSUInteger age;
@end
我们认为,如果两个EOCPerson的所有字段均相等,那么这两个对象就相等。于是“isEqual:”方法可以写成:
- (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方法:
- (NSUIngeter)hash {
return 1337;
}
collection性能问题
如果在collection中使用上面的对象将产生性能问题,因为collection在检索哈希表(hash table)时,会用对象的哈希码做索引。假如某个collection是用set实现的,那么set可能会根据哈希码把对象分装到不同的数组中。在向set中添加新对象时,要根据其哈希码找到与之相关的那个数组,依次检查其中各个元素。由此可知,如果每个对象都返回相同的哈希码,那么在set中已有1000000个对象的情况下,若是继续向其中添加对象,则需将这个1000000个对象全部扫描一遍。
hash方法也可以这样来实现:
- (NSUInteger)hash {
NSString* stringToHash = [NSString stringWithFormat:@"%@:%@:%lu", _firstName, _lastName, _age];
return [stringToHash hash];
}
这个方法是将NSString对象中的属性都塞入另一个字符串中,然后令hash方法返回该字符串的哈希码。但是这样做还需负担创建字符串的开销,所以返回单一值要慢。因为必须先计算其哈希码,把这种对象添加到collection中也会产生性能问题。
最后一种方法:
- (NSUInteger)hash {
NSUInteger firstNameHash = [_firstName hash];
NSUInteger lastNameHash = [_lastName hash];
NSUInteger ageHash = _age;
return firstNameHash ^ lastNameHash ^ ageHash;
}
优点:这种做法既能保持较高效率,又能使生成的哈希码至少位于一定范围之内,而不会过于频繁地重复。
缺点:此算法生成的哈希码还是会碰撞(collision)。
编写hash方法时,应该用当前的对象做做实验,以便在减少碰撞频度与降低运算复杂程度之间取舍。
特定类所具有的等同性判定方法
如果经常需要判断等同性,那么可能会自己来创建等同性判定方法,因为无须检测参数类型,所以能大大提升检测速度。
在编写判定方法时,也应一并覆写“isEqual:”方法。
例:
- (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];
}
}
等同性判定的执行深度
创建等同性判定方法时,需要决定是根据整个对象来判断等同性,还是仅根据其中几个字段来判断。
深度等同性判定(deep equality)
例如,NSArray的检测方式为先看两个数组所含对象个数是否相同,若相同,则在每个对应位置地两个对象身上调用其“isEqual:”方法。如果对应位置上的对象均相等,那么这两个数组就相等,这就叫“深度等同性判定”。
唯一标识符(unique identifier)
有些类可能会含有另外一个属性,此属性是“唯一标识符”,在数据库中用作“主键”(primary key):
@property NSUInteger identifier;
在这种情况下,我们也许只会根据标识符来判断等同性。因为只要两者标识符相同,就肯定表示同一个对象,因而必然相等。
容器中可变类的等同性
在容器中放入可变类对象的时候,就不应在改变其哈希值了。
用一个NSMutableSet与几个NSMutableArray对象测试一下,就能发现这个问题。
- 先把一个数组加入set中。
NSMutableSet* set = [NSMutableSet set];
NSMutableArray* arrayA = [@[@1, @2] mutableCopy];
[set addObject:arrayA];
NSLog(@"set = %@", set);
// Output : set = {((1, 2))}
现在set里含有一个数组对象,数组中包含两个对象。
- 在向set里加入一个与前一个数组相同的数组。
NSMutableSet* set = [NSMutableSet set];
NSMutableArray* arrayA = [@[@1, @2] mutableCopy];
[set addObject:arrayA];
NSLog(@"set = %@", set);
// Output : set = {((1, 2))}
NSMutableArray* arrayB = [@[@1, @2] mutableCopy];
[set addObject:arrayB];
NSLog(@"set = %@", set);
// Output : set = {((1, 2))}
此时set里仍然只有一个对象:因为数组B与数组A相同,所以set不会改变。
3. 加入一个和set中已有对象不同的数组。
NSMutableArray* arrayC = [@[@1] mutableCopy];
[set addObject:arrayC];
NSLog(@"set = %@", set);
// Output : set = {((1), (1, 2))}
现在set里有两个数组。
4. 改变数组C的内容,令其和最早加入的那个数组相等。
[arrayC addObject:@2];
NSLog(@"set = %@", set);
// Output : set = {((1, 2), (1, 2))}
set中居然包含了两个相等的数组!这是不允许的。
所以把某对象放入set之后又修改其内容。那么后来的行为将很难预测。
要点
- 若想检测对象的等同性,请提供“isEqual:”方法与hash方法。
- 相同的对象必须具有相同的哈希码,但是两个哈希码相同的对象却未必相同。
- 不要盲目地逐个检测每条属性,而是应该依照具体需求来制定检测方案。
- 编写hash方法时,应该使用计算速度快而且哈希码碰撞机率低的算法。
以“类族模式”隐藏实现细节
“类族”(class cluster)是一种很有用的模式,可以隐藏“抽象基类”背后的实现细节。Objective-C的系统框架中普遍使用此模式。
创建类族
现在举例来演示如何创建类族。假设有一个处理雇员的类,每个雇员都有“名字”和“薪水”这两个属性,管理者可以命令其执行日常1工作。但是,各类雇员的工作内容不同。经理在带领雇员做项目时,无须关心每个人如何完成其工作,仅需指示其开工即可。
- 定义抽象基类。
typedef NS_ENUM(NSUInteger, EOCEmplyeeType) {
EOCEmplyeeTypeDeveloper,
EOCEmplyeeTypeDesigner,
EOCEmplyeeTypeFinance,
};
@interface EOCEmplyee : NSObject
@property (nonatomic, copy) NSString* name;
@property (nonatomic, assign) NSUInteger salary;
// Helper for creating Employee
+ (EOCEmplyee*)employeeWithType:(EOCEmplyeeType)type;
// Make Employees do their respective day's work
- (void)doADaysWork;
@end
@implementation EOCEmplyee
+ (EOCEmplyee*)employeeWithType:(EOCEmplyeeType)type {
switch (type) {
case EOCEmplyeeTypeDeveloper:
return [EOCEmplyeeDeveloper new];
break;
case EOCEmplyeeTypeDesigner:
return [EOCEmplyeeDesigner new];
break;
case EOCEmplyeeTypeFinance:
return [EOCEmplyeeFinance new];
break;
}
}
- (void)doADaysWork {
// Subclasses implement this.
}
@end
- 每个实体子类都从基类基础而来。
例:
@interface EOCEmplyeeDeveloper : EOCEmplyee
@end
@implementation EOCEmplyeeDeveloper
- (void)doADaysWork {
// ......
}
@end
这种根据待创建的雇员类别分配好对应的雇员类实例叫“工厂模式”。
Coacoa里的类族
系统框架中有许多类族。大部分collection类都是类族,例如NSArray与其可变版本NSMutableArray。这两个类共属于一个类族,这意味着二者在实现各自类型的数组时可以共用实现代码,此外,还能够把可变数组复制为不可变数组,反之亦然。
对于Cocoa中NSArray这样的类族来说,还是有办法新增子类的,但是需要遵守几条规则。这几条规则如下。
- 子类应该继承自类族中的抽象基类。
若要编写NSArray类族的子类,则需令其继承自不可变数组的基类或可变数组的基类。 - 子类应该定义自己的数据储存方式。
子类必须用一个实例变量来存放数组中的对象。因为NSArray本身只不过是包在其他隐藏对象外面的壳,它仅仅定义了所有数组都需具备的一些接口。对于这个自定义的数组子类来说,可以用NSArray来保存其实例。 - 子类应当覆写超类文档中需要覆写的方法。
在每个抽象基类中,都有一些子类必须覆写的方法。
在类族中实现子类时所需要遵循的规范一般都会定义与基类的文档之中,编码前应该先看看。
要点
- 类族模式可以把实现细节隐藏在一套简单的公共接口后面。
- 系统框架中经常使用类族。
- 从类族的公共抽象基类中继承子类时要当心,若有开发文档,则应首先阅读。
在既有类中使用关联对象存放自定义数据
有时需要在对象中存放相关信息。这时我们通常会从对象所属的类中继承一个子类,然后改用这个子类对象。然而并非所有情况下都能这么做,有时候类的实例可能是由某种机制所创建的,而开发者无法令这种机制创建出自己所写的子类实例。Objective-C中有一项强大的特性可以解决此问题,这就是“关联对象”(Associated Object)。
关联对象
可以给某对象关联许多其他对象,这些对象通过“键”来区分。存储对象值的时候,可以指明“存储策略”(storage policy),用于维护相应的“内存管理语义”。
存储策略
存储策略由名为objc_AssociationPolicy的枚举所定义。
对象关联类型图:
下列方法可以管理关联对象:
- void objc_setAssociatedObject (id object, void *key, id value, objc_AssociationPolicy) 此方法 以给定的键和策略为某对象设置关联对象值。
- id objc_getAssociatedObject (id object, void *key) 此方法根据给定的键从某对象中获取相应的关联对象值。
- void objc_removeAssociatedObjects (id object) 此方法移除指定对象的全部关联对象。
关联对象与NSDictionary关于键值的不同
NSDictionary:如果在两个键上调用“isEqual:”方法的返回值是YES,那么NSDictionary就认为二者相等。
关联对象:二者必须是完全相同的指针。
**在设置关联对象时,弱小令两个键匹配到同一个值,则二者必须是完全相同的指针才行。
要点
- 可以通过“关联对象”机制来把两个对象连起来。
- 定义关联对象时可指定内存管理语义,用以模仿定义属性时所采用的“拥有关系”与“非拥有关系”。
- 只有在其他做法不可行时才应选用关联对象,因为这种做法通常会引入难以查找的bug。
理解objc_msgSend的作用
在对象上调用方法在Objective-C中叫做“传递消息”。消息有“名称”(name)或“选择子”(selector),可以接受参数,而且可能还有返回值。
C语言的函数调用方式使用“动态绑定”(static binding),也就是说,在编译器就能决定运行时所应调用的函数。
在Objectivec-C中,如果向某对象传递消息,那就会使用动态绑定机制来决定需要调用的方法。即在对象收到消息之后,究竟该调用那个方法完全于运行期决定,甚至可以在程序运行时改变。这些特性使得Objective-C成为一门真正的动态语言。
obj_msgSend函数介绍
obj_msgSend函数是消息传递机制中的核心函数,其原型如下:
void objc_msgSend(id self, SEL cmd, ...)
给对象发宋消息可以这样来写:
id rerurnValue = [someObject messageName:parameter];
在本例中,someObject叫做“接收者”(receiver),messageName叫做“选择子”(selector)。选择子与参数合起来称为消息。编译器看到消息后,将其转化成上面的objc_msgSend函数调用。如下:
id returnValue = objc_msgSend(someObject, @selector(messageName), parameter);
objc_msgSend函数是一个参数可变的函数。第一个参数代表接受者,第二个参数代表选择子(SEL是选择子的类型,指的是方法的名字),后续参数就是消息中的那些参数,其顺序不变。
obj_msgSend函数作用
objc_msgSend函数会依据接受者与选择子的类型来调用适当的方法。
该方法会在接受者所属的类中搜寻其“方法列表”(list of methods),如果能找到与选择子名称相符的方法,就跳至其实现代码。若是找不到,那就沿着继承体系继续向上查找,等找到合适的方法之后再跳转。如果最终还是找不到相符的方法,那就执行“消息转发”(message forwarding)操作。
objc_msgSend会将匹配结果缓存在“快速映射表”(fast map)里面,每个类都有这样一块缓存,若稍后还向该类发送与选择子相同的消息,那么执行起来就很快了。
其他边界情况时调用的函数
- objc_msgSend_stret。如果待发送的消息要返回结构体,那么可交由此函数处理。只有当CPU的寄存器能够容纳得下消息返回类型时,这个函数才能处理此消息。若返回值无法容纳与CPU寄存器中(比如说返回的结构体太大了),那么就由另一个函数执行派发。此时,那个函数会通过分配在栈上的某个变量来处理消息所返回的结构体,
- objc_msgSend_fpret。如果消息返回的是浮点数,那么可交由此函数处理。在某些架构的CPU中调用函数时,需要对“浮点数寄存器”(floating-point register)做特殊处理,也就是说,通常所用的objc_msgSend在这种情况下并不合适。这个函数是为了处理x86等架构CPU中某些令人稍觉惊讶的奇怪状况。
- obj_msgSendSuper。如果要给超类发消息,例如[super message:parmeter],那么就交由此函数处理。也有另外两个与objc_msgSend_stret和objc_msgSend_fpret等效的函数,用于处理发给super的相应消息。
尾调用优化
Objective-C对象的每个方法都可以视为简单的C函数,其原型如下:
<return _type> Class _selector(id self, SEL _cmd, ...)
每个类里都有一张表格,其中的指针都会指向这种函数,而选择子的名称则是查明时所用的“键”。objc_msgSend等函数正是通过这张表格来寻找应该执行的方法并跳至其实现的。
请注意,原型的样子和objc_msgSend函数很像。这不是巧合,而是为了利用“尾调用优化”技术,令“跳至方式实现”这一操作变得跟简单。
如果某函数的最后一项操作是调用另外一个函数,那么就可以运用“尾调用优化”技术。编译器会生成调转至另一函数所需的指令码,而且不会向调用堆栈中推入新的“栈桢”(frame stack)。只有当某函数的最后一个操作仅仅是调用其他函数而不会将其返回值另作他用时,才能执行“尾调用优化”。这项优化对objc_msgSend非常关键,如果不这么做的话,那么每次调用Objective-C方法之前,都需要为调用objc_msgSend函数准备“栈桢”,大家在“栈踪迹”(stack trace)中可以看到这种“栈桢”。此外,若是不优化,还会过早地发生“栈溢出”(stack overflow)现象。
要点
- 消息由接收者。选择子及参数构成。给某对象“发生消息”(invoke a message)也就相当于在该对象上“调用方法”(call a method)。
- 发给某对象的全部消息都要由“动态消息派发系统”(dynamic message dispatch system)来处理,该系统会查出对应的方法,并执行其代码。
理解消息转发机制
当对象接收到无法解读的消息后,就会启动“消息转发”(message forwarding)机制,程序员可经由此过程告诉对象应该如何处理未知消息。
开发者在编写自己的类时,可于转发过程中设置挂钩,用以执行预定的逻辑,而不使应用程序崩溃。
消息转发分为两大阶段
- 征询接收者,所属的类,看其是否能动态添加方法,以处理当前这个“未知的选择子”(unknown selector),这叫做“动态方法解析”(dynamic method resolution)。
- 如果运行期系统已经把第一阶段执行完了,那么接受者自己就无法再以动态新增方法的手段来响应包含该选择子的消息了。此时,运行期系统会请求接收者以其他手段来处理与消息相关的方法调用。
这又细分为两小步:- 请看接收者看看有没有其他对象能处理这条消息。
- 若有,则运行期系统会把消息转给那个对象。
- 若没有,运行期系统会把与消息有关的全部细节都封装到 NSInvocation对象中,再给接收者最后一次机会,令其设法解决当前还未处理的这条消息。