Objective-C(二)对象、消息、运行期

这是Objective-C系列的第2篇。

什么是属性

属性是Objective-C的一项特性,用于封装对象中的数据;

1 实例变量

1
2
3
4
5
6
@interface Person:NSObject {
@public
NSString *_firstName;
NSString *_lastName;
}
@end

在Java或C++中,我们可以定义实例变量的作用域。然而编写Objective-C却很少这么做。这种写法的问题是:对象布局在编译期就已经固定了。只要碰到访问_firstName变量的代码,编译器就把其替换成偏移量(offset),这个偏移量是硬编码,表示该变量距离存放对象的内存区域的起始地址有多远。

这样做,是不能应对增加一个实例变量这种情况带来的麻烦的,比如:

1
2
3
4
5
6
7
@interface Person:NSObject {
@public
NSString *_dateOfBirth;
NSString *_firstName;
NSString *_lastName;
}
@end

可以看到,实例变量中在内存中的地址偏移量的改变(假设指针为4个字节)。

所以,如果代码使用编译期计算出来的偏移量,那么在修改类定义之后必须重新编译,否则就会出错。

在Objective-C中,为了应对这种问题,把实例变量当做一种存储偏移量所用的“特殊变量”,交由“类对象”保管。偏移量会在运行期查找,如果类的定义变了,那么存储的偏移量也就变了。这样,无论何时访问实例变量,总能使用正确的偏移量。甚至可以在运行期向类中新增实例变量。

这个问题还有一种解决方法就是——尽量不要直接访问实例变量,而应该通过存取方法来做。虽说属性最终还是得通过实例变量来实现,但它却提供了一种简洁的抽象机制。

1
2
3
4
@interface Person:NSObject 
@property NSString *firstName;
@peoperty NSString *lastName;
@end

上面代码,编译器替我们做了两件事:

  • 生成存取方法;
  • 关联实例变量;

其中,关联的实例变量,是属性名前加“_”,即firstName属性对应添加的实例变量是“_firstName”。

当然,你也可以通过@synthesize指定关联的实例变量,但一般不推荐这么做!

1
2
3
@implementation Person
@synthesize firstName = _myFirstName;
@end

最后,存取方法,也可以自己实现。还有一种阻止编译器自动合成存取方法,就是使用@dynamic关键字,它告诉编译器:不要自动创建实现属性所用的实例变量的存取方法。而且,在编译访问属性的代码是,即使编译器发现没有定义存取方法,也不会报错,因为它相信能在运行期找到。

比如,从CoreData框架中的NSManagedObject类里继承给一个子类,那么就需要在运行期动态创建存取方法,因为子类的某些属性不是实例变量,其数据来自于后端的数据库。

2 属性特质

原子性

atomicnonatomic

具备atomic特质的获取方法会通过锁定机制来确保操作的原子性。如果两个线程读写同一属性,那么不论何时,总能看到有效的属性值。若是不加锁的话,那么当其中一个线程正在改写某属性值时,另外一个线程也许会突然闯入,把尚未修改好的属性值读取出来。

在iOS程序中,所有的属性都会声明为nonatomic。这样做的历史原因是:在iOS中使用同步锁的开销很大(Mac OS程序中,不会遇到性能瓶颈),将会带来性能问题。

一般情况下并不要求属性必须是“原子的”,因为这并不能保证“线程安全”,若要实现线程安全,还需采用更为深层的锁定机制才行。例如,一个线程在连续多次读取某属性值的过程中有别的线程在同时改写该值,那么即便将属性声明为atomic,也还是会读取到不同的属性值。

读写权限

readwirtereadonly

内存管理语义

属性用于封装数据,而数据则要有“具体的所有权语义”。

  • assign:只会执行针对“纯量类型”的简单赋值操作;
  • strong:此特质表明一种“拥有关系”,为这种属性设置新值时,设置方法会先保留新值,并释放旧值,然后再将新值设置上去;
  • weak:此特质表明该属性定义了一种“非拥有关系”。为这种属性设置新值时,设置方法既不保留新值,也不释放旧值。与assign类似,然后在属性所指的对象遭到摧毁时,属性值会被清空(nil out)。
  • unsafe_unretained:此特质的语义和assign类似,但是它适用于“对象类型”,表达一种“非拥有关系”。当目标对象遭到摧毁是,属性值不会被清空。
  • copy:此特质所表达的所属关系和strong类似。然而设置方法并不保留新值,而是将其拷贝。

在对象内部尽量直接访问实例变量

