[iOS开发]——协议与分类(effectiveOC2.0阅读笔记)
- 第23条:通过委托与数据源协议进行对象间通信
- 第24条:将类的实现代码分散到便于管理的数个分类之中
- 第25条:总是为第三方类的分类名称加前缀
- 第26条:勿在类中声明属性
- 第27条:使用“class-continuation分类”隐藏实现细节
- 第28条:通过协议提供匿名对象
OC语言有一项特性叫“协议”(protocol),它与java的“接口”(interface)类似。OC不支持多重继承,因而我们把某个类应该实现的一系列方法定义在协议里面。协议最为常见的用途时实现委托模式,不过也有其他用法。理解并善用协议可令代码变得更容易维护,因为协议这种方式能很好的描述接口。
“分类”(Category)也是OC的一项重要语言特性。利用分类机制,我们无须继承子类即可直接为当前类添加方法。
第23条:通过委托与数据源协议进行对象间通信
对象之间经常需要相互通信,而通信方式有很多种。OC开发者广泛使用一种“委托模式”(Delegate pattern)的编程设计模式来实现对象间的通信,该模式的主旨是:定义一套接口,某对象若想接收另一个对象的委托,则需遵从此接口,以便于成为其“委托对象”(delegate)。而这“另一个对象”则可以给委托对象回传一些信息,也可以在发生相关事件时通知委托对象。
此模式可将数据与业务逻辑解藕。比如说,用户界面里有个现实一系列数据所用的视图,那么,此视图只应包含显示数据所需的逻辑代码,而不应决定要显示和中数据以及数据之间如何交互等问题。视图对象的属性中,可以包含负责数据与事件处理的对象。这两种对象分别称为:“数据源”(data source)与“委托”(delegate)。
OC中,一般通过“协议”这项语言特性来实现此模式,整个Cocoa系统框架都是这么做的。这样写的话,你的代码可以更好地和系统框架融合在一起。
举个例子,假如我们需要编写一个从网上获取数据的类。此类也许要从远端服务器的某个资源里获取数据。那个远程服务器啃根果很长时间才会应答,而在获取数据的过程中阻塞应用程序则是一种非常糟糕的做法。于是,在这种情况下,我们通常会使用委托模式:获取网络数据的类含有一个“委托对象”,在获取完数据之后,它会回调这个委托对象。如图所示解释了这一过程:EOCDataModel对象就是EOCNetworkFetcher的委托对象。EOCDataModel请求EOCNetworkFetcher“以异步方式执行一项任务”(perform a task asynchronously),而EOCNetworkFetcher在执行问这项任务之后,九回通知其委托对象,也就是EOCDataModel。
利用协议机制,很容易就能以OC代码实现此模式。代码可以这样来写:
@protocol EOCNetworkFetcherDelegate
- (void) networkFetcher:(EOCNetworkFetcher *)fetcher didReceiveData:(NSData *)data;
- (void) networkFetcher:(EOCNetworkFetcher *)fetcher didFailWithError:(NSError *)error;
@end
有了这个协议之后,类就可以用一个属性来存放其委托对象了。在本例中,这个类就是EOCNetworkFether类。于是,此类的接口可以写成这样:
@interface EOCNetworkFetcher : NSObject
@property (nonatomic, weak) id <EOCNetworkFetcherDelegate> delegate;
@end
一定要注意:这个属性需定义成weak,而非strong,因为两者之间必须为“非拥有关系”(nonowning relationship)。通常情况下,扮演delegate的那个对象也要持有本对象。一直到用完本对象之后,才会释放。
假如声明属性的时候用strong将本对象与委托对象之间定为“拥有关系”,那么就会引入“保留环”(retain cycle)。因此, 本类中存放委托对象的这个属性要么定义为weak,要么定义为unsafe_unretained。如果需要在相关对象销毁时自动清空(autoniling),则定义为前者,若不需要自动清空,则定义为后者。下图演示了本对象与委托对象之间的所有权关系:
实现委托对象的办法是声明某个类遵从委托协议,然后把协议中想实现的那些方法在类里实现出来。某类若要遵从委托协议,可以在其接口中声明,也可以在“class-continuation 分类”中声明。如果要象外界公布此类实现了某协议,那么就在接口中声明,而如果这个协议是个委托协议的话,那么通常只会在类的内部使用。所以说,这种情况一般都是在“class-continuation 分类”里声明的:
@implementation EOCDataModel () <EOCNetworkFetcherDelegate>
@end
@implementation EOCDataModel
- (void) networkFetcher: (EOCNetworkFetcher *)fetcher didReceiveData: (NSData *) data {
/* Handle data */
}
- (void) networkFetcher: (EOCNetworkFetcher *)fetcehr didFailWithError: (NSError *)error {
/* Handle error */;
}
@end
委托协议中的方法一般都是“可选的”(optional),因为扮演“受委托者”角色的这个对象未必关心其中的所有方法。为了指明可选方法,委托协议经常使用@optional关键字来标注其大部分或全部的方法:
NSData *data = /* data obtained from network */;
if ([_delegate respondsToSelector: @selector(networkFetcher:didReceiveData:)]) {
[_delegate networkFetcher:self didReceiveData:data];
}
可以用“respondsToSelector:”来判断委托对象是否实现了相关方法。如果实现了,就调用,如果没有实现,就不执行任何操作,因为给nil发送消息将使if语句的值成为false。
在调用delegate对象中的方法时,总是应该把发起委托的实例也一并传入方法中,这样在delegete对象在实现相关方法时,救能根据传入的实例分别执行不同的代码了:
- (void)networkFetcher: (EOCNetworkFetcher *)fetcher didReceiveData: (NSData *)data {
if (fetcher == _myFetcherA) {
/* Handle data */
} else if (fetcher == _myFetcherB) {
/* Handle data */
}
}
上面这段代码表明,委托对象有两个不同的“网络数据获取器”,所以他们必须根据所传的参数判断到底是哪个EOCNetworkFetcher获取到了数据。若没有此信息,则委托对像在同一时间只能使用一个网络请求获取器,这么做不太好。
delegate里的方法也可以用于从获取委托对象中获取信息。比方说,EOCNetworkFetcher类也许想提供一种机制:在获取数据的时候如果遇到了“重定向”(redirect),那么将询问其委托对象是否应该发生重定向。delegate对象中的相关方法也可以写成这样:
- (BOOL)networkFetcher: (EOCNetworkFetcher *)fetcher shouldFollowRedirectToURL: (NSURL *) url;
通过这个例子,大家应该很容易理解什么是“委托模式了”
:因为对象把应对某个行为的责任委托给另一个类了。
也可以用协议定义一套接口,令某类经由该接口获取其所需的数据。委托模式的这一用法只在向类提供数据,故而又称“数据源模式”(Data Source Pattern)。此模式中,信息从数据源(DataSource)流向类(Class);而在常规的委托模式中,信息则从类流向受委托者(Delegate)。如下图所示:
假如我们在实现委托模式与数据源模式时,如果协议中的 方法是可选的,每次调用委托对象时都需要用类型信息查询方法去判断是否实现了该委托协议方法。 这样的话,如果经常需要频繁地调用某些方法,这些方法为一个协议中多个可选方法中的一个的时候,经常会因为多次的判断而降低代码的性能,对于此类问题,我们的解决方法如下:
- (void) setDelegate:(id<EOCNetworkFetcher>) delegate {
_delegate = delegate;
_delegateFlags.didReceiveData = [delegate respondsToSelector: @selector(networkFetcher:didReceiveData:)];
_delegateFlags.didFailWithError = [delegate respondsToSelector: @selector(networkFetcher:didFailWithError:)];
_delegateFlags.didUpdateProgressTo = [delegate respondsToSelector:@selector(networkFetcher:didUpdateProgressTo:)];
}
这样的话,每次调用delegate的相关方法之前 ,就不用检测委托对象是否能响应给定的选择子了,而是直接查询结构体里的标志:
if (_delegateFlags.didUpdateProgressTo) {
[_delegate networkFetcher:self didUpdateProgressTo: currentProgress];
}
要点
- 委托模式为对象提供了一套接口,使其可由此将相关事件告知其他对象。
- 将委托对象应该支持的接口定义成协议,在协议中把可能需要处理的事件定义成方法。
- 当某对象需要从另外一个对象中获取数据时,可以使用委托模式。这种情况下,该模式亦称“数据源协议”(data source protocal)。
- 若有必要,可实现含有位段的结构体,将委托对象是否能响应相关协议方法这一信息缓存至其中。
第24条:将类的实现代码分散到便于管理的数个分类之中
类中经常会填满各种方法,这些方法的代码则全部堆在一个巨大的实现文件里。有时这么做是合理的,因为即便通过重构把这个类打散,效果也不会更好。在此情况下,可以通过OC的“分类”机制,把类代码按逻辑划入几个分区中,这对开发与调试都有好处。
比如说,我们把个人信息建模为类。那么这个类可能包含下面这几个方法:
#import <Foundation/Foundation.h>
@interface EOCPerson : NSObject
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
@property (nonatomic, strong, readonly) NSArray *friends;
- (id)initWithFirstName: (NSString *)firstName andLastName:(NSString *)lastName;
/ * Friendship methods * /
- (void) addFriend:(EOCPerson *)person;
- (void) removeFriend:(EOCPerson *)person;
- (BOOL) isFriendsWith:(EOCPerson *)person;
/ * Work methods * /
- (void) performDaysWork;
- (void) takeVacationFromWork;
/ * Play methods * /
- (void) goToTheCinema;
- (void) goToSportsGame;
@end
在实现该类时,所有方法的代码可能会写在一个大文件里。如果继续向该类添加方法时,这个文件就会越来越大,变得更加难以管理。所以说,如果把这个类分成几个不同的部分。例如,可以用“分类”机制把刚才的类改写成:
#import <Foundation/Foundation.h>
@interface EOCPerson : ESObject
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
@property (nonatomic, strong, readonly) NSArray *firends;
- (id)initWithFirstName: (NSString *)fitstName andLastName: (NSString *)lastName;
@end
@interface EOCPerson (Friendship)
- (void)addFriend:(EOCPerson *)person;
- (void)removeFriend:(EOCPerson *)person;
- (BOOL)isFriendsWith:(EOCPerson *)person;
@end
@interface EOCPerson (Work)
- (void)performDaysWork;
- (void)takeVacationFromWork;
@end
@interface EOCPerson (Play)
- (void)goToTheCinema;
- (void)goToSportsGame;
@end
现在,类的实现代码按照方法分成了好几个部分。所以说,这项语言特性就叫做“分类”啦。在本例中,类的基本要素(诸如属性与初始化方法等)都声明在“主实现”(main implementation)里。执行不同类型的操作所用的另几套方法则归入各个分类中。
使用分类机制之后,依然可以把整个类都定义在一个接口文件中,并将其代码写在一个实现文件中。可是,随着分类数量增加,当前这份实现文件很快就膨胀得无法管理了。此时就可以把每个分类提取到各自的文件中去,以EOCPerson为例,可以按照其分类拆分成下列几个文件:
EOCPerson + Friendship(.h/.m)
EOCPerson + Work(.h/.m)
EOCPerson + Play(.h/.m)
比方说,与交友功能相关的那个分类可以这样写:
//EOCPerson + Friendship.h
#import "EOCPerson.h"
@interface EOCPerson (Friendship)
- (void) addFriend:(EOCPerson *)person;
- (void) removeFriend:(EOCPerson *)person;
- (void) isFriendsWith:(EOCPerson *)person;
@end
//EOCPerson + Friendship.m
#import "EOCPerson + Friendship.h"
@implementation EOCPerson (Friendship)
- (void) addFriend: (EOCPerson *)person {
/* ... */
}
- (void) removeFriend:(EOCPerson *)person {
/* ... */
}
- (BOOL) isFriendsWith:(EOCPerson *)person {
/* ... */
}
@end
通过分类机制,可以把类代码分成很多个易于管理的小块,以便单独检视。使用分类机制之后,如果想用分类中的方法,那么要记得在引入EOCPerson.h时一并引入分类的头文件。虽然稍微有点麻烦,不过分类仍然是一种管理代码的好办法。
即使类本身不是太大,我们也可以使用分类机制将其切割成几块,把相应代码归入不同的“功能区”(functional area)中。这个类用于执行从URL中获取数据的请求,而且通常使用HTTP协议从英特网中的某个服务器上获取,不过,由于该类设计得较为通用,所以也可以使用其他协议。与标准的URL相比,执行HTTP请求时还需要另外一些信息,例如“HTTP方法”(HTTP method, GET, POST等)或HTTP头(HTTP header)。
然而却不便从NSURLRequest中继承子类以实现HTTP协议的特殊需求,因为本类包裹了一套操作CFURLRequest数据结构所需的C函数,所有HTTP方法都包含在这个结构里。于是,为了扩展NSURLRequest类,把与HTTP有关的方法归入名为NSHTTPURLRequest的分类中,而把与可变版本有关的方法归入名为NSMutableHTTPURLRequest的分类中,这样,所有底层CFURLRequest函数就都封装在同一个OC类里了,而在这个类中,与 HTTP有关的方法却又要单独放在一处,因为若是不这么做的话,该类的使用者就会有疑问:为什么能在使用FTP协议的request对象上设置HTTP方法呢?
之所以要将类代码打散到分类中还有个原因,就是便于调试:对于某个类中的所有方法来说,分类名称都会出现在其符号中。例如,“addFriend:”方法的“符号名”(symbol name)如下:
- [EOCPerson (Friendship) addFriend:]
在调试器的回溯信息中,会看到类似下面这样的内容:
frame #2: 0x00001c50 Test'-[EOCPerson(Friendship) addFriend:] + 32 at main.m:46
根据回溯信息中的分类名称,很容易就能精确定位到类中的方法所属的功能区,这对于某些应该视为私有的方法来说更是极为有用。可以创建名为Private的分类, 把这种方法全都放在里面。这个分类里的方法一般只会在类或框架内部使用,而无须对外公布。这样一来,类的使用者有时可能会在查看回溯信息时发现private一词,从而知道不应该直接调用此方法了。这可算作一种编写“自我描述式代码”(self-documenting code)的办法。
在编写准备分享给其他开发者使用的程序库时,可以考虑创建Private分类。经常会遇到这样一些方法:它们不是公共API的一部分,然而却非常适合在程序库之内使用。此时应该创建Private分类,如果程序库中的某个地方要用到这些方法,那就引入次分类的头文件。而分类的头文件并不随程序库一并公开,于是该库的使用者也就不知道库里还有这些私有方法了。
要点
- 使用分类机制把类的实现代码划分成易于管理的小块。
- 将应该视为“私有”的方法归入名叫Private的分类中,以隐藏实现细节。
第25条:总是为第三方类的分类名称加前缀
分类机制通常用于向无源码的既有类中新增功能。这个特性极为强大,但在使用时也是很容易忽视其中可能会产生的问题。这个问题在于:分类中的方法是直接添加在类里面的,它们就好比这个类中的固有方法。将分类方法加入类中这一操作是在运行期系统加载分类时完成的。运行期系统会把分类中所实现的每个方法都加入类的方法列表中。如果类中本来就有此方法,而分类又实现了一次,那么分类中的方法会覆盖原来那一份实现代码。实际上可能会发生很多此覆盖,比如某个分类中的方法。多次覆盖的结果以最后一个分类为准。
比方说,要给NSString添加分类,并在其中提供一些辅助方法,用于处理与HTTP URL有关的字符串。你可能会把分类写成这样:
@interface NSString (HTTP)
//Encode a string with URL encoding
- (NSString *)urlEncodeString;
//Decode a URL encoded string
- (NSString *)urlDecodedString;
@end
现在看来没有什么问题,可是,如果还有一个分类也往NSString里添加方法,那个分类里可能也有个名叫urlEncodedString的方法,其代码与你所添加的大同小异,但却不能正确实现你所需的功能。那个分类的加载时机如果晚于你所写的这个分类,那么其代码就会把你的那一份覆盖掉。这样的话,你在代码中调用urlEncodeedString方法时,实际执行的是按个分类里的实现代码。由于其执行结果和你预期的不同,所以自己所写的那些代码也许就无法正常运行了。这种bug很难追查,因为你可能意识不到实际执行的urlEncodedString代码并不是自己实现的那一份。
要解决此问题,一般的做法是:以命名空间来区别各个分类的名称与其中所定义的方法。想在OC中实现命名空间功能,只有一个办法,就是给相关名称加上某个公用的前缀。与给类名加前缀时所考虑的因素相似,给分类所加的前缀也要选的恰当的才行。一般来说,这个前缀应该与应用程序或程序库中其他地方所用的前缀相同。于是,我们可以给刚才那个NSStirng分类加上ABC前缀:
@interface NSString (ABC_HTTP)
//Encode a string with URL encoding
- (NSString *)abc_urlEncodedString;
//Decode a URL encoded string
- (NSString *)abc_urlDecodedString;
@end
从技术角度讲,并不是非得用命名空间把各个分类的名称区隔开不可。即便两个分类重名了,也不会出错。然而这样不好,编译器会发出类似下面这种警告信息:
warning: duplicate definition of category 'HTTP' on interface 'NSString'
即便加了前缀,也难保其他分类不会覆盖你所写的方法,然而几率却小了很多,因为其他程序库很少会和你选用同一个前缀。这样做也能避免类的开发者以后在更新该类时所添加的方法与你在分类中添加的方法重名。比如说如果你编写某个方法不加前缀,那就有可能会覆写苹果公司的自带方法,造成难以查找的bug。
此外,如果向某个类的分类中加入方法,那么在应用程序中,该类的每个实例均可调用这些方法。比如说,若是向NSString、NSArray、NSNumber这种系统类里加入方法,那么这些类的每个实例均可调用你所加的方法,即便这些实例不是由你的代码所创建出来的,也依然会如此。如果你无意中把自己分类里的方法名起的和其他分类一样,或是与第三方库所添加分类中的方法重名了,那么就可能会出现奇怪的bug,因为你以为此方法执行的是自己所写的那份代码,然而实际上却不是。与之相似,刻意覆写分类中的方法也不好,尤其是当你把代码发布为程序库供其他开发者使用,而他们又要依赖系统中现存的功能时,更不应该这么做。若是其他开发者又覆写了同一个方法,那么情况会更糟,因为无法确定最后到底会执行哪份实现代码。这又一次说明了为何要给分类中的方法名加上前缀。
要点
- 向第三方类中添加分类时,总应给其名称加上你专用的前缀。
- 向第三方类中添加分类时,总应给其中的方法名加上你专用的前缀。
第26条:勿在类中声明属性
属性是封装数据的方式。尽管从技术上说,分类里也可以声明属性,但这种做法还是要尽量避免。原因在于,除了“class-continuation分类”之外,其他分类都无法向类中新增实例变量,因此,它们无法把实现属性所需的实例变量合成出来。
比方说你实现过一个表示个人信息的类,在了解过第24条之后,决定用分类机制将其代码分段。那么你可能会设计一个专门处理交友事务的分类,其中所有方法都与操作某人的朋友列表有关。若是不知道刚才讲的那个问题,可能就会把朋友列表的那项属性也放到Friendship分类里面去了:
#import <Foundation/Foundation.h>
@interface EOCPerson: NSObject
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
- (id) initWithFirstName:(NSString *) firstName andLastName: (NSString *) lastName;
@end
@implementation EOCPerson
//Methods
@end
@interface EOCPerson (Friendship)
@property (nonatomic, strong) NSArray *friends;
- (BOOL) isFriendsWith:(EOCPerson *)person;
@end
@implementation EOCPerson (Friendship)
//Methods
@end
在编译上述代码时,编译器就会给出以下警告信息:
警告的意思大致就是此分类无法合成与friends属性相关的实例变量,所以开发者需要在分类中为该属性实现存取方法。此时可以把存取方法声明为@dynamic,也就是说,这些方法等到运行期再提供,编译器目前是看不见的。如果决定使用消息转发机制在运行期拦截方法调用,并提供其实现,那么或许可以采用这种做法。
关联对象能够解决在分类中不能合成实例变量的问题。比方说,我们可以在分类中用下面这段代码实现存取方法:
#import <objc/runtime.h>
static const char *kFriendsPropertyKey = @"kFriendsPropertyKey";
@implementation EOCPerson (Friendship)
- (NSArray *)friends {
return objc_getAssociatedObject(self, kFriendsPropertyKey);
}
- (void) setFriends:(NSArray *)friends objc_setAssociatedObject (self, kFriendsPropertyKey, friends, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
@end
这样做可行,但不太理想,要把相似代码写很多遍,而且在内存管理问题上容易出错,因为我们在为属性实现存取方法时,经常会忘记遵从其内存管理语义。比方说,你可能通过属性特质(attribute)修改了某个特性的内容管理语义。而此时还要记得,在设置方法中也得修改关联对象时所用的内存管理语义才行。所以说,这个做法不坏但是不推荐。
此外,你也可以选用可变数组来实现friends属性所对应的实例变量。若是这样做,就得在设置方法中将传入的数组参数拷贝为可变版本,而这又成为另外一个编码时容易出错的地方。因此,把属性定义在“主接口”(main interface)中要比定义在分类里清晰得多。
在本例中,正确做法就是把所有属性都定义在主接口里。类所封装的全部数据都应该定义在主接口里,这里是唯一能够定义实例变量(也就是数据)的地方。而属性只是定义实例变量及相关存取方法所用的“语法糖”,所以也应遵循同实例变量一样的规则。至于分类机制,则应将其理解为一种手段,目标在于扩展类的功能,而非封装数据。
虽说如此,但有时候支付属性还是可以在分类中使用的。比方说,要在NSCalendar类中创建分类,以返回包含各个月份名称的字符串数组。由于获取方法并不访问数据,而且属性也不需要有实例变量来实现,所以可像下面这样来实现此分类:
@interface NSCalendar (EOC_Additions)
@property (nonatomic, strong, readonly) NSArray *eoc_allMonths;
@end
@implementation NSCalendar (EOC_Additions)
- (NSArray *) eoc_allMonths {
if ([self.calendarIdentifier isEqualToString:NSGregorianCalendar]) {
return @[@"January", @"February", @"March", @"April", @"May", @"June", @"July", @"August", @"September", @"October", @"November", @"December"];
} else if (/ * other calendar identifiers * /) {
/ * return months for other calendars * /
}
}
@end
由于实现属性所需的全部方法(在本例中,属性是只读的,所以只需实现一个方法)都已实现,所以不会再为该属性自动合成实例变量了。于是,编译器也就不会发出警告信息。然而,即便在这种情况下,也最好不要用属性。属性所要表达的意思是:类中有数据在支持着它。属性是用来封装数据的。在本例中,应该直接声明一个方法,用以获取月份名称列表:
@interface NSCalendar (EOC_Additions)
- (NSArray *) eoc_allMonths;
@end
要点
- 把封装数据所用的全部属性都定义在主接口里
- 在“class-continuation分类”之外的其他分类中,可以定义存取方法,但尽量不要定义属性。
第27条:使用“class-continuation分类”隐藏实现细节
类中经常会包含一些无须对外公布的方法及实例变量。其实这些内容也可以对外公布,并且写明其为私有,开发者不应依赖它们。OC动态消息系统的工作方式决定了其不可能实现真正的私有方法或私有实例变量。然而,我们最好还是只把确实需要对外公布的那部分内容公开。那么,这种不需对外公布但却应该具有的方法及实例变量应该怎么写呢?此时,这个特殊的“class-continuation分类”就派上用场了。
“class-continuation分类”和普通的分类不同,它必须定义在其所接续的那个类的实现文件里。其重要之处在于,这是唯一能声明实例变量的分类,而且此分类没有特定的实现文件,其中的方法都应该定义在类的主实现文件里。与其他分类不同,“class-continuation分类”没有名字。比如,有个类叫做EOCPerson,其“class-continuation分类”写法如下:
@interface EOCPerson ()
// Methods here
@end
为什么需要有这种分类呢?因为其中可以定义方法和实例变量。为什么能在其中定义方法和实例变量呢?只因有“稳固的ABI”这一机制,使得我们无须知道对象大小即可使用它。由于类的使用者不一定需要知道实例变量的内存布局,所以,它们也就未必非得定义在公共接口中了。基于上述原因,我们可以像在类的实现文件中那样,与“class-continuation分类”中给类新增实例变量。只需要在适当位置上多写几个括号,然后把实例变量放进去:
@interface EOCPerson () {
NSString *_anInstanceVariable;
}
//Method declaratioins here
@end
@implementation EOCPerson {
int _anotherInstanceVariable;
}
//Method implementations here
@end
这样做有什么好处呢?公共接口里本来就能定义实例变量。不过,把它们定义在"class-continuation分类"或“实现块”中可以将其隐藏起来,只供本类使用。即便在公共接口中将其标注为private,也还是会泄露细节。比方说,你有个绝密的类,不想给其他人知道。假设你所写的某个类拥有那个绝密类的实例,而这个实例变量又声明在公共接口中:
#import <Foundation/Foundation.h>
@class EOCSuperSecretClass;
@interface EOCClass : NSObject {
@private
EOCSuperSecretClass *_secretInstance;
}
@end
那么,信息就泄露了,别人就会知道有个名叫EOCSuperSecretClass的类。为解决此问题,可以不把实例变量声明为强类型,而是将其类型由EOCSuperSecretClass *改为id。然而这么做不够好,因为在类的内部使用此实例时,无法得到编译器的帮助。没必要只因为想对外界隐藏某个内容就放弃编译器的辅助检查功能吧?这个问题可以由“class-continuation分类”来解决。那个代表绝密来的实例可以声明成这样:
//EOCClass.h
#import <Foundation/Foundation.h>
@interface EOCClass: NSObject
@end
//EOCClass.m
#import "EOCClass.h"
#import "EOCSuperSecretClass.h"
@interface EOCClass () {
EOCSuperSecretClass *_secretInstance;
}
@end
@implementation EOCClass
//Methods here
@end
实例变量也可以定义在实现块里,从语法上说,这与直接添加到“class-continuation分类”等效,只是看个人喜好了。由于“class-continuation分类”里还能定义一些属性,所以在这里额外声明一些实例变量也很合适。这些实例变量并非私有的,因为在运行期总可以调用某些方法绕过此限制,不过,从一般意义上来说,它们还是私有的。此外,由于没有在公共头文件中声明,所以将代码作为程序库的一部分来发行时,其隐藏程度更好。
编写Objective-C++代码时“class-continuation分类”也尤为有用。OC++时OC与C++的混合体,其代码可以用这两种语言来编写。由于兼容性原因,游戏后端一般用C++来写。另外,有时候要使用的第三方库可能只有C++绑定,此时也必须使用C++来编码。这些情况下,使用"class- continuation分类"会很方便。假设某个类打算这样写:
#import <Foundation/Foundation.h>
#include "SomeCppClass.h"
@interface EOCClass : NSObject {
@private
SomeCppClass _cppClass;
}
@end
该类的实现文件可能叫做EOCClass.mm,其中.mm扩展名表示编译器应该将此文件按OC++来编译,否则,就无法正确引入SomeCppClass.h了。然而请注意,名为SomeCppClass的这个C++类必须完全引入,因为编译器要完整地解析其定义方能得知_cppClass实例变量的大小。于是,只要是包含EOCClass.h的类,都必须编译为Objective-C++才行,因为它们都引入了SomeCppClass类的头文件。这很快就会失控,最终导致整个应用程序全部都要编译为OC++。这么做确实完全可行,不过笔者觉得相当别扭,尤其是将代码发布为程序库供其他应用程序使用时,更不应该如此。要求第三方开发者将其源文件扩展名均改成.mm不是很合适。
你可能认为解决此问题的办法是:不引入C++类的头文件,只是向前声明该类,并且将实例变量做成指向此类的指针。
#import <Foundation/Foundation.h>
@calss SomeCppClass;
@interface EOCClass : NSObject {
@private
SomeCppClass *_cppClass;
}
@end
现在实例变量必须是指针,若不是,则编译器无法得知其大小,从而会报错。但所有指针的大小确实都是固定的,于是编译器只需知道其所指的类型即可。不过,这么做还是会遇到刚才那个问题,因为引入EOCClass头文件的源码里都包含class关键字,而这是C++的关键字,所以仍然需要按OC++来编译才行。这样做既别扭又无必要,因为该实例变量毕竟是private的,这个问题还是得用“class-continuation分类”来解决。将刚才那个类改写之后,其代码如下:
//EOCClass.h
#import <Foundation/Foundation.h>
@interface EOCClass : NSObject
@end
//EOCClass.mm
#import "EOCClass.h"
#include "SomeCppClass.h"
@interface EOCClass () {]
SomeCppClass _cppClass;
}
@end
@implementation EOCClass
@end
改写后的EOCClass类,其头文件里就没有C++代码了,使用头文件的人甚至意识不到其底层实现代码中混有C++成分。某些系统库用到了这种模式,比如网页浏览器框架WebKit,其大部分代码都以C++编写,然而对外展示出来的却是一套整洁的OC接口。CoreAnimation里面也用到了此模式,他的许多后端代码都用C++写成,但对外公布的却是一套纯OC接口。
“class-continuation分类”还有一种合理用法,就是将public接口中声明为“只读”的属性扩展为“可读写”,以便在类的内部设置其值。我们通常不整洁访问实例变量,而是通过设置访问方法来做,因为这样能够触发“键值观测”(Key-Value ObservingKVO)通知,其他对象有可能正监听此事件。出现在“class-continuation分类”或其他分类中的属性必须同类接口里的属性具备相同的特质(attribute),不过,其“只读”状态可以扩充为“可读写”。例如,有个描述个人信息的类,其公共接口如下:
#import <Foundation/Foundation.h>
@interface EOCPerson : NSObject
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
- (id)initWithFirstName:(NSString *) firstName lastName:(NSString *)lastName;
@end
只需要用上面几行代码就行了。现在EOCPerson的实现代码可以随意调用“setFirstName:”或"setLastName:"这两个设置方法,也可以用“点语法”来设置属性。这样做很有用,既能令外界无法修改对象,又能在其内部按照需要管理数据。这样,封装在类中的数据就由实例本身来控制,而外部代码则无法修改其值。若观察者(observer)正读取属性值而内部代码又在写入该属性时,则有可能引发“竞争条件”(race condition)。合理使用同步机制能缓解此问题。
如果要声明只有类的实现文件使用的私有方法的时候,我们可以将这些私有方法都声明在“class-continuarion分类”中。但是我们在其中声明私有方法的时候最好加上前缀,以体现其为私有方法。新版编译器不强制要求开发者在使用方法之前必须先声明。然而像上面这样在"class-continuation分类"中声明一下通常还是有很多好处的,因为这样可以便于我们对其的管理,使代码更加简单易读懂。
最后还要讲一种方法:若对象所遵从的协议只应视为私有,则可在“class-continuation分类”中声明。有时由于对象所遵从的某个协议在私有API中,所以我们可能不太想在公共接口中泄漏这一信息。比方说,EOCPerson遵从了名为EOCSeecretDelegate的协议。因为这个是个私有的内部协议,你甚至连名字都不想给别人知道。此时还得请“class-continuation分类”来帮忙。不要在公共接口中声明EOCPerson类遵从了EOCSecretDelegate协议,而是改到在“class-continuation分类”里面声明:
#import "EOCPerson.h"
#import "EOCSecretDelegate.h"
@interface EOCPerson () <EOCSecretDelegate>
@end
@implementation EOCPerson
/* ... */
@end
要点
- 通过“class-continuation分类”向分类中新增实例变量。
- 如果某属性在主接口中声明为“只读”,而类的内部又要用设置方法修改此属性,那么就在“class-continuation分类”中将其扩展为“可读写”。
- 把私有方法的原型声明在“class-continuation分类”里面。
- 若想使类所遵循的协议不为人所知,则可于“class-continuation分类”中声明。
第28条:通过协议提供匿名对象
协议定义了一系列方法,遵从此协议的对象应该实现它们(如果这些方法不是可选的,那么就必须实现)。于是,我们可以用协议把自己所写的API中的实现细节隐藏起来,将返回的对象设计为遵从此协议的纯id类型。这样的话,想要隐藏的类名就不会出现在API之中了。若是接口背后有多个不同的实现类,而你又不想指明具体使用那个类,那么可以考虑用这个方法——因为这些类可能会变,有时候它们又无法容纳于标准的类继承体系中,因而不能以某个公共基类来统一表示。
此概念经常称为“匿名对象”(anonymous object),这与其他语言的“匿名对象”不同,在那些语言中,该词是指以内联形式所创建出来的无名类,而此词在OC中则不是这个意思。委托与数据源对象中就曾用到这种匿名对象。例如,在定义“受委托者”(delegate)这个属性时,可以这样写:
@property (nonatomic, weak) id<EOCDelegete> delegate;
因为该属性的类型是id,所以实际上任何类的对象都能充当这一属性,即便该类不继承自NSObject也可以,只要遵循EOCDelegate协议就行。对于具备此属性的类来说,delegate就是“匿名的”(anonymous)。如有需要,可在运行期查出此对象所属的类型。然而这样做不太好,因为指定属性类型时所写的那个EOCDelegate契约已经表明此对象的具体类型无关紧要了。
NSDictionary也能实际说明这一概念。在字典中,键的标准内存管理语义就是“设置时拷贝”,而值的语义则是“设置时保留”因此,在可变版本的字典里,设置键值对所用的方法签名是:
- (void)setObject:(id)object forKey:(id<NSCopying>)key
表示键的那个参数其类型为id< NSCopying>,作为参数值的对象,它可以是任何类型,只要遵从NSCopying协议就好,这样的话,就能向该对象发送拷贝消息了.这个key参数可以视为匿名对象。与delegate属性一样,字典也不关心key对象所属的具体类,而且它也决不应该依赖于此。字典对象只要能确定它可以给此实例发送拷贝消息就行了。
处理数据库连接(database connection)的程序库也用这个思路,以匿名对象来表示从另一个库中所返回的对象。对于处理连接所用的那个类,你也许不想叫外人知道其名字,因为不同的数据库可能要用不同的类来处理。如果没办法令其都继承自同一基类,那么就得返回id类型的东西了。不过我们可以把所有数据库连接都具备的那些方法放到协议中,令返回的对象遵从此协议。协议可以这样写:
@protocol EOCDatabaseConnection
- (void)connect;
- (void)disconnect;
- (BOOL)isConneceted;
- (NSArray *)performQuery:(NSString *)query;
@end
然后,就可以用“数据库处理器”(database handler)单例来提供数据库连接了。这个单例的接口可以写成:
#import <Foundation/Foundation.h>
@protocol EOCDatabaseConnection;
@interface EOCDatabaseConnection;
+ (id)sharedInstance;
- (id<EOCDatabaseConnection>)connectionWithIdentifier: (NSString *)identifier;
@end
这样的话,处理数据库连接所用的类的名称就不会泄露了,有可能来自不同的框架的那些类现在均可以经由同一个方法来返回了。使用此API的人仅仅要求所返回的对象能用来连接、断开并查询数据库即可。这一点很重要,本例中,处理数据库连接所用的后端代码可能是用了各种第三方库来连接不同类型的数据库(例如MySQL、PostgreSQL等)。由于这些类都在多个第三方库里,所以也许没有办法令所有的连接类都继承自同一基类。因此,可以创建匿名对象吧这些第三方类简单包裹一下,使匿名对象成为其子类,并遵从EOCDatabaseConnection协议。然后,用“connectionWithIdentifier:”方法来返回这些类对象。在开发后续版本是,无须改变公共API,即可切换后端的实现类。
有时对象类型并不重要,重要的是对象有没有实现某些方法,在此情况下,也可以用这些“匿名类型”(anonymous type)来表达这一概念。即便实现代码总是使用固定的类,你可能还是会把它写成遵从某协议的匿名类型,以表示类型在此处并不重要。
CoreData框架里也有这种用法。查询CoreData数据库所得的结果由名叫NSFetchedResultsController的类来处理,如有需要,处理时还会把数据分区。在负责处理查询结果的控制器中,有个section属性,用以表示数据分区。此属性是个数组,但其中的对象却没有指明具体类型,只是说这些对象都遵从了NSFetchedResultsSectionInfo协议。下面这段代码通过控制器来获取数据分区信息:
NSFetchedResultsController *controller = /* some controller */;
NSUInteger section = /* section index to query */;
NSArray *sections = controller.sections;
id <NSFetchedResultsSectioniInfoo> sectionInfo = sections[section];
NSUInteger numberOfObjects = sectionInfo.numberOfObjects;
sectionInfo是个匿名对象。设计此种API时,要把“通过对象能够访问数据分区信息”这一功能与接口中清晰地表达出来。在幕后,此对象可能是由处理结果的控制器所创建的内部状态对象(internal state object)。没必要把表示此种数据的类对外公布,因为使用控制器的人绝对不用关心查询结果中的数据分区是如何保存的,他们只需要知道可以在这些对象上查询数据就行了。我们可以把section数组中返回的内部状态对象视为遵从NSFetchedResultsSectionInfo协议的匿名对象。使用者之要明白这种对象实现了某些特定的方法即可,其余实现细节都隐藏起来了。
要点
- 协议可在某种程度上提供匿名类型。具体的对象类型可以淡化成遵从某协议的id类型,协议里规定了对象所应实现的方法。
- 使用匿名对象来隐藏类型名称(或类名)。
- 如果具体类型不重要,重要的是对象能够响应(定义在协议里的)特定方法,那么可使用匿名对象来表示。