第16条:提供“全能初始化方法”
本条要点:(作者总结)
- 在类中提供了一个全能初始化方法,并于文档里指明。其他初始化方法均应调用此方法。
- 若全能初始化方法与超类不同,则需覆写超类中的对应方法。
- 如果超类的初始化方法不适用于子类,那么应该覆写这个超类方法,并在其中抛出异常。
所有对象均要初始化。在初始化时,有些对象可能无须开发者向其提供额外信息,不过一般来说还是要提供的。通常情况下,对象若不知道必要的信息,则无法完成其工作。以 iOS 的 UI 框架 UIKit 为例,其中有个类叫做 UITableViewCell ,初始化该类对象时,需要指明其样式及标识符,标识符能够区分不同类型的单元格。由于这种对象的创建成本较高,所以绘制表格时可依照标识符来复用,以提升程序效率。我们把这种可为对象提供必要信息以便其能完成工作的初始化方法叫做 “全能初始化方法”(designated initializer)(常译为 “指定初始化方法”)。
如果创建类实例的方式不止一种,那么这个类就会有多个初始化方法。这当然很好,不过仍然要在其中选定一个作为全能初始化方法,令其他初始化方法都来调用它。NSDate 就是个例子,其初始化方法如下:
1 - (instancetype)init; 2 - (instancetype)initWithString:(NSString *)string; 3 - (instancetype)initWithTimeIntervalSinceNow:(NSTimeInterval)seconds; 4 - (instancetype)initWithTimeInterval:(NSTimeInterval)seconds sinceDate:(NSDate *)refDate; 5 - (instancetype)initWithTimeIntervalSinceReferenceDate:(NSTimeInterval)seconds; 6 - (instancetype)initWithTimeIntervalSince1970:(NSTimeInterval)seconds;
正如该类的文档所述的那样,在上面几个初始化方法中, “initWithTimeIntervalSinceReferenceDate:” 是全能初始化方法。也就是说,其余的初始化方法都要调用它。于是,只有在全能初始化方法中,才会存储内部数据。这样的话,当底层数据存储机制改变时,只需要修改此方法的代码就好,无须改动其他初始化方法。
比如说,要编写一个表示矩形的类。其接口可以这样写:
1 #import <Foundation/Foundation.h> 2 3 @interface EOCRectangle : NSObject 4 5 @property (nonatomic, assign) float width; 6 @property (nonatomic, assign) float height; 7 8 @end
根据第18条中的建议,我们把属性声明为只读。不过这样一来,外界就无法设置 Rectangle 对象的属性了。开发者可能会提供初始化方法以设置这两个属性:
1 - (instancetype)initWithWidth:(float)width andHeight:(float)height { 2 if (self = [super init]) { 3 _width = width; 4 _height = height; 5 } 6 return self; 7 }
可是,如果有人用 [[EOCRectangle alloc] init] 来创建矩形会如何呢? 这么做合乎规则的,因为 EOCRectangle 的超类 NSObject 实现了这个名为 init 的方法,调用完该方法后,全部实例变量将设为 0 (或设置成符合其数据类型且与 0 等价的值)。如果把 alloc 方法分配好的 EOCRectangle 交由此方法来初始化,那么矩形的宽度与高度就是 0,因为全部实例变量都设为 0 了。这也可能是你想要的效果,不过此时我们一般希望能自己设定默认的宽度与高度值,或是抛出异常,指明本类实例必须用 “全能初始化方法”来初始化。也就是说,在 EOCRectangle 这个例子中,应该像下面这样,参照其中一种版本来覆写 init 方法:
1 // Using default values 2 - (instancetype)init { 3 return [self initWithWidth:5.f andHeight:10.f]; 4 } 5 6 // Throwing an exception 7 - (instancetype)init { 8 @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Must use initWithWidth:andHeight: instead." userInfo:nil]; 9 }
请注意,设置默认值的那个 init 方法调用了全能初始化方法。如果采用这个版本来覆写,那么也可以直接在其代码中设置 _width 与 _height 实例变量的值。然而,若是类的底层存储方式变了(比如开发者决定把宽度与高度一起放在某结构体中),则 init 与全能初始化方法设置数据所用的代码都要修改。在本例这种简单的情况下没有太大问题,但是如果类的初始化方法有很多种,而且待初始化的数据较为复杂,那么这样做就麻烦的多。很容易就忘了修改其中某个初始化方法,从而导致各初始化方法之间相互不一致。
现在假定要创建名叫 EOCSquare 的类,令其称为 EOCRectangle 的子类。这种继承方式完全合理,不过,新类的初始化方法应该怎么写呢?因为本类表示正方形,所以其宽度与高度必须相等才行。于是,我们可能会像下面这样创建初始化方法:
1 #import "EOCRectangle.h" 2 3 @interface EOCSquare : EOCRectangle 4 5 - (instancetype)initWithDimension:(float)dimension; 6 7 @end 8 9 #import "EOCSquare.h" 10 11 @implementation EOCSquare 12 13 - (instancetype)initWithDimension:(float)dimension { 14 return [super initWithWidth:dimension andHeight:dimension]; 15 } 16 17 @end
上述方法就是 EOCSquare 类的全能初始化方法。请注意,它调用了超类的全能初始化方法。回过头看看 EOCRectangle 类的实现代码,你就会发现,那个类也调用了其超类的全能初始化方法。全能初始化方法
的调用链一定要维系。然而,调用者可能会使用 “initWithWidth:andHeight:” 或 init 方法来初始化 EOCSquare 对象。类的编写者并不希望看到此种情况,因为那样做可能会创建出 “宽度” 和 “高度”不相等的正方形。于是,就引出了类继承时需要注意的一个重要问题:如果子类的全能初始化方法与超类方法的名称不同,那么总应覆写超类的全能初始化方法。在 EOCSquare 这个例子中,应该像下面这样覆写 EOCRectangle 的全能初始化方法:
1 - (instancetype)initWithWidth:(float)width andHeight:(float)height { 2 float dimension = MAX(width, height); 3 return [self initWithDimension:dimension]; 4 }
请注意看此方法是如何利用 EOCSquare 的全能初始化方法来保证对象属性正确的。覆写这个方法之后,即便使用 init 来初始化 EOCSquare 对象,也能照常工作。原因在于,EOCRectangle 类覆写了 init 方法,并以默认值为参数,调用了该类的全能初始化方法。在用 init 方法初始化 EOCSquare 对象时,也会这么调用,不过由于 “initWithWidth:andHeight:” 已经在子类中覆写了,所以实际上执行的是 EOCSquare 类的这一份实现代码,而此代码又会调用本类的全能初始化方法。因此一切正常,调用者不可能创建出边长不相等地 EOCSquare 对象。
有时我们不想覆写超类的全能初始化方法,因为那样做没有道理。比方说,现在不想令 “initWithWidth:andHeight:” 方法以其两参数中较大者作边长来初始化 EOCSquare 对象;反之,我们认为这是方法调用者自己犯了错误。在这种情况下,常用方法是覆写超类的全能初始化方法并于其中抛出异常:
1 - (instancetype)initWithWidth:(float)width andHeight:(float)height { 2 @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Must use initWithDimension: instead." userInfo:nil]; 3 }
这样做看起来似乎显得突兀,不过有时却是必需的,因为那种情况下创建出来的对象,其内部数据有可能相互不一致(inconsistent internal data)。如果这么做了,那么在 EOCRectangle 与 EOCSquare 这个例子中,调用 init 方法也会抛出异常,因为 init 方法也得调用 “initWithWidth:andHeight:”。此时可以覆写 init 方法,并在其中以合理的默认值来调用 “initWithDimension:” 方法:
1 - (instancetype)init { 2 return [self initWithDimension:5.f]; 3 }
不过,在 Objective-C 程序中,只有当发生严重错误时,才应该抛出异常,所以,初始化方法抛出异常乃是不得已之举,表明实例真的没有办法初始化了。
有时候可能需要编写多个全能初始化方法。比方说,如果某对象的实例有两种完全不同的创建方式,必须分开处理,那么就会出现这种情况。以 NSCoding 协议为例,此协议提供了 “序列化机制”(serialization mechanism),对象可依此指明其自身的编码(encode)及解码(decode)方式。Mac OS X 的 AppKit 与 iOS 的 UIKit 这两个 UI 框架都广泛运用此机制,将对象序列化,并保存至 XML 格式的 “NIB” 文件中。这些 NIB 文件通常用来存放视图控制器(view controller)及其视图布局。加载 NIB 文件时,系统会解压缩(unarchiving)的过程中解码视图控制器。NSCoding 协议定义了下面这个初始化方法,遵从该协议者都应实现此方法:
1 - (instancetype)init { 2 return [self initWithDimension:5.f]; 3 }
我们在实现此方法时一般不调用平常所使用的那个全能初始化方法,因为该方法要通过 “解码器”(decoder)将对象数据解压缩,所以和普通的初始化方法不同。而且,如果超类也实现了 NSCoding,那么还需调用超类的 “initWithCoder:” 方法。于是,子类中有不止一个初始化方法调用了超类的初始化方法,因此,严格的说,在这种情况下出现了两个全能初始化方法。
具体到 EOCRectangle 这个例子上,其代码就是:
1 #import <Foundation/Foundation.h> 2 3 @interface EOCRectangle : NSObject 4 5 @property (nonatomic, assign) float width; 6 @property (nonatomic, assign) float height; 7 8 - (instancetype)initWithWidth:(float)width andHeight:(float)height; 9 10 @end 11 12 #import "EOCRectangle.h" 13 14 @implementation EOCRectangle 15 16 // Designated initializer 17 - (instancetype)initWithWidth:(float)width andHeight:(float)height { 18 if (self = [super init]) { 19 _width = width; 20 _height = height; 21 } 22 return self; 23 } 24 25 // Superclass's designated initializer 26 - (instancetype)init { 27 return [self initWithWidth:5.f andHeight:10.f]; 28 } 29 30 // Initializer from NSCoding 31 - (instancetype)initWithCoder:(NSCoder *)aDecoder { 32 // Call throgh to super's designated initializer 33 if (self = [super init]) { 34 _width = [aDecoder decodeFloatForKey:@"width"]; 35 _height = [aDecoder decodeFloatForKey:@"height"]; 36 } 37 return self; 38 } 39 40 @end
请注意,NSCoding 协议的初始化方法没有调用本类的全能初始化方法,而且调用了超类的相关方法。然而,若超类也实现了 NSCoding,则需改调用超类的 “initWithCoder:”初始化方法。例如,在此情况下,EOCSquare 类就得这么写:
1 #import "EOCRectangle.h" 2 3 @interface EOCSquare : EOCRectangle 4 5 - (instancetype)initWithDimension:(float)dimension; 6 7 @end 8 9 #import "EOCSquare.h" 10 11 @implementation EOCSquare 12 13 // Designated initializer 14 - (instancetype)initWithDimension:(float)dimension { 15 return [super initWithWidth:dimension andHeight:dimension]; 16 } 17 18 // Superclass designated initializer 19 - (instancetype)initWithWidth:(float)width andHeight:(float)height { 20 float dimension = MAX(width, height); 21 return [self initWithDimension:dimension]; 22 } 23 24 // NSCoding designated initializer 25 - (instancetype)initWithCoder:(NSCoder *)aDecoder { 26 if (self = [super initWithCoder:aDecoder]) { 27 // EOCSquate's specific initializer 28 29 } 30 return self; 31 } 32 33 @end
每个子类的全能初始化方法都应该调用其超类的对应方法,并逐层向上,实现 “initWithCoder:” 时也要这样,应该先调用超类相关方法,然后再执行与本类有关的任务。这样编写出来的 EOCSquare 类就完全遵守 NSCoding 协议了(fully NSCoding compliant)。如果编写 “initWithCoder:” 方法时没有调用超类的同名方法,而是调用了自制的初始化方法,或是超类的其他初始化方法,那么 EOCRectangle 类的 “initWithCoder:” 方法就没有机会执行,于是,也就无法将 _width 及 _height 这两个实例变量解码了。
END