通过点语法与直接访问内部实例变量的区别在于:

  • 由于不经过Objective-C的“方法派发”(method dispatch)步骤,所以直接访问实例变量的速度会比较快在这种情况下,编译器所生成的代码会直接访问对象实例变量的那块内存;
  • 直接访问实例变量,不会调用其“设置方法”,这就绕过了为相关属性所定义的内存管理语义。比如,在ARC下,直接访问一个声明为copy的属性,那么并不会拷贝该属性,只会保留新值并释放旧值;
  • 如果直接访问实例变量,那么不会触发“键值观察(key-value observing)”通知,这样做是否会产生问题,取决于具体的对象行为;
  • 通过属性来访问有助于排查与之相关的错误,因为可以在“获取方法”与“设置方法”中打断点,监控该属性的调用者及其访问时机;

所以,合理的方案是:写入实例变量时,通过其“设置方法”来做,而在读取实例变量时,则直接访问之

读取直接访问,是为了提高访问速度,而写入则为了保留内存管理语义。

这样做需要注意下面两点:

  1. 在初始化方法中应该如何设置属性值?
  2. 惰性初始化

理解“对象等同性”

根据等同性(equality)来比较对象是一个非常有用的功能。不过按照==操作符比较出来的结果未必是我们想要的,因为该操作符比较的是两个指针本身,而不是其所指的对象。应该使用NSObject协议中声明的isEqual:方法来判断两个对象的等同性。某些对象还提供了特别的等同性判断方法,比如NSStirng类提供的isEqualToString:

1
2
3
4
5
6
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

针对NSString可以看出:

  • == :比较两个对象的指针是否一致;
  • isEqaual: :比较两个对象的字符串是否一致;

isEqualToString方法在这里和isEqual是等效的,但是isEqualToString更快,因为后者由于不知道受测对象的类型,还需要执行额外的步骤。

NSObject协议中有两个用于判断等同性的关键方法:

1
2
- (BOOL)isEqual:(id)object;
@property (readonly) NSUInteger hash;

NSObject对这两个方法的默认实现是:当前仅当其“指针值”完全相等时,这两个对象才相等。若想在自定义的对象中重写这些方法,就必须理解其约定;

如果isEqual:方法判断两个对象相等,那么其hash值必定相等。

反过来,如果其hash值相等,那么isEqual:方法未必会认为两者相等。

下面有个类,我们认为其所有字段相等,那么这两个对象就相等。重写其isEqual:如下:

1
2
3
4
@interface JSDog : NSObject
@property (nonatomic ,assign)NSInteger age;
@property (nonatomic ,copy)NSString *name;
@end

@implementation JSDog中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (BOOL)isEqual:(id)object
{
if (self == object) {
return YES;
}

if ([self class] != [object class]) {
return NO;
}

JSDog *otherDog = (JSDog*)object;
if (_age != otherDog.age) {
return NO;
}

if (![_name isEqualToString:otherDog.name]) {
return NO;
}

return YES;
}

这是一种比较典型的写法,分析一下:

  • 首先判断两个指针是否一致,如果一致,毫无疑问,两个对象是相等的;
  • 其次,判断两个类是否一致,假如类不一致,那么便不相等,但是此处需要注意的是,在业务中需要继承体系上认为相等的,比如JSDog实例可以JSAnimal(父类)相等的情况要考虑,即继承体系上的父子关系是否相等需要业务来做决定。
  • 最后,判断各个字段是否相等,只有全部相等两个对象才算相等,否则,就不相等。

接下来,看一下hash方法,首先,假如我们认为isEqual:相等了,那么hash必相等,我们简单的可以如下:

-(NSUInteger)hash
{
    return 12345;
}

这么写,一看就知道有问题,因为不管什么情况,都返回相等的hash值。那么是什么问题呢?

在collection中检索对象是依靠哈希表(hash table)时,会用对象的哈希码做索引。假如某个collection使用set来实现的,那么set可能会根据哈希码将对象分装到不同的数组(也成为箱子)中。在向set中添加新对象时,要根据其哈希码找到与之相关的那个数组,依次检查其中各个元素,判断其是否相等。如果相等,就说明要添加的对象已经在set里面了。

问题来了,假如哈希码都一样,我们不是要每依次判断对象是否相等,假如现在数组中已经有10000个对象,那么我再加入一个对象时,由于哈希码一致,我们要做10000次的对象是否相等的判断,效率低下,性能堪忧。所以,我们要改进我们的hash方法,至少要根据不同的属性返回不同的hash值,下面是改进的版本:

1
2
3
4
5
-(NSUInteger)hash
{
NSString *stringToHash = [NSString stringWithFormat:@"%@:%ld",_name,(long)_age];
return [stringToHash hash];
}

上面的hash方法可以根据不同的属性返回不同的hash值,但是该hash方法,仍然要负担创建字符串的开销,所以比返回单一值要慢,由于计算哈希码的开销过大,也许在collection中仍然会出现性能问题。

1
2
3
4
5
6
7
-(NSUInteger)hash
{
NSUInteger ageHash = _age;
NSUInteger nameHash = [_name hash];

return ageHash ^ nameHash;
}

