在這篇文章里,我們會(huì)看看如何用 Objective-C 寫(xiě)值對(duì)象 (value objects)。在編寫(xiě)中,我們會(huì)接觸到 Objective-C 中的一些重要的接口和方法。所謂值對(duì)象,就是指那些能夠被判等的,持有某些數(shù)值的對(duì)象 (對(duì)它們判等時(shí)我們看重值是否相等,而對(duì)是否是同一個(gè)對(duì)象并不是那么關(guān)心)。通常來(lái)說(shuō),值對(duì)象會(huì)被用作 model 對(duì)象。比如像下面的 Person 對(duì)象就是一個(gè)簡(jiǎn)單的例子:
@interface Person : NSObject
@property (nonatomic,copy) NSString* name;
@property (nonatomic) NSDate* birthDate;
@property (nonatomic) NSUInteger numberOfKids;
@end
創(chuàng)造這樣的對(duì)象可以說(shuō)是我們?nèi)諒?fù)一日的基本工作了,雖然這些對(duì)象表面上看起來(lái)相當(dāng)簡(jiǎn)單,但是其中暗藏玄機(jī)。
我們中有很多人會(huì)教條主義地認(rèn)為這類(lèi)對(duì)象就應(yīng)該是不可變的 (immutable)。一旦你創(chuàng)建了一個(gè) Person 對(duì)象,它就不可能在做任何改變了。我們?cè)谏院髸?huì)在本話(huà)題中涉及到可變性的問(wèn)題。
首先我們來(lái)看看定義一個(gè) Person 時(shí)所用到的屬性。創(chuàng)建屬性是一件機(jī)械化的工作:對(duì)于一般的屬性,你會(huì)將它們聲明為 nonatomic。默認(rèn)情況下,對(duì)象屬性是 strong 的,標(biāo)量屬性是 assign 的。但是有一個(gè)例外,就是對(duì)于具有可變副本的屬性,我們傾向于將其聲明為 copy。比如說(shuō),name 屬性的類(lèi)型是 NSString,有可能有人創(chuàng)建了一個(gè) Person 對(duì)象,并且給這個(gè)屬性賦了一個(gè) NSMutableString 的名字值。然后過(guò)了一會(huì)兒,這個(gè)可變字符串被變更了。如果我們的屬性不是 copy 而是 strong 的話(huà),隨著可變字符串的改變,我們的 Person 對(duì)象也將發(fā)生改變,這不是我們希望發(fā)生的。對(duì)于類(lèi)似數(shù)組或者字典這樣的容器類(lèi)來(lái)說(shuō),也是這樣的情況。
要注意的是這里的 copy 是淺拷貝;容器里還是會(huì)包含可變對(duì)象。比如,如果你有一個(gè) NSMutableArray* a,其中有一些 NSMutableDictionary 的元素,那么 [a copy] 將返回一個(gè)不可變的數(shù)組,但是里面的元素依然是同樣的 NSMutableDictionary 對(duì)象。我們稍后會(huì)看到,對(duì)于不可變對(duì)象的 copy 是沒(méi)有成本的,只會(huì)增加引用計(jì)數(shù)而已。
因?yàn)閷傩允窍鄬?duì)最近才加入到 Objective-C 的,所以在較老的代碼中,你有可能不會(huì)見(jiàn)到屬性。取而代之,可能會(huì)有自定義的 getter 和 setter,或者直接是實(shí)例變量。對(duì)于最近的代碼,看起來(lái)大家都贊同還是使用屬性比較好,這也正是我們所推薦的。
如果我們需要的是不可變對(duì)象,那么我們要確保它在被創(chuàng)建后就不能再被更改。我們可以通過(guò)使用初始化方法并且在接口中將我們的屬性聲明為 readonly 來(lái)實(shí)現(xiàn)這一點(diǎn)。我們的接口看起來(lái)是這樣的:
@interface Person : NSObject
@property (nonatomic,readonly) NSString* name;
@property (nonatomic,readonly) NSDate* birthDate;
@property (nonatomic,readonly) NSUInteger numberOfKids;
- (instancetype)initWithName:(NSString*)name
birthDate:(NSDate*)birthDate
numberOfKids:(NSUInteger)numberOfKids;
@end
在初始化方法的實(shí)現(xiàn)中,我們必須使用實(shí)例變量,而不是屬性。
編者注 在初始化方法或者是 dealloc 中最好不要使用屬性,因?yàn)槟銦o(wú)法確定 `self` 到底是不是確實(shí)調(diào)用的是你想要的實(shí)例
@implementation Person
- (instancetype)initWithName:(NSString*)name
birthDate:(NSDate*)birthDate
numberOfKids:(NSUInteger)numberOfKids
{
self = [super init];
if (self) {
_name = [name copy];
_birthDate = birthDate;
_numberOfKids = numberOfKids;
}
return self;
}
@end
現(xiàn)在我們就可以構(gòu)建新的 Person 對(duì)象,并且不能再對(duì)它們做改變了。這一點(diǎn)很有幫助,在寫(xiě)和 Person 對(duì)象一起工作的其他類(lèi)的時(shí)候,我們知道這些值是不會(huì)發(fā)生改變的。注意這里 copy 不再是接口的一部分了,現(xiàn)在它只和實(shí)現(xiàn)的細(xì)節(jié)相關(guān)。
要比較相等,我們需要實(shí)現(xiàn) isEqual: 方法。我們希望 isEqual: 方法僅在所有屬性都相等的時(shí)候返回真。Mike Ash 的 Implement Equality and Hashing 和 NSHipster 的 Equality 為我們很好地闡述了如何實(shí)現(xiàn)。首先,我們需要寫(xiě)一個(gè) isEqual: 方法:
- (BOOL)isEqual:(id)obj
{
if(![obj isKindOfClass:[Person class]]) return NO;
Person* other = (Person*)obj;
BOOL nameIsEqual = self.name == other.name || [self.name isEqual:other.name];
BOOL birthDateIsEqual = self.birthDate == other.birthDate || [self.birthDate isEqual:other.birthDate];
BOOL numberOfKidsIsEqual = self.numberOfKids == other.numberOfKids;
return nameIsEqual && birthDateIsEqual && numberOfKidsIsEqual;
}
如上,我們先檢查輸入和自身是否是同樣的類(lèi)。如果不是的話(huà),那肯定就不相等了。然后對(duì)每一個(gè)對(duì)象屬性,判斷其指針是否相等。|| 操作符的操作看起來(lái)好像是不必要的,但是如果我們需要處理兩個(gè)屬性都是 nil 的情形的話(huà),它能夠正確地返回 YES。比較像 NSUInteger 這樣的標(biāo)量是否相等時(shí),則只需要使用 == 就可以了。
還有一件事情值得一提:這里我們將不同的屬性比較的結(jié)果分開(kāi)存儲(chǔ)到了它們自己的 BOOL 中。在實(shí)踐中,可能將它們放到一個(gè)大的判斷語(yǔ)句中會(huì)更好,因?yàn)槿绻@么做的話(huà)你就可以避免一些不必要的取值和比較了。比如在上面的例子中,如果 name 已經(jīng)不相等了的話(huà),我們就沒(méi)有必要再檢查其他的屬性了。將所有判斷合并到一個(gè) if 語(yǔ)句中我們可以自動(dòng)地得到這樣的優(yōu)化。
接下來(lái),按照文檔所說(shuō),我們還需要實(shí)現(xiàn)一個(gè) hash 函數(shù)。蘋(píng)果如是說(shuō):
如果兩個(gè)對(duì)象是相等的,那么它們必須有同樣的 hash 值。如果你在一個(gè)子類(lèi)里定義了 isEqual: 方法,并且打算將這個(gè)子類(lèi)的實(shí)例放到集合類(lèi)中的話(huà),那么你一定要確保你也在你的子類(lèi)里定義了 hash 方法,這是非常重要的。
首先,我們來(lái)看看如果不實(shí)現(xiàn) hash 方法的話(huà),下面的代碼會(huì)發(fā)生什么;
Person* p1 = [[Person alloc] initWithName:name birthDate:start numberOfKids:0];
Person* p2 = [[Person alloc] initWithName:name birthDate:start numberOfKids:0];
NSDictionary* dict = @{p1: @"one", p2: @"two"};
NSLog(@"%@", dict);
第一次運(yùn)行上面的代碼是,一切都很正常,字典中有兩個(gè)條目。但是第二次運(yùn)行的時(shí)候卻只剩一個(gè)了。事情變得不可預(yù)測(cè),所以我們還是按照文檔說(shuō)的來(lái)做吧。
可能你還記得你在計(jì)算機(jī)科學(xué)課程中學(xué)到過(guò),編寫(xiě)一個(gè)好的 hash 函數(shù)是一件不太容易的事情。好的 hash 函數(shù)需要兼?zhèn)?em>確定性和均布性。確定性需要保證對(duì)于同樣的輸入總是能生成同樣的 hash 值。均布性需要保證輸出的結(jié)果要在輸出范圍內(nèi)均勻地對(duì)應(yīng)輸入。你的輸出分布越均勻,就意味著當(dāng)你將這些對(duì)象用在集合中時(shí),性能會(huì)越好。
首先我們得搞清楚到底發(fā)生了什么。讓我們來(lái)看看沒(méi)有實(shí)現(xiàn) hash 函數(shù)時(shí)候的情況下,使用 Person 對(duì)象作為字典的鍵時(shí)的情況:
NSMutableDictionary* dictionary = [NSMutableDictionary dictionary];
NSDate* start = [NSDate date];
for (int i = 0; i < 50000; i++) {
NSString* name = randomString();
Person* p = [[Person alloc] initWithName:name birthDate:[NSDate date] numberOfKids:i++];
[dictionary setObject:@"value" forKey:p];
}
NSLog(@"%f", [[NSDate date] timeIntervalSinceDate:start]);
這在我的機(jī)子上花了 29 秒時(shí)間來(lái)執(zhí)行。作為對(duì)比,當(dāng)我們實(shí)現(xiàn)一個(gè)基本的 hash 方法的時(shí)候,同樣的代碼只花了 0.4 秒。這并不是精確的性能測(cè)試,但是卻足以告訴我們實(shí)現(xiàn)一個(gè)正確的 hash 函數(shù)的重要性。對(duì)于 Person 這個(gè)類(lèi)來(lái)說(shuō),我們可以從這樣一個(gè) hash 函數(shù)開(kāi)始:
- (NSUInteger)hash
{
return self.name.hash ^ self.birthDate.hash ^ self.numberOfKids;
}
這將從我們的屬性中取出三個(gè) hash 值,然后將它們做 XOR (異或) 操作。在這里,這個(gè)方法對(duì)我們的目標(biāo)來(lái)說(shuō)已經(jīng)足夠好了,因?yàn)閷?duì)于短字符串 (以前這個(gè)上限是 96 個(gè)字符,不過(guò)現(xiàn)在不是這樣了,參見(jiàn) CFString.c 中 hash 的部分) 來(lái)說(shuō),NSString 的 hash 函數(shù)表現(xiàn)很好。對(duì)于更正式的 hash 算法,hash 函數(shù)應(yīng)該依賴(lài)于你所擁有的數(shù)據(jù)。這在 Mike Ash 的文章和其他一些地方有所涉及。
在 hash 文檔中,有下面這樣一段話(huà):
如果一個(gè)被插入集合類(lèi)的可變對(duì)象是依據(jù)其 hash 值來(lái)決定其在集合中的位置的話(huà),這個(gè)對(duì)象的 hash 函數(shù)所返回的值在該對(duì)象存在于集合中時(shí)是不允許改變的。因此,要么使用一個(gè)和對(duì)象內(nèi)部 狀態(tài)無(wú)關(guān)的 hash 函數(shù),要么確保在對(duì)象處于集合中時(shí)其內(nèi)部狀態(tài)不發(fā)生改變。比如說(shuō),一個(gè)可 變字典可以被放到一個(gè) hash table 中,但是只要這個(gè)字典還在 hash table 中時(shí),你就不能 更改它。(注意,要知道一個(gè)給定對(duì)象是不是存在于某個(gè)集合中是一件很困難的事情。)
這也是你需要確保對(duì)象的不可變性的另一個(gè)重要原因。只要確保了這一點(diǎn),你就不必再擔(dān)心這個(gè)問(wèn)題了。
為了讓我們的對(duì)象更有用,我們最好實(shí)現(xiàn)一下 NSCopying 接口。這能夠使我們能在容器類(lèi)中使用它們。對(duì)于我們的類(lèi)的一個(gè)可變的變體,可以這么實(shí)現(xiàn) NSCopying:
- (id)copyWithZone:(NSZone *)zone
{
Person* p = [[Person allocWithZone:zone] initWithName:self.name
birthDate:self.birthDate
numberOfKids:self.numberOfKids];
return p;
}
然而,在接口的文檔中,他們提到了另一種實(shí)現(xiàn) NSCopying 的方式:
對(duì)于不可變的類(lèi)和其內(nèi)容來(lái)說(shuō),NSCopying 的實(shí)現(xiàn)應(yīng)該保持原來(lái)的對(duì)象,而不是創(chuàng)建一份新的拷貝。
所以,對(duì)于我們的不可變版本,我們只需要這樣就夠了:
- (id)copyWithZone:(NSZone *)zone
{
return self;
}
如果我們想要序列化對(duì)象,我們可以實(shí)現(xiàn) NSCoding。這個(gè)接口中有兩個(gè) required 的方法:
- (id)initWithCoder:(NSCoder *)decoder
- (void)encodeWithCoder:(NSCoder *)encoder
實(shí)現(xiàn)這個(gè)和實(shí)現(xiàn)判等方法同樣直接,也同樣機(jī)械化:
- (id)initWithCoder:(NSCoder *)aDecoder
{
self = [super init];
if (self) {
_name = [aDecoder decodeObjectForKey:@"name"];
_birthDate = [aDecoder decodeObjectForKey:@"birthDate"];
_numberOfKids = [aDecoder decodeIntegerForKey:@"numberOfKids"];
}
return self;
}
- (void)encodeWithCoder:(NSCoder *)aCoder
{
[aCoder encodeObject:self.name forKey:@"name"];
[aCoder encodeObject:self.birthDate forKey:@"birthDate"];
[aCoder encodeInteger:self.numberOfKids forKey:@"numberOfKids"];
}
可以在 NSHipster 和 Mike Ash 的博客上了解這方面的更多內(nèi)容。順帶一提,在處理比如來(lái)自網(wǎng)絡(luò)的數(shù)據(jù)這樣不信任的來(lái)源的數(shù)據(jù)時(shí),不要使用 NSCoding,因?yàn)閿?shù)據(jù)可能被篡改過(guò)。通過(guò)修改歸檔的數(shù)據(jù),很容易實(shí)施遠(yuǎn)程代碼運(yùn)行攻擊。在處理這樣的數(shù)據(jù)時(shí),應(yīng)該使用 NSSecureCoding 或者像 JSON 這樣的自定義格式
現(xiàn)在,我們還有一個(gè)問(wèn)題:這些能自動(dòng)化么?答案是能。一種方式是1代碼生成,但是幸運(yùn)的是有一種更好的替代:Mantle。Mantle 使用自舉 (introspection) 的方法生成 isEqual: 和 hash。另外,它還提供了一些幫助你創(chuàng)建字典的方法,它們可以被用來(lái)讀寫(xiě) JSON。當(dāng)然,一般來(lái)說(shuō)在運(yùn)行時(shí)做這些不如你自己寫(xiě)起來(lái)高效,但是另一方面,自動(dòng)處理這個(gè)流程的話(huà)犯錯(cuò)的可能性要小得多。
在 C 中可變值是默認(rèn)的,其實(shí)在 Objective-C 中也是這樣的。一方面,這非常方便,因?yàn)槟憧梢栽谌魏螘r(shí)候改變它。在構(gòu)建相對(duì)小的系統(tǒng)外,這一般不成問(wèn)題。但是正如我們中很多人的經(jīng)驗(yàn)一樣,在構(gòu)建較大的系統(tǒng)時(shí),使用不可變的對(duì)象會(huì)容易得多。在 Objective-C 中,我們一直是使用不可變對(duì)象的,現(xiàn)在其他的語(yǔ)言也逐漸開(kāi)始添加不可變對(duì)象了。
我們來(lái)看看使用可變對(duì)象的兩個(gè)問(wèn)題。其中一個(gè)是它們有可能在你不希望的時(shí)候發(fā)生改變,另一個(gè)是在多線程中使用可變對(duì)象。
假設(shè)我們有一個(gè) table view controller,其中有一個(gè) people 屬性:
@interface ViewController : UITableViewController
@property (nonatomic) NSArray* people;
@end
在實(shí)現(xiàn)中,我們僅僅把數(shù)組中的每個(gè)元素映射到一個(gè) cell 中:
- (NSInteger)numberOfSectionsInTableView:(UITableView*)tableView
{
return 1;
}
- (NSInteger)tableView:(UITableView*)tableView numberOfRowsInSection:(NSInteger)section
{
return self.people.count;
}
現(xiàn)在,在設(shè)定上面的 view controller 中,我們的代碼可能是這樣的:
self.items = [NSMutableArray array];
[self loadItems]; // Add 100 items to the array
tableVC.people = self.items;
[self.navigationController pushViewController:tableVC animated:YES];
table view 將開(kāi)始執(zhí)行 tableView:numberOfRowsInSection: 之類(lèi)的方法,一開(kāi)始,一切都 OK。但是假設(shè)在某個(gè)時(shí)候,我們進(jìn)行了這樣的操作:
[self.items removeObjectAtIndex:1];
這改變了 items 數(shù)組,但是它同時(shí)也改變了我們的 table view controller 中的 people 數(shù)組。如果我們沒(méi)有進(jìn)一步地同 table view controller 進(jìn)行通訊的話(huà),table view 還會(huì)認(rèn)為有 100 個(gè)元素需要顯示,然而我們的數(shù)組卻只包括 99 個(gè)元素。你大概知道我們會(huì)面臨怎樣的窘境了。在這里,我們應(yīng)該做的是將屬性聲明為 copy:
@interface ViewController : UITableViewController
@property (nonatomic, copy) NSArray* items;
@end
現(xiàn)在,我們?cè)趯⒖勺償?shù)組設(shè)置給 items 的時(shí)候,會(huì)生成一個(gè)不可變的 copy。如果我們?cè)O(shè)定的是一個(gè)通常 (不可變) 的數(shù)組,那么 copy 操作是沒(méi)有開(kāi)銷(xiāo)的,它僅僅只是增加了引用計(jì)數(shù)。
假設(shè)我們有一個(gè)用來(lái)表示銀行賬號(hào)的可變對(duì)象 Account,其有一個(gè) transfer:to: 方法:
- (void)transfer:(double)amount to:(Account*)otherAccount
{
self.balance = self.balance - amount;
otherAccount.balance = otherAccount.balance + amount;
}
多線程的代碼可能會(huì)在以很多方式掛掉。比如線程 A 要讀取 self.balance,線程 B 有可能在 A 繼續(xù)之前就修改了這個(gè)值。對(duì)于這其中可能造成的各種風(fēng)險(xiǎn),請(qǐng)參看我們的話(huà)題二。
如果我們使用的是不可變對(duì)象的話(huà),事情就簡(jiǎn)單多了。我們不能改變它們,這個(gè)規(guī)則迫使我們?cè)谝粋€(gè)完全不一樣的層級(jí)上來(lái)提供可變性,這將使代碼簡(jiǎn)單得多。
不可變性還能在緩存數(shù)值方面幫助我們。比如,假設(shè)你已經(jīng)將一個(gè) markdown 文檔解析成一個(gè)帶有表示各種不同元素的結(jié)點(diǎn)的樹(shù)結(jié)構(gòu)了。在你想從這個(gè)結(jié)構(gòu)中生成 HTML 的時(shí)候,因?yàn)槟阒肋@些元素都不會(huì)再改變,所以可以該將這些值都緩存下來(lái)。如果你的對(duì)象是可變的,你可能就需要每次都從頭開(kāi)始生成 HTML,或者是為每一個(gè)對(duì)象做構(gòu)建優(yōu)化和觀察操作。如果是不可變的話(huà),你就不必?fù)?dān)心緩存會(huì)失效了。當(dāng)然,這可能會(huì)帶來(lái)性能的下降,但是在絕大多數(shù)情況下,簡(jiǎn)單帶來(lái)的好處相比于那一點(diǎn)輕微的性能下降是值得的。
不可變對(duì)象是從像 Haskell 這樣的函數(shù)式編程語(yǔ)言中借鑒過(guò)來(lái)的概念。在 Haskell 中,值默認(rèn)都是不可變的。Haskell 程序一般都有一個(gè)單純函數(shù)式 (purely functional) 作為核心,在其中沒(méi)有可變對(duì)象,沒(méi)有狀態(tài),也沒(méi)有像 I/O 這樣的副作用。
在 Objective-C 程序中我們可以借鑒這些。在任何可能的地方使用不可變的對(duì)象,我們的程序會(huì)變得容易測(cè)試得多。Gary Bernhardt 做了一個(gè)很棒的演講,向我們展示了使用不可變對(duì)象如何幫助我們開(kāi)發(fā)更好的軟件。在演講中他用的是 Ruby,但是在 Objective-C 中,概念其實(shí)是相通的。