第18条:尽量使用不可变对象
本条要点:(作者总结)
- 尽量创建不可变的对象。
- 若某属性仅可于对象内部修改,则在 “class-continuation 分类” 中将其由 readonly 属性扩展为 readwrite 属性。
- 不要把可变的 collection 作为属性公开,而要提供相关方法,以此修改对象中的可变 collection。
设计类的时候,应充分运用属性来封装数据。而在使用属性时,则可将其声明为 “只读”(read-only)。默认情况下,属性是 “即可读又可写的”(read-write),这样设计出来的类都是“可变的”(mutable)。不过,一般情况下我们要建模的数据未必需要改变。比方说,某数据所表示的对象源自一项只读的网络服务(web service),里面可能包含一系列需要显示在地图上的相关点,像这种对象就没必要改变其内容。即使修改了,新数据也不会推送回服务器。正如第8条所述,如果把可变对象(mutable object)放入 collection 之后又修改其内容,那么很容易就会破坏 set 的内部数据结构,使其失去固有的语义。因此,笔者建议大家尽量减少对象中的可变内容。
具体到编程实践中,则应该尽量把对外公布出来的属性设为只读,而且只在确有必要时才对外公布。例如,要编写一个类来处理地图上的景点,这些点的数据通过某个网络服务来获取。一开始写出来的代码也许是这样:
1 #import <Foundation/Foundation.h> 2 3 @interface EOCPointOfInterest : NSObject 4 5 @property (nonatomic, copy) NSString *identifier; 6 @property (nonatomic, copy) NSString *title; 7 @property (nonatomic, assign) float latitude; 8 @property (nonatomic, assign) float longitude; 9 10 - (instancetype)initWithIdentifier:(NSString *)identifier 11 title:(NSString *)title 12 latitude:(float)latitude 13 longitude:(float)longitude; 14 15 @end
对象中的值都经由网络服务获取,在与网络服务通信的过程中,以 identifier 来指代相关的景点。用网络服务所提供的数据创建好某个点之后,就无须改动其值了。如果用其他编程语言来写,则可能会通过相应的机制创建出私有的实例变量,这些变量只有 get 存取方法,没有 set 存取方法。然而使用 Objective-C 编程时则会简单许多,根本无须考虑私有变量。
为了将 EOCPointOfInterest 做成不可变的类,需要把所有属性都声明为 readonly:
1 #import <Foundation/Foundation.h> 2 3 @interface EOCPointOfInterest : NSObject 4 5 @property (nonatomic, copy, readonly) NSString *identifier; 6 @property (nonatomic, copy, readonly) NSString *title; 7 @property (nonatomic, assign, readonly) float latitude; 8 @property (nonatomic, assign, readonly) float longitude; 9 10 - (instancetype)initWithIdentifier:(NSString *)identifier 11 title:(NSString *)title 12 latitude:(float)latitude 13 longitude:(float)longitude; 14 15 @end
如果有人试着改变属性值,那么编译的时候就会报错。对象中的属性值可以读出,但是无法写入,这样能保证 EOCPointOfInterest 中的各个数据之间总是相互协调的。于是,开发者在使用对象时就能肯定其底层数据不会改变。因此,对象本身的数据结构也就不可能出现不一致的现象。比如说,在将 EOCPointOfInsterest 对象显示到地图视图上时,这些点的底层经纬度数据不会变动。
读者也许会问,既然这些属性都没有设置方法(setter),那为何还要指定内存管理语义呢?如果不指定,采用默认的语义也可以:
1 @property (nonatomic, readonly) NSString *identifier; 2 @property (nonatomic, readonly) NSString *title; 3 @property (nonatomic, readonly) float latitude; 4 @property (nonatomic, readonly) float longitude;
虽说如此,我们还是应该在文档里指明实现所用的内存管理语义,这样的话,以后想把它变为可读写的属性时,就会简单一些。
有时可能想修改封装在对象内部的数据,但是却不想令这些数据为外人所动。这种情况下,通常的做法实在对象内部将 readonly 属性重新声明为 readwrite。当然,如果该属性是 nonatomic 的,那么这样做可能会产生 “竞争条件”(race condition)(又名“竞态条件”)。在对象内部写入某属性时,对象外的观察者也许正读取该属性。若想避免此问题,我们可以在必要时通过 “派发队列”(dispatch queue)等手段,将(包括对象内部的)所有数据存取操作都设为同步操作。
将属性在对象内部重新声明为 readwrite 这一操作可于 “class-continuation” 分类中完成,在公共接口中声明的属性可于此处重新声明,属性的其他特质必须保持不变,而 readonly 可扩展为 readwrite 。以 EOCpointOfInterest 为例,其 “class-continuation 分类” 可以这样写:
1 #import "EOCPointOfInterest.h" 2 3 @interface EOCPointOfInterest () 4 5 @property (nonatomic, copy, readwrite) NSString *identifier; 6 @property (nonatomic, copy, readwrite) NSString *title; 7 @property (nonatomic, assign, readwrite) float latitude; 8 @property (nonatomic, assign, readwrite) float longitude; 9 10 @end 11 12 @implementation EOCPointOfInterest 13 14 /* ... */ 15 16 @end
现在,只能于 EOCPointOfInterest 实现代码内部设置这些属性值了。其实更准确地说,在对象外部,仍然能通过 “键值编码”(Key-Value Coding, KVC)技术设置这行属性值,比如说,可以像下面这样,使用 “setValue:forKey:” 方法来修改:
1 [pointOfInterest setValue:@"abc" forKey:@"identifier"];
这样做可以改动 identifier 属性,因为 KVC 会在类里查找 “setIdentifier:” 方法,并借此修改此属性。即便没有于公共接口中公布此方法,它也依然包含在类里。不过,这样做等于违规地绕过了本类所提供的 API,要是开发者使用这种 “杂技代码”(hack)的话,那么得自己来应对可能出现的问题。
有些 “爱用蛮力的(brutal)”程序员甚至不通过 “设置方法”,而是直接用类型信息查询功能查出属性所对应的实例变量在内部布局中的偏移量,以此来人为设置这个实例变量的值。这样做比绕过本类的公共 API 还要不合规范。从技术上来讲,即便某个类没有对外公布 “设置方法”,也依然可以想办法修改对应的属性,然而,不应该因为这个原因而忽视笔者所提的建议,大家还是要尽量编写不可变的对象。
在定义类的公共 API 时,还要注意一件事情:对象里表示各种 collection 的那些属性究竟应该设成可变的,还是不可变的。例如,我们用某个类来表示个人信息,该类里还存放了一些引用,指向此人的诸位朋友。你可能想把这个人的全部朋友都放在一个 “列表”(list)里,并将其做成属性。假如开发者可以添加或删除此人的朋友,那么这个属性就需要用可变的 set 来实现。在这种情况下,通常应该提供一个 readonly 属性供外界使用,该属性将返回不可变的 set,而此 set 则是内部那个可变 set 的一份拷贝。比方说,下面这段代码就能实现出这样一个类:
1 // EOCPerson.h 2 3 #import <Foundation/Foundation.h> 4 5 @interface EOCPerson : NSObject 6 7 @property (nonatomic, copy, readonly) NSString *firstName; 8 @property (nonatomic, copy, readonly) NSString *lastName; 9 @property (nonatomic, strong, readonly) NSSet *friends; 10 11 - (instancetype)initWithFirstName:(NSString *)firstName 12 andLastName:(NSString *)lastName; 13 - (void)addFriend:(EOCPerson *)person; 14 - (void)removeFriend:(EOCPerson *)person; 15 16 @end
1 // EOCPerson.m 2 #import "EOCPerson.h" 3 4 @interface EOCPerson () 5 6 @property (nonatomic, copy, readwrite) NSString *firstName; 7 @property (nonatomic, copy, readwrite) NSString *lastName; 8 9 @end 10 11 @implementation EOCPerson { 12 NSMutableSet *_internalFriends; 13 } 14 15 - (NSSet *)friends { 16 return [_internalFriends copy]; 17 } 18 19 - (void)addFriend:(EOCPerson *)person { 20 [_internalFriends addObject:person]; 21 } 22 23 - (void)removeFriend:(EOCPerson *)person { 24 [_internalFriends removeObject:person]; 25 } 26 27 - (instancetype)initWithFirstName:(NSString *)firstName andLastName:(NSString *)lastName { 28 if (self = [super init]) { 29 _firstName = firstName; 30 _lastName = lastName; 31 _internalFriends = [NSMutableSet new]; 32 } 33 return self; 34 } 35 36 @end
也可以用 NSMutableSet 来实现 friends 属性,令该类的用户不借助 “addFriend:” 与 “removeFriend:” 方法而直接操作此属性。但是这种过分解藕(decouple)数据的做法很容易出 bug,比方说,在添加或删除朋友时,EOCPerson 对象可能还要执行其他相关操作,若是采用这种做法,那就等于直接从底层修改 set 可能会令对象的各数据之间互不一致。
说到这里,笔者还要强调:不要在返回的对象上查询类型以确定其是否改变。比方说,你正使用一个包含 EOCPerson 类的库来开发程序。为了省事,该库的开发者可能并没有将内部那个可变的 set 拷贝一份再返回,而是直接返回了可变的 set。这样做也算合理,因为 set 可能很大,拷贝起来太耗时了。返回 NSMutableSet 也合乎语法,因为该类是 NSSet 的子类,于是,你可能会像这样来使用 EOCPerson:
1 EOCPerson *person = /*...*/; 2 NSSet *friends = person.friends; 3 if([firends isKindOfClass:[NSMutableSet class]]) { 4 NSMutableSet *mutableFriends = (NSMutableSet *)friends; 5 /* mutable the set*/ 6 }
然而笔者要说:大家应该竭力避免这种做法。在你与 EOCPerosn 类之间的约定(contract)里,并没有提到实现 friends 所用的那个 NSSet 一定是可变的,因此不应像这样使用类型信息查询功能来编码。这依然说明:开发者或许不宜从底层直接修改对象的数据。所以,不要假设这个 NSSet 就一定能直接修改。
END