Objective-C对象的整理与思考

内存模型

对象本质是 c 中的结构体,Objective-C 中对对象的定义为struct objc_object,其中isa是它唯一的私有成员变量。在内存模型中,每个对象除了isa指针,还包括从顶级父类到直接父类,以及自己的所有成员变量。

类对象

isa指针指向的是对象的类,对象的类本质上也是对象,称为类对象。类对象描述了对象的数据:对象占用的内存大小、成员变量的类型和布局等,而且也描述了对象的行为:对象能够响应的消息、实现的实例方法等:

  • 对象的方法列表(对象能够接收的消息列表,保存在它所对应的类对象中)
  • 成员变量列表
  • 属性列表

因此,当我们调用[receiver message]时,首先通过isa在类对象中查找message,找到则执行,否则沿着继承链继续在 superclass 中找。类对象中保存着类的继承关系,类对象中有一个superclass指针,指向它的父类对象。大部分对象都继承自NSObject,另一个根类是NSProxy。根类的superclass指针指向nil

既然类对象是一个对象,那么它也是其他类的实例,这个类叫做元类(metaclass)。其实,每个类对象都是元类的一个单例。

元类

元类其实也是对象,它保存着类的数据与类方法列表。与类对象不同的是,所有的元类都是根元类的实例,即isa指针都指向根元类,而根元类的isa指针指向自己。根元类也继承自根类。

比如在调用类方法时,[NSObject new],类对象是否能相应这个消息是通过isa到元类中查找才能知道。

理论上我们也可以给元类发送消息,但是 Objective-C 倾向于隐藏元类,不想让大家知道元类的存在。元类是为了保持 Objective-C 对象模型在设计上的完整性而引入的,比如用来保存类方法等,它主要是用来给编译器使用的。

属性

属性用@property定义,那么属性的本质是什么?

@property = ivar + getter + setter;

下面解释下:

属性(property)有两大概念:ivar(实例变量)、存取方法(access method = getter + setter)

也就是说,定义一个属性后,编译器会自动帮你定义一个与之对应的实例变量,并生成对该实例变量的存取方法。

property 在 runtime 中为objc_property_t

1
typedef struct objc_property *objc_property_t;

objc_property是一个结构体,定义如下:

1
2
3
4
struct property_t {
const char *name;
const char *attributes;
};

而attributes本质是objc_property_attribute_t,定义了property的一些属性,定义如下:

1
2
3
4
5
/// Defines a property attribute
typedef struct {
const char *name; /**< The name of the attribute */
const char *value; /**< The value of the attribute (usually empty) */
} objc_property_attribute_t;

而attributes的具体内容是什么呢?其实,包括:类型,原子性,内存语义和对应的实例变量。

例如:我们定义一个string的property@property (nonatomic, copy) NSString *string;,通过 property_getAttributes(property)获取到attributes并打印出来之后的结果为T@"NSString",C,N,V_string

其中T就代表类型,可参阅Type Encodings,C就代表Copy,N代表nonatomic,V就代表对于的实例变量。

自动合成(autosynthesis)

编译器为属性自动添加存取方法的过程叫做自动合成。这个过程是编译器在编译器进行的,以编辑器里看不到这些“合成方法”(synthesized method)的源代码。

编译器自动添加的实例变量的名称默认是属性名前再加个下划线。如属性firstName对应的默认实例变量为_firstName。也可以通过synthesize来手动指定实例变量名:

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

定义属性大致会生成五个东西:

  • OBJC_IVAR_$类名$属性名称 :该属性的“偏移量” (offset),这个偏移量是“硬编码” (hardcode),表示该变量距离存放对象的内存区域的起始地址有多远。
  • settergetter 方法对应的实现函数
  • ivar_list :成员变量列表
  • method_list :方法列表
  • prop_list :属性列表

也就是说我们每次在增加一个属性,系统都会在ivar_list中添加一个成员变量的描述,在 method_list中增加 setter 与 getter 方法的描述,在属性列表中增加一个属性的描述,然后计算该属性在对象中的偏移量,然后给出 setter 与 getter 方法对应的实现,在 setter 方法中从偏移量的位置开始赋值,在 getter 方法中从偏移量开始取值,为了能够读取正确字节数,系统对象偏移量的指针类型进行了类型强转.

修饰符

属性的修饰符由四部分组成,举个例子:

1
@property (nonatomic, readwrite, getter=isGood, assign) BOOL good;
  • 原子性:有atomicnonatomic两种,atomic使属性具有原子性,一定程度上是线程安全的,而nonatomic则不支持。在 iOS 开发当中,绝大部分情况下使用nonatomic
  • 读写规则:有readwritereadonly两种,分别支持读写和只读
  • 存取方法名:通过getter=setter=可以指定属性的存取方法名
  • 内存管理:有assignstrongcopy等修饰符,具体作用在内存管理中解释

