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