第51条:精简initialize与load的实现代码
本条要点:(作者总结)
- 在加载阶段,如果类实现了 load 方法,那么系统就会调用它。分类里也可以定义此方法,类的 load 方法要比分类中的先调用。与其他方法不同,load 方法不参与覆写机制。
- 首次使用某个类之前,系统会向其发送 initialize 消息。由于此方法遵从普通的覆写规则,所以通常应该在里面判断当前要初始化的是哪个类。
- load 与 initialize 方法都应该实现的精简一些,这有助于保持应用程序的响应能力,也能减少引入 “保留环”(interdependency cycle)的几率。
- 无法在编译期设定的全局常量,可以放在 initialize 方法里初始化。
有时候,类必须先执行某些初始化操作,然后才能正常使用。在 Objective-C 中,绝大多数都继承自 NSObject 这个类,而该类有两个方法,可用来实现这种初始化操作。
首先要讲的是 load 方法,其原型如下:
+ (void)load;
对于加入运行期系统中的每个类(class)及分类(category)来说,必定会调用此方法,而且仅调用一次。当包含类或分类的程序库载入系统时,就会执行此方法,而这通常就是指应用程序启动的时候,若程序是为 iOS 平台设计的,则肯定会在此时执行。 Mac OS X 应用程序更自由一些,它们可以使用 “动态加载”(dynamic loading)之类的特性,等应用程序启动好之后再去加载程序库。如果分类和其所属的类都定义了 load 方法,则先调用类里的,再调用分类里的。
load 方法的问题在于,执行该方法时,运行期系统处于 “脆弱状态”(fragile state)。在执行子类的 load 方法之前,必定会先执行所有超类的 load 方法,而如果代码还依赖了其他程序库,那么程序库里相关类的load 方法也必定会先执行。然而,根据某个给定的程序库,却无法判断出其中各个类的载入顺序。因此,在 load 方法中使用其他类是不安全的。比方说,有下面这段代码:
#import <Foundation/Foundation.h>
#import "EOCClassA.h" // < From the same library
@interface EOCClass : NSObject
@end
@implementation EOCClassB
+ (void)load {
NSLog(@"Loading EOCClassB");
EOCClassA *object = [EOCClassA new];
// Use 'Object'
}
@end
此处使用 NSLog 没问题,而且相关字符串也会照常纪录,因为 Foundation 框架肯定在运行 load 方法之前就已经载入系统了。但是,在 EOCClassB 的load 方法里使用 EOCClassA 却不太安全,因为无法确定在执行 EOCClassB 的 load 方法之前,EOCClassA 是不是已经加载好了。可以想见:EOCClassA 这个类,也许会在其 load 方法中执行某些重要操作,只有执行完这些操作之后,该类实例才能正常使用。
有个重要的事情需要注意,那就是load 方法并不像普通的方法那样,它并不遵从那套继承规则。如果某个类本身没实现 load 方法,那么不管其各级超类是否实现此方法,系统都不会调用。此外,分类和其所属的类里,都可能出现 load 方法。此时两种实现代码都会调用,类的实现要比分类的实现先执行。
而且 load 方法务必实现得精简一些,也就是要尽量减少其所执行的操作,因为整个应用程序在执行load 方法时都会阻塞。如果 load 方法中包含繁杂的代码,那么应用程序在执行期间就会变得无响应。不要在里面等待锁,也不要调用可能会加锁的方法。总之,能不做的事情就别做。实际上,凡是想通过 load 在类加载之前执行某些任务的,基本都做的不太对。其真正用途仅在于调试程序,比如可以在分类里编写此方法,用来判断该分类是否已经正确载入系统中。也许此方法一度很有用处,但现在完全可以说:时下编写 Objective-C 代码时,不需要用它。
想执行与类相关的初始化操作,还有个办法,就是覆写下列方法:
+ (void)initialize;
对于每个类来说,该方法会在程序首次用该类之前调用,且只调用一次。 它是由运行期系统来调用的,绝不应该通过代码直接调用。其虽与 load 相似,但却有几个非常重要的微妙区别。首先,它是 “惰性调用的”,也就是说,只有当程序用到了相关的类时,才会调用。因此,如果某个类一直都没有使用,那么其 initialize 方法就一直不会运行。这也就等于说,应用程序无须先把每个类的 initialize 都执行一遍,这与 load 方法不同,对于 load 来说,应用程序必须阻塞并等着所有类的 load 都执行完,才能继续。
此方法与 load 还有个区别,就是运行期系统在执行该方法时,是处于正常状态的,因此,从运行期系统完整度上来讲,此时可以安全使用并调用任意类中的任意方法。而且,运行期系统也能确保 initialize 方法一定会在 “线程安全的环境”(thread-safe environment)中执行,也就是说,只有执行 initialize 的那个线程可以操作类或类实例。其他线程都要先阻塞,等着 initialize 执行完。
最后一个区别是: initialize 方法与其他消息一样,如果某个类未实现它,而其超类实现了,那么就会运行超类的实现代码。这听起来并不稀奇,但却经常为开发者所忽视。比方说有下面这两个类:
#import <Foundation/Foundation.h>
@interface EOCBaseClass : NSObject
@end
@implementation EOCBaseClass
+ (void)initialize {
NSLog(@"%@ initialize", self);
}
@end
@interface EOCSubClass : EOCBaseClass
@end
@implementation EOCClass
@end
即便 EOCSubClass 类没有实现 initialize 方法,它也会收到这条消息。由各级超类所实现 initialize 也会先行调用。所以,首次使用 EOCSubClass 时,控制台会输出如下消息:
EOCBaseClass initialize
EOCSubClass initialize
你可能认为输出的内容有些奇怪,不过这完全符合规则。与其他方法 (除去 load)一样, initialize 也遵循通常的继承规则,所以,当初始化基类 EOCBaseClass 时,EOCBaseClass 中定义的 initialize 方法要运行一遍,而当初始化 EOCSubClass 时,由于该类并未覆写此方法,因而还要把父类的实现代码再运行一遍。鉴于此,通常都会这么来实现 initialize 方法:
+ (void)initialize {
if (self == [EOCBaseClass class]) {
NSLog(@"%@ initialized", self);
}
}
加上这条检测语句之后,只有当开发者所期望的那个类载入系统时,才会执行相关的初始化操作。如果把刚才的例子照此改写,那就不会打印出两条消息了,这次只输出一条:
EOCBaseClass initialize
看过 load 与 initialize 方法的这些特性之后,又回到了早前提过的那个主要问题上,也就是这两个方法的实现代码要尽量精简。在里面设置一些状态,使本类能够正常运作就可以了,不要执行那种耗时太久或需要加锁的任务。对于 load 方法来说,其原因已在前面解释过了,而 initialize 方法要保持精简的原因,也与之相似。首先,大家都不想看到应用程序“挂起”(hang)。对于某个类来说,任何线程都可能成为初次用到它的那个线程,并导致其初始化。如果这个线程碰巧是 UI 线程,那么初始化期间就会一直阻塞,导致应用程序无响应。有时候很难预测到底哪个线程会先用到这个类,强令某线程去初始化该类,显然不是好办法。
其二,开发者无法控制类的初始化时机。类在首次使用之前,肯定要初始化,但编写程序时不能令代码依赖特定的时间点,否则会很危险。运行期系统将来更新了之后,可能会略微改变类的初始化方式,这样的话,开发者原来如果假设某个类必定会在某个具体时间点初始化,那么现在这条假设可能就不成立了。
最后一个原因,如果某个类的实现代码很复杂,那么其中可能会直接或间接用到其他类。若那些类尚未初始化,则系统会迫使其初始化。然而,本类的初始化方法此时尚未运行完毕。其他类在运行其 initialize 方法时,有可能会依赖本类中
的某些数据,而这些数据此时也许还未初始化好。例如:
#import <Foundation/Foundation.h>
static id EOCClassAInternalData;
@interface EOCClassB : NSObject
@end
@implementation EOCClassA
+ (void)initialize {
if (self == [EOCClassA class]) {
[EOCClassB doSomethingThatUsesItsInternalData];
EOCClassAInternalData = [self setupInternalData];
}
}
@end
@implementation EOCClassB
+ (void)initialize {
if (self == [EOCClassB class]) {
[EOCClassA doSomethingThatUsesItsInternalData];
EOCClassBInternalData = [self setupInternalData];
}
}
@end
若是 EOCClassA 先初始化,那么 EOCClassB 随后也会初始化,它会在自己的初始化方法中调用 EOCClassA 的 doSomethingThatUsesItsInternalData,而此时 EOCClassA 内部的数据还没准备好。在实际编码工作中,问题不可能像此处说的那样明显,而是牵涉的类可能也不止两个。因此,当代码无法正常运行时,想要找出错误就更难了。
所以说,initialize 方法只应该用来设置内部数据。不应该在其中调用其他方法,即便是本类自己的方法,也最好别调用。因为稍后可能还要给那些方法里添加更多功能,如果在初始化过程中调用它们,那么还是有可能导致刚才说的那个问题。若某个全局状态无法在编译期初始化,则可以放在 initialize 里来做。下列代码演示了这种用法:
// EOCClass.h
#import <Foundation/Foundation.h>
@interface EOCClass : NSObject
@end
// EOCClass.m
#import "EOCClass.h"
static const int kInterval = 10;
static NSMutableArray *kSomeObjects;
@implementation EOCClass
+ (void)initialize {
if (self == [EOCClass class]) {
kSomeObjects = [NSMutableArray new];
}
}
@end
整数可以在编译期定义,然而可变数组不行,因为它是个 Objective-C 对象,所以创建实例之前必须先激活运行期系统。注意,某些 Objective-C 对象也可以在编译期创建,例如 NSString 实例。然而,创建下面这种对象会令编译器报错:
static NSMutableArray *kSomeObjects = [NSMutableArray new];
编写 load 或 initialize 方法时,一定要留心这些注意事项。把代码实现得简单一些,能节省很多调试时间。除了初始化全局状态之外,如果还有其他事情要做,那么可以专门创建一个方法来执行这些操作,并要求该类的使用者必须在使用本类之前调用此方法。比方说,如果“单例类”(singleton class)在首次使用之前必须执行一些操作,那就可以采用这个办法。
END