属性默认的修饰符为:

  • atomic
  • readwrite
  • getter=$prop$, setter=set$Prop$
  • 基本数据类型为 assign,对象为 strong

@synthesize

@synthesize一般有两个作用:

  1. 指定属性对应的实例变量名,不写的话默认为@synthesize var = _var,一般不推荐改写实例变量名
  2. 当自动合成失效时,进行手动合成。

自动合成失效,即编译器不帮你生成实例变量。有以下情况会时自动合成失效:

  • 手动实现了 getter 和 setter 方法(或实现了 readonly 属性的 getter),这时编译器会认为你想自己管理实例变量,所以不会帮你生成
  • 通过@dynamic指定了属性的动态性,详见下节

@dynamic

@dynamic告诉编译器:属性的 setter 和 getter 方法由用户自己实现,不用自动生成。假如一个属性被声明为@dynamic var,然后你没有提供 setter 方法和 getter 方法,编译的时候没问题,但是当程序运行到instance.var = someVar`,由于缺 setter 方法会导致程序崩溃;或者当运行到 someVar = var 时,由于缺 getter 方法同样会导致崩溃。编译时没问题,运行时才执行相应的方法,这就是所谓的动态绑定。

拷贝

系统对象

一般在定义NSStringNSArrayNSDictionary这些对象时,通常会使用copy关键字,因为它们有对应的可变类型:NSMutableStringNSMutableArrayNSMutableDictionary。用copy修饰的对象在被赋值时会自动调用它的 copy 方法,即被赋值的指针会指向拷贝对象。

对于上述具有可变和不可变两种形式的对象而言,调用 copy 方法产生的都是不可变的拷贝对象,对应的可变拷贝方法为mutableCopy。拷贝还存在深拷贝和浅拷贝两种形式,对应如下:

  • [immutableObject copy] // 浅复制
  • [immutableObject mutableCopy] //深复制
  • [mutableObject copy] //深复制
  • [mutableObject mutableCopy] //深复制

这个也比较好理解,对于不可变对象进行 copy,因为被拷贝对象与拷贝对象都不可变,所以只需进行指针复制即可;如果有一个对象是可变的,那就需要进行内容复制。对于容器对象而言,内容复制只是对于容器本身的复制,容器中的对象仍是指针复制。

因此,对于不可变对象的申明,最好使用 copy 修饰,防止对象在外部被修改。对于可变对象,使用 strong,保持对象的可变性。如:

1
@property (nonatomic ,readwrite, strong) NSArray *array;

如果 array 持有的是一个外部可变对象,那么 array 会随着外部对象的改变而改变,因为它们指向的是同一块内存,而 array 的申明却是不可变对象NSArray。又如:

1
@property (nonatomic ,readwrite, copy) NSMutableArray *mutableArray;

如果对 mutableArray 进行元素的增删,编译时不会报错,但是运行时会崩溃。因为用了 copy 修饰符,所以它本质上是一个不可变数组,因此运行时找不到对应的增删元素的方法。申明的可变只能骗过编译器。

block 也经常使用 copy 关键字,这是历史遗留问题。在 MRC 中,方法内部的 block 是在栈区的,使用 copy 可以把它放到堆区.在 ARC 中写不写都行:对于 block 使用 copy 还是 strong 效果是一样的,但写上 copy 也无伤大雅,还能时刻提醒我们:编译器自动对 block 进行了 copy 操作。如果不写 copy ,该类的调用者有可能会忘记或者根本不知道“编译器会自动对 block 进行了 copy 操作”,他们有可能会在调用之前自行拷贝属性值。这种操作多余而低效。

自定义对象

如果要让自定义的对象能够实现 copy 方法,首先需要实现<NSCopying>协议(mutableCopy 则需要实现<NSMutableCopying>协议)。

已 NSCopying 协议为例,该协议只有一个方法:

1
- (id)copyWithZone:(NSZone *)zone;

要实现 copy,就要实现这个方法,而不是 copy 方法。比如可以这样写:

1
2
3
4
5
6
7
- (id)copyWithZone:(NSZone *)zone {
SomeThing *copy = [[[self class] allocWithZone:zone] init];
copy->_var = [_var coppy];
copy->_set = [[NSMutableSet alloc] initWithSet:_set
copyItems:YES];
return copy;
}

注意,对象的实例变量如果需要拷贝,还需要手动实现。如果缩写的对象需要深拷贝,那么可考虑新增一个专门执行深拷贝的方法。

如果需要重写带 copy 修饰符的 setter,不要忘了手动 copy:

1
2
3
4
- (void)setName:(NSString *)name {
_name = [name copy];
// maybe do something
}

等同性

首先看一段代码:

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

在比较两个对象时,==操作符比较的是两个指针所指向的地址,equalA 比较两个不同的对象,因此返回 NO。isEqualToString:方法是 NSString 的特有方法,只比较字符串是否相等,上面两个字符串是一样的,因此返回 YES。isEqual:方法是定义在 NSObject 协议中的方法,它的默认实现是:当且仅当其内存地址完全相等时,两个对象才相等,这与==操作符的比较方式相同。但是在 NSString 中,isEqual:方法得到了覆写,它首先比较两个对象类型是否相同,若相等,则调用isEqualToString:,否则再执行原有的isEqual:。因此,equalB 的结果与 equalC 相同。但是第三个调用比第二个调用快。

isEqualToString:这类方法是特定类所具有的等同性判定方法,除 NSString 之外,NSArray 与 NSDictionary 类也具有特殊的等同性判定方法,分别为isEqualToArrayisEqualToDictionary。一般对于自定义类也可编写此类比较方法。

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

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

如果要比较自定义的类需要覆写isEqual:方法,同时也需要覆写hash。在覆写isEqual:方法时,可以首先==操作符比较,若相等则直接返回 YES,接着比较对象类型,若不同则直接返回 NO,然后再比较能够判定对象等同性的关键属性即可。

接着实现hash方法,根据等同性约定:若两对象相等,则其哈希码也相等,但是两个哈希码相同的对象却未必相等。对于哈希码计算的要求是:

  • 计算速度快
  • 对于相等的对象,其哈希值必须相等
  • 尽量减少不等对象的哈希码碰撞

有一种做法是:

1
2
3
- (NSUInteger)hash {
return var1 ^ var2 ^ var3;
}

这种做法既能保持较高效率,又能使生成的哈希码至少位于一定范围之内,而不会过于频繁。如果哈希码碰撞率过高,在 collection 中使用这类对象就会产生性能问题。

对于容器中可变类的等同性,有一种情况要注意,就是在容器中放入可变类对象后,就不应再改变其哈希码了。如果某对象在放入“箱子”之后哈希码又变了,那么其现在所处的这个箱子对它来说就是“错误”的。要想解决这个问题,需要确保哈希码不是根据对象的“可变部分”计算出来的,或是保证放入 collection 之后就不再改变对象内容了。

Core Fundation 对象

Core Fundation 框架是一组 C 语言接口,它与 Fundation 框架紧密相关,CF 对象与 OC 对象也可以相互转换,因为 ARC 的内存管理只针对 OC 对象有效,所以对象的转换主要涉及对象所有权的处理:

  • __bridge:只作类型转换,不涉及所有权的转移。如:
1
CFStringRef cfString = (__bridge CFStringRef)string;

这里 string 对象会由 ARC 管理内存,所以不用手动释放 cfString。

  • __bridge_retained:会让 CF 对象得到对象所有权,即 CF 和 OC 都持有对象所有权。
1
CFStringRef cfString = (__bridge_retained CFStringRef)string;

string 释放后,cfString 仍然可以使用,但是它需要手动被释放:CFRelease(cfString);__bridge_retained可以用CFBridgingRetain()代替。

  • __bridge_transfer:对象所有权转移:CF -> OC
1
NSString *string = (__bridge_transfer NSString *)cfString;

cfString 的所有权已经被转移,所以不需要手动释放。可以用CFBridgingRelease()代替。

+load & +initialize

+load

+load 方法是当类或分类被添加到 Objective-C runtime 时被调用的,实现这个方法可以让我们在类加载的时候执行一些类相关的行为。子类的 +load 方法会在它的所有父类的 +load 方法之后执行,而分类的 +load 方法会在它的主类的 +load 方法之后执行。但是不同的类之间的 +load 方法的调用顺序是不确定的。

load 方法并不遵从那套继承规则。如果某个类本身没实现 load 方法,那么不管其各级超类是否实现此方法,系统都不会调用。此外,在分类和其所属的类里,都可能出现 load 方法。此时两种实现代码都会调用。

+load 方法的执行先于 main 函数。

load 方法务必实现的精简一些,也就是要尽量减少其所执行的操作,其真正用途仅在于调试程序。

+initialize

+initialize 方法是在类或它的子类收到第一条消息之前被调用的,这里所指的消息包括实例方法和类方法的调用。也就是说 +initialize 方法是以懒加载的方式被调用的,如果程序一直没有给某个类或它的子类发送消息,那么这个类的 +initialize 方法是永远不会被调用的。

initialize 方法与其他消息一样,如果某个类未实现它,而其超类实现了,那么就会运行超类的实现代码。因此父类的实现可能会被执行多次的。有时候,这可能是你想要的;但如果我们想确保自己的 +initialize 方法只执行一次,避免多次执行可能带来的副作用时,我们可以使用下面的代码来实现:

1
2
3
4
5
+ (void)initialize {
if (self == [ClassName class]) {
// ... do the initialization ...
}
}
+load +initialize
调用时机 被添加到 runtime 时 收到第一条消息前,可能永远不调用
调用顺序 父类->子类->分类 父类->子类
调用次数 1次 多次
是否需要显式调用父类实现
是否沿用父类的实现
分类中的实现 类和分类都执行 覆盖类中的方法,只执行分类的实现

参考资料