这里,避免了创建字符串的开销,又能使生成的哈希码至少位于一定的范围内,而不会过于频繁的重复。当然,这种算法生成的哈希码避免不了完全不碰撞。所以在设计哈希算法是要在碰撞频度与降低运算复杂程度之间取舍。

1. 特定类所具有的等同性判定方法

除了NSString中,具有isEqualToString:这种特定类的判定方法,NSArray具有isEqualToArray:,以及NSDictionary具有isEqualToDictionary:

自己来写特定类判定方法时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
- (BOOL)isEqual:(id)object
{
if ([self class] == [object class]) {
return [self isEqualToDog:(JSDog *)object];
}else{
return [super isEqual:object];
}
}

- (BOOL)isEqualToDog:(JSDog*)otherDog
{
if (self == otherDog) {
return YES;
}

if ([self class] != [otherDog class]) {
return NO;
}

if (_age != otherDog.age) {
return NO;
}

if (![_name isEqualToString:otherDog.name]) {
return NO;
}

return YES;
}

2. 等同性判定的执行深度

创建等同性判定方法时,需要决定是根据整个对象来判断等同性,还是仅根据其中几个字段来判断。

NSArray的检测方式是先看两个数组所含对象个数是否一致,若相同,则在每个对应位置的两个对象身上调用“isEqual:”方法。如果对应位置上的对象都相等,那么则两个数组相等,这叫做“深度等同性判定”。

不过,有时候我们仍然可以根据业务来将深度维度降下来,只根据其中某一个属性来判定。比如JSDog存储在数据库表中有个identifier的唯一标识符,假如此标识符相同,我们就认为这是同一条🐶,无需多做其他判断,这种降维的工作一般由业务驱动,而不是凭空构想的。

3. 容器中可变类的等同性

看一个实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
NSMutableSet *set = [NSMutableSet set];
//1.
NSMutableArray *arrayA = [@[@1,@2] mutableCopy];
[set addObject:arrayA];
NSLog(@"set = %@",set);

//2.
NSMutableArray *arrayB = [@[@1,@2] mutableCopy];
[set addObject:arrayB];
NSLog(@"set = %@",set);

//3.
NSMutableArray *arrayC = [@[@1] mutableCopy];
[set addObject:arrayC];
NSLog(@"set = %@",set);

//4.
[arrayC addObject:@2];
NSLog(@"set = %@",set);

//5.
NSSet *setB = [set copy];
NSLog(@"setB = %@",setB);

打印出来的log:

1
2
3
4
5
set = {(         (1,2) )}
set = {( (1,2) )}
set = {( (1), (1,2) )}
set = {( (1,2), (1,2) )}
setB = {( (1,2) )}

第一步,添加arrayA是正常的,第二步添加arrayB,由于set中arrayA与arrayB相等,所以set仍然只有一个对象也是正常的。第三步,添加了一个set中没有的arrayC,正常的。

第四步,将arrayC中数组,添加一个元素2,使得arrayC此时与arrayA相等了,可奇怪的是,居然同时存在于set中;

第五步,将此时包含了两个相等的数组arrayA,arrayC的set进行copy,得到setB,又发生了奇怪的事,两个相同的数组只剩下一个。

把某个对象放入了collection中,就不应该改变其哈希码,像上面的情况就是在讲arrayC加入到set后,又更改了arrayC的哈希码。collection会根据哈希码将不同的对象放入到不同的“箱子数组”中,如果某对象在放入“箱子”之后,哈希码改变,那么现在所处的这个箱子对它来说就是错误的。

所以,要么确保哈希码不是根据对象的“可变部分”计算出来的,要么保证放入collection中的对象是不再可变的。

  • 若想检测对象的等同性,请提供isEqual:hash方法;
  • 相同的对象必须有相同的hash码,但是hash码相同的对象却未必相同;
  • 不要盲目地检测每个属性,而是应该按照具体需求来制定检测方案;
  • 编写hash方法时,应该使用计算速度快而且哈希码碰撞率低的算法。

new与alloc/init

  • new 实际调用alloc/init方法,等效;
  • new 不支持自定义init方法;
  • alloc-init 更清晰;

@synchronized

指令@synchronized()通过对一段代码的使用进行加锁。其他试图执行该段代码的线程都会被阻塞,直到加锁线程退出执行该段被保护的代码段,也就是说@synchronized()代码块中的最后一条语句已经被执行完毕的时候。 一般在公用变量的时候使用,如单例模式或者操作类的static变量中使用。

指令@synchronized()需要一个参数。该参数可以使任何的Objective-C对象,包括self。这个对象就是互斥信号量。他能够让一个线程对一段代码进行保护,避免别的线程执行该段代码。针对程序中的不同的关键代码段,我们应该分别使用不同的信号量。只有在应用程序编程执行多线程之前就创建好所有需要的互斥信号量对象来避免线程间的竞争才是最安全的。