在這篇文章里,我們會(huì)看看如何用 Objective-C 寫值對(duì)象 (value objects)。在編寫中,我們會(huì)接觸到 Objective-C 中的一些重要的接口和方法。所謂值對(duì)象,就是指那些能夠被判等的,持有某些數(shù)值的對(duì)象 (對(duì)它們判等時(shí)我們看重值是否相等,而對(duì)是否是同一個(gè)對(duì)象并不是那么關(guān)心)。通常來說,值對(duì)象會(huì)被用作 model 對(duì)象。比如像下面的 Person
對(duì)象就是一個(gè)簡單的例子:
@interface Person : NSObject
@property (nonatomic,copy) NSString* name;
@property (nonatomic) NSDate* birthDate;
@property (nonatomic) NSUInteger numberOfKids;
@end
創(chuàng)造這樣的對(duì)象可以說是我們?nèi)諒?fù)一日的基本工作了,雖然這些對(duì)象表面上看起來相當(dāng)簡單,但是其中暗藏玄機(jī)。
我們中有很多人會(huì)教條主義地認(rèn)為這類對(duì)象就應(yīng)該是不可變的 (immutable)。一旦你創(chuàng)建了一個(gè) Person
對(duì)象,它就不可能在做任何改變了。我們?cè)谏院髸?huì)在本話題中涉及到可變性的問題。
首先我們來看看定義一個(gè) Person
時(shí)所用到的屬性。創(chuàng)建屬性是一件機(jī)械化的工作:對(duì)于一般的屬性,你會(huì)將它們聲明為 nonatomic
。默認(rèn)情況下,對(duì)象屬性是 strong
的,標(biāo)量屬性是 assign
的。但是有一個(gè)例外,就是對(duì)于具有可變副本的屬性,我們傾向于將其聲明為 copy
。比如說,name
屬性的類型是 NSString
,有可能有人創(chuàng)建了一個(gè) Person
對(duì)象,并且給這個(gè)屬性賦了一個(gè) NSMutableString
的名字值。然后過了一會(huì)兒,這個(gè)可變字符串被變更了。如果我們的屬性不是 copy
而是 strong
的話,隨著可變字符串的改變,我們的 Person
對(duì)象也將發(fā)生改變,這不是我們希望發(fā)生的。對(duì)于類似數(shù)組或者字典這樣的容器類來說,也是這樣的情況。
要注意的是這里的 copy 是淺拷貝;容器里還是會(huì)包含可變對(duì)象。比如,如果你有一個(gè) NSMutableArray* a
,其中有一些 NSMutableDictionary
的元素,那么 [a copy]
將返回一個(gè)不可變的數(shù)組,但是里面的元素依然是同樣的 NSMutableDictionary
對(duì)象。我們稍后會(huì)看到,對(duì)于不可變對(duì)象的 copy 是沒有成本的,只會(huì)增加引用計(jì)數(shù)而已。
因?yàn)閷傩允窍鄬?duì)最近才加入到 Objective-C 的,所以在較老的代碼中,你有可能不會(huì)見到屬性。取而代之,可能會(huì)有自定義的 getter 和 setter,或者直接是實(shí)例變量。對(duì)于最近的代碼,看起來大家都贊同還是使用屬性比較好,這也正是我們所推薦的。
如果我們需要的是不可變對(duì)象,那么我們要確保它在被創(chuàng)建后就不能再被更改。我們可以通過使用初始化方法并且在接口中將我們的屬性聲明為 readonly 來實(shí)現(xiàn)這一點(diǎn)。我們的接口看起來是這樣的:
@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法確定 `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)很有幫助,在寫和 Person
對(duì)象一起工作的其他類的時(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)。首先,我們需要寫一個(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;
}
如上,我們先檢查輸入和自身是否是同樣的類。如果不是的話,那肯定就不相等了。然后對(duì)每一個(gè)對(duì)象屬性,判斷其指針是否相等。||
操作符的操作看起來好像是不必要的,但是如果我們需要處理兩個(gè)屬性都是 nil
的情形的話,它能夠正確地返回 YES
。比較像 NSUInteger
這樣的標(biāo)量是否相等時(shí),則只需要使用 ==
就可以了。
還有一件事情值得一提:這里我們將不同的屬性比較的結(jié)果分開存儲(chǔ)到了它們自己的 BOOL
中。在實(shí)踐中,可能將它們放到一個(gè)大的判斷語句中會(huì)更好,因?yàn)槿绻@么做的話你就可以避免一些不必要的取值和比較了。比如在上面的例子中,如果 name
已經(jīng)不相等了的話,我們就沒有必要再檢查其他的屬性了。將所有判斷合并到一個(gè) if 語句中我們可以自動(dòng)地得到這樣的優(yōu)化。
接下來,按照文檔所說,我們還需要實(shí)現(xiàn)一個(gè) hash 函數(shù)。蘋果如是說:
如果兩個(gè)對(duì)象是相等的,那么它們必須有同樣的 hash 值。如果你在一個(gè)子類里定義了 isEqual: 方法,并且打算將這個(gè)子類的實(shí)例放到集合類中的話,那么你一定要確保你也在你的子類里定義了 hash 方法,這是非常重要的。
首先,我們來看看如果不實(shí)現(xiàn) hash
方法的話,下面的代碼會(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è),所以我們還是按照文檔說的來做吧。
可能你還記得你在計(jì)算機(jī)科學(xué)課程中學(xué)到過,編寫一個(gè)好的 hash 函數(shù)是一件不太容易的事情。好的 hash 函數(shù)需要兼?zhèn)?em>確定性和均布性。確定性需要保證對(duì)于同樣的輸入總是能生成同樣的 hash 值。均布性需要保證輸出的結(jié)果要在輸出范圍內(nèi)均勻地對(duì)應(yīng)輸入。你的輸出分布越均勻,就意味著當(dāng)你將這些對(duì)象用在集合中時(shí),性能會(huì)越好。
首先我們得搞清楚到底發(fā)生了什么。讓我們來看看沒有實(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í)間來執(zhí)行。作為對(duì)比,當(dāng)我們實(shí)現(xiàn)一個(gè)基本的 hash
方法的時(shí)候,同樣的代碼只花了 0.4 秒。這并不是精確的性能測(cè)試,但是卻足以告訴我們實(shí)現(xiàn)一個(gè)正確的 hash
函數(shù)的重要性。對(duì)于 Person
這個(gè)類來說,我們可以從這樣一個(gè) hash 函數(shù)開始:
- (NSUInteger)hash
{
return self.name.hash ^ self.birthDate.hash ^ self.numberOfKids;
}
這將從我們的屬性中取出三個(gè) hash 值,然后將它們做 XOR
(異或) 操作。在這里,這個(gè)方法對(duì)我們的目標(biāo)來說已經(jīng)足夠好了,因?yàn)閷?duì)于短字符串 (以前這個(gè)上限是 96 個(gè)字符,不過現(xiàn)在不是這樣了,參見 CFString.c 中 hash 的部分) 來說,NSString
的 hash 函數(shù)表現(xiàn)很好。對(duì)于更正式的 hash 算法,hash 函數(shù)應(yīng)該依賴于你所擁有的數(shù)據(jù)。這在 Mike Ash 的文章和其他一些地方有所涉及。
在 hash
文檔中,有下面這樣一段話:
如果一個(gè)被插入集合類的可變對(duì)象是依據(jù)其 hash 值來決定其在集合中的位置的話,這個(gè)對(duì)象的 hash 函數(shù)所返回的值在該對(duì)象存在于集合中時(shí)是不允許改變的。因此,要么使用一個(gè)和對(duì)象內(nèi)部 狀態(tài)無關(guān)的 hash 函數(shù),要么確保在對(duì)象處于集合中時(shí)其內(nèi)部狀態(tài)不發(fā)生改變。比如說,一個(gè)可 變字典可以被放到一個(gè) hash table 中,但是只要這個(gè)字典還在 hash table 中時(shí),你就不能 更改它。(注意,要知道一個(gè)給定對(duì)象是不是存在于某個(gè)集合中是一件很困難的事情。)
這也是你需要確保對(duì)象的不可變性的另一個(gè)重要原因。只要確保了這一點(diǎn),你就不必再擔(dān)心這個(gè)問題了。
為了讓我們的對(duì)象更有用,我們最好實(shí)現(xiàn)一下 NSCopying
接口。這能夠使我們能在容器類中使用它們。對(duì)于我們的類的一個(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ì)于不可變的類和其內(nèi)容來說,NSCopying 的實(shí)現(xiàn)應(yīng)該保持原來的對(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)容。順帶一提,在處理比如來自網(wǎng)絡(luò)的數(shù)據(jù)這樣不信任的來源的數(shù)據(jù)時(shí),不要使用 NSCoding
,因?yàn)閿?shù)據(jù)可能被篡改過。通過修改歸檔的數(shù)據(jù),很容易實(shí)施遠(yuǎn)程代碼運(yùn)行攻擊。在處理這樣的數(shù)據(jù)時(shí),應(yīng)該使用 NSSecureCoding
或者像 JSON 這樣的自定義格式
現(xiàn)在,我們還有一個(gè)問題:這些能自動(dòng)化么?答案是能。一種方式是1代碼生成,但是幸運(yùn)的是有一種更好的替代:Mantle。Mantle 使用自舉 (introspection) 的方法生成 isEqual:
和 hash
。另外,它還提供了一些幫助你創(chuàng)建字典的方法,它們可以被用來讀寫 JSON。當(dāng)然,一般來說在運(yùn)行時(shí)做這些不如你自己寫起來高效,但是另一方面,自動(dòng)處理這個(gè)流程的話犯錯(cuò)的可能性要小得多。
在 C 中可變值是默認(rèn)的,其實(shí)在 Objective-C 中也是這樣的。一方面,這非常方便,因?yàn)槟憧梢栽谌魏螘r(shí)候改變它。在構(gòu)建相對(duì)小的系統(tǒng)外,這一般不成問題。但是正如我們中很多人的經(jīng)驗(yàn)一樣,在構(gòu)建較大的系統(tǒng)時(shí),使用不可變的對(duì)象會(huì)容易得多。在 Objective-C 中,我們一直是使用不可變對(duì)象的,現(xiàn)在其他的語言也逐漸開始添加不可變對(duì)象了。
我們來看看使用可變對(duì)象的兩個(gè)問題。其中一個(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 將開始執(zhí)行 tableView:numberOfRowsInSection:
之類的方法,一開始,一切都 OK。但是假設(shè)在某個(gè)時(shí)候,我們進(jìn)行了這樣的操作:
[self.items removeObjectAtIndex:1];
這改變了 items
數(shù)組,但是它同時(shí)也改變了我們的 table view controller 中的 people
數(shù)組。如果我們沒有進(jìn)一步地同 table view controller 進(jìn)行通訊的話,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 操作是沒有開銷的,它僅僅只是增加了引用計(jì)數(shù)。
假設(shè)我們有一個(gè)用來表示銀行賬號(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)參看我們的話題二。
如果我們使用的是不可變對(duì)象的話,事情就簡單多了。我們不能改變它們,這個(gè)規(guī)則迫使我們?cè)谝粋€(gè)完全不一樣的層級(jí)上來提供可變性,這將使代碼簡單得多。
不可變性還能在緩存數(shù)值方面幫助我們。比如,假設(shè)你已經(jīng)將一個(gè) markdown 文檔解析成一個(gè)帶有表示各種不同元素的結(jié)點(diǎn)的樹結(jié)構(gòu)了。在你想從這個(gè)結(jié)構(gòu)中生成 HTML 的時(shí)候,因?yàn)槟阒肋@些元素都不會(huì)再改變,所以可以該將這些值都緩存下來。如果你的對(duì)象是可變的,你可能就需要每次都從頭開始生成 HTML,或者是為每一個(gè)對(duì)象做構(gòu)建優(yōu)化和觀察操作。如果是不可變的話,你就不必?fù)?dān)心緩存會(huì)失效了。當(dāng)然,這可能會(huì)帶來性能的下降,但是在絕大多數(shù)情況下,簡單帶來的好處相比于那一點(diǎn)輕微的性能下降是值得的。
不可變對(duì)象是從像 Haskell 這樣的函數(shù)式編程語言中借鑒過來的概念。在 Haskell 中,值默認(rèn)都是不可變的。Haskell 程序一般都有一個(gè)單純函數(shù)式 (purely functional) 作為核心,在其中沒有可變對(duì)象,沒有狀態(tài),也沒有像 I/O 這樣的副作用。
在 Objective-C 程序中我們可以借鑒這些。在任何可能的地方使用不可變的對(duì)象,我們的程序會(huì)變得容易測(cè)試得多。Gary Bernhardt 做了一個(gè)很棒的演講,向我們展示了使用不可變對(duì)象如何幫助我們開發(fā)更好的軟件。在演講中他用的是 Ruby,但是在 Objective-C 中,概念其實(shí)是相通的。