目录
第二章:对象、消息、运行期
对象之间能够关联与交互,这是面向对象语言的重要特征。本章讲述这些特,并深入研究代码在运行期的行为。
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