第三章 接口与API设计—第18条:尽量使用不可变对象

设计类的时候,应充分运用属性来封装数据(参见第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。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345

推荐阅读更多精彩内容