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