OC语言有一个特殊的属性叫“协议”(protocol)。它与Java的“接口”(interface)类似。OC不支持多重继承,因而我们把某个类应该实现的一系列方法定义在协议里面。协议最为常见的是用途是实现委托模式,不过也有其他用法。
“分类”(Category)也是OC的一项重要语言特性。利用分类机制,我们无须继承子类即可以直接为当前类添加方法。由于OC运行期系统是高度动态的,所以才能支持这一特性。
对象之间经常需要相互通信,而通信的方式有很多种。OC开发者广泛使用一种名叫“委托模式”(Delegate pattern)的编程设计模式来实现对象间的通信,该模式的主旨是:定义一套接口,某对象若想接受另一个对象的委托,则需遵从此接口,以便成为其“委托对象”(delegate)。而这“另一个对象”则可以给其委托对象回传一些信息,也可以在发生相关事件时通知委托对象。
此模式可以将数据与业务逻辑解耦。比如说,用户界面里有显示一系列数据所用的视图,那么,此视图只应包含显示数据所需的逻辑代码,而不应该决定要显示何种数据以及数据之间如何交互的问题。视图对象的属性中,可以包含负责数据与事件处理的对象。这两种对象分别称为“数据源”(data source)与“委托”(delegate)。
在OC中,一般通过“协议”这项语言特性来实现此模式。整个Cocoa系统框架都是这么做的。
举个例子:假设编写一个从网上获取数据的类。此类也许要从远程服务器的某个资源里获取数据,在获取数据的过程中阻塞应用是一种非常糟的做法,于是,在这种情况下,我们通常会使用委托模式:获取网络数据的类含有一个“委托对象”,在获取完数据之后,它会回调这个委托对象:
MyDataModel
对象就是MyNetWorkFetcher
的委托对象。MyDataModel
请求MyNetworkFetcher
“以异步方式执行一项任务”(perform a task asynchronously),而MyNetWorkFetcher
在执行完这项任务之后,就会通知其委托对象,也就是MyDataModel
。
这种协议机制,很容易就能以OC代码实现此模式:
@protocol MyNetworkFetcherDelegate
- (void)networkFetcher:(MyNetworkFetcher*)fetcher didReceiveData:(NSData*)data;
- (void)networkFetcher:(MyNetworkFetcher*)fetcher didFailWithError:(NSError*)error;
@end
委托协议名通常是在相关类名后面加上Delegate
一词,整个类名采用“驼峰法”来写。
有了这个协议后,类就可以用一个属性来存放其委托对象了。在本例中,这个类就是MyNetworkFetcher
类,于是,此类的接口可以写成:
@interface MyNetworkFetcher : NSObject
@property (nonatomic, weak) id <MyNetworkFetcherDelegate> delegate;
@end
注意,属性要定义成weak
,而非strong
,因为两者之间必须为“非拥有关系”(nonowing relationship)。通常情况下,扮演delegate
的那个对象也要持有本对象,假如声明时用了strong
就会定义为“拥有关系”,那么就会引入“保留环”(retain cycle)。因此,如需要在相关对象销毁时自动清空,则定义为weak
,若不需要自动清空,则定义为unsafe_unretained
。
实现委托对象的办法是声明某个类遵从委托协议,然后把协议中想实现的那些方法在类里实现出来。某类若要遵从委托协议,可以在其接口中声明,也可以在“分类”中声明。如果要对外界公布此类实现了某协议,就在接口声明,而如果这个协议是个委托协议的话,那么通常只会在类的内部使用。所以说这种情况一般都是在“分类”中声明的:
@implementation MyDataModel () <MyNetworkDelegate>
@end
@implementation MyDataModel
- (void)networkFetcher:(MyNetworkFetcher*)fetcher didReceiveData:(NSData*)data {
/*handel data*/
}
- (void)networkFetcher:(MyNetworkFetcher*)fetcher didFailWithError:(NSError*)error {
/*handel error*/
}
@end
委托协议中的方法一般都是“可选的”(optional),因为“受委托者”对象可能未必关心其中的所有方法。如:
@protocol MyNetworkFetcherDelegate
@optional
- (void)networkFetcher:(MyNetworkFetcher*)fetcher didReceiveData:(NSData*)data;
- (void)networkFetcher:(MyNetworkFetcher*)fetcher didFailWithError:(NSError*)error;
@end
如果在委托对象上调用可选方法,那么必须提前使用类型信息查询方法判断这个委托对象能否响应相关选择子。例:
NSData *data = /*data obtained from network*/;
if ([_delegate respondToSelector:@selector(networkFetcher:didReceiveData:)]) {
[_delegate networkFetcher:self didReceiveData:data];
}
delegate
对象中的方法名也一定要起的恰当。方法名应该准确描述当前发生的事件以及delegate
对象为何要获知此事件。此外,在调用delegate
对象中的方法时,总是应该把发起委托的实例也一并传入方法中,这样,delegate
对象在实现相关方法时,就能根据传入的实例分别执行不同的代码了。比如:
- (void)networkFetcher:(MyNetworkFetcher*)fetcher didReceiveData:(NSData*)data {
if (fetcher == _myFetcherA) {
/*Handel data*/
} else {
/*Handel data*/
}
}
delegate
里的方法也可以用于从获取委托对象中获取信息。比如说,MyNetworkFetcher类也许想提供一种机制:在获取数据时如果遇到了“重定向”(redirect),那么将询问其委托对象是否应该发生重定向。delegate
对象中的相关方法可以写成了这样:
- (BOOL)networkFetcher:(MyNetworkFetcher*)fetcher shouldFollowRedirectToURL:(NSURL*)url;
通过这个例子,大家应该很容易理解此模式为何叫做“委托模式”:因为对象把应对某个行为的责任委托给另外一个类了。
也可以用协议定义一套接口,令某类经由该接口获取其所需的数据。委托模式的这一用法旨在向类提供数据,故而又称“数据源模式”(Data Source Pattern)。在此模式中,信息从数据源(Data Source)流向类(Class);而在常规的委托模式中,信息则从类流向受委托者(Delegate)。
比方说,用户界面框架中的“列表视图”(list view)对象可能会通过数据源协议来获取要在列表中显示的数据。除了数据源之外,列表视图还有一个受委托者,用于处理用户与列表的交互操作。将数据源协议与委托协议分离,能使接口更清晰,因为这两部分的逻辑代码也分开了。另外,“数据源”与“受委托者”可以是两个不同的对象。然而一般情况下,都用同一个对象来扮演这两种角色。
在实现委托模式与数据源模式时,如果协议中的方法是可选的,那么就会写出一大批类似下面的代码来:
if ([_delegate respondToSelector:@selector(someClassDidSomething)]) {
[_delegate someClassDidSomething];
}
很容易用代码查出来某个委托对象是否能响应特定的选择子,可是频繁执行,除了第一次,后续的检测很多都是多余的。鉴于此,我们通常把委托对象能否响应某个协议方法这一信息缓存起来,以优化程序效率。
假设在例子中加入一个表示数据获取进度的回调方法,将会被多次调用,如果每次检查能否响应就多余了:
@protocol MyNetworkFetcherDelegate
@optional
- (void)networkFetcher:(MyNetworkFetcher*)fetcher didReceiveData:(NSData*)data;
- (void)networkFetcher:(MyNetworkFetcher*)fetcher didFailWithError:(NSError*)error;
- (void)networkFetcher:(MyNetworkFetcher*)fetcher didUpdateProgressTo:(float)progress;
@end
扩充后协议只增加了一个方法networkFetcher:didUpdateProgressTo:
。将方法响应能力缓存起来的最佳途径就是使用“位段”(bitfield)数据类型。可于[C语言学习]位段了解。这是一项乏人问津的C语言特性,但在此处正合适。我们可以把结构体中某个字段所占用的二进制个位数设为特定的值:
struct data {
unsigned int fieldA : 8;
unsigned int fieldB : 4;
unsigned int fieldC : 2;
unsigned int fieldD : 1;
};
结构体中,fieldA位段将占用8个二进制位,fieldB占用4个,fieldC占用2个,fieldD占用1个。于是,fieldA可以表示0到255间的值,fieldD可以表示0或1这两个值。我们可以像fieldD这样,把委托对象是否实现了协议中的相关方法这一信息缓存起来。如果创建的结构体中只有大小为1的位段,那么就能把许多Boolean值塞入一小块数据里面了。如:
@interface MyNetworkFetcher() {
struct {
unsigned int didReceiveData : 1;
unsigned int didFailWithError : 1;
unsigned int didUpdateProgressTo : 1;
} _delegateFlags;
}
@end
使用分类来新增实例变量,而新增的实例变量是个结构体,其中有三个位段。那么就可以像下面这样查询并设置结构体的位段:
//Set flag
_delegateFlags.didReceiveData = 1;
//Check flag
if (_delegateFlags.didReceiveData) {
//Yes, flag set
}
这个结构体用来缓存委托对象是否能响应特定的选择子。实现缓存功能的代码写在delegate属性所对应的设置方法里:
- (void)setDelegate:(id<MyNetworkFetcher>)delegate {
_delegate = delegate;
_delegateFlags.didReceiveData = [delegate respondToSelector:@selector(didReceiveData:)];
_delegateFlags.didFailWithError = [delegate respondToSelector:@selector(didFailWithError:)];
_delegateFlags.didUpProgressTo = [delegate respondToSelector:@selector(didUpProgressTo:)];
}
这样的话,每次调用delegate
相关方法之前就不用检测委托对象能否响应给定的选择子了,而是直接检查标志。在相关方法要调用多次时,值得这种优化。
类中经常容易填满各种方法,而这些方法的代码则全部堆在一个巨大的实现文件中,有时这么做是合理的,因为即使通过重构把这个类打散,效果也不会更好。在此情况下,可以通过“分类”机制,把类代码按逻辑划入几个分区中。比如,把个人信息建模为类:
#import <Foundation/Foundation.h>
@interface Person : NSObject
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
@property (nonatomic, strong, readonly) NSSet *friends;
- (id)initWithFirstName:(NSString*)name andLastName:(NSString*)lastName;
//Friendship
- (void)addFriend:(Person*)person;
- (void)removeFriend:(Person*)person;
- (BOOL)isFriendWith:(Person*)person;
//Work
- (void)performDaysWork;
- (void)takeVacationFromWork;
//Play
- (void)goToTheCinema;
- (void)goToSportsGame;
@end
在实现该类时,所有方法的代码可能会写在一个大文件里。如果还向类中继续添加方法,那么源代码的文件就会越来越大,变得难于管理。所以说应该把这样的类分成几个不同的部分。如:
#import <Foundation/Foundation.h>
@interface Person : NSObject <NSCopying>
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
@property (nonatomic, strong, readonly) NSSet *friends;
- (id)initWithFirstName:(NSString*)name andLastName:(NSString*)lastName;
@end
@interface Person (Friendship)
- (void)addFriend:(Person*)person;
- (void)removeFriend:(Person*)person;
- (BOOL)isFriendWith:(Person*)person;
@end
@interface Person (Work)
- (void)performDaysWork;
- (void)takeVacationFromWork;
@end
@interface Person (Play)
- (void)goToTheCinema;
- (void)goToSportsGame;
@end
现在,类的实现代码按照方法分成了好几个部分。所以说这项语言特性叫做“分类”。本例中,类的基本要素(属性与初始化方法等)都声明在“祝实现”(main implement)里。执行不同类型的操作所用的另外几套方法则归入各个分类中。
使用分类机制之后,依然可以把整个类都定义在一个接口文件中,并将其代码写在一个实现文件里。可是,随着分类数量的增加,当前这份实现文件很快就膨胀得无法管理了。此时可以把每个分类提取到各自的文件中去。以Person为例,可以按照其分类拆开分成下列几个文件:
比如说,与交友功能相关的那个分类可以这样写:
//Person+Friendship.h
#import "Person.h"
@interface Person (Friendship)
- (void)addFriend:(Person*)person;
- (void)removeFriend:(Person*)person;
- (BOOL)isFriendWith:(Person*)person;
@end
//Person+Friendship.m
#import "Person+Friendship.h"
@implementation Person (Friendship)
- (void)addFriend:(Person*)person {
//...
}
- (void)removeFriend:(Person*)person {
//...
}
- (BOOL)isFriendWith:(Person*)person {
//...
}
@end
通过分类机制,可以把类代码分成很多个易于管理的小块,以便单独检视。使用分类机制之后,如果想用分类中的方法,要记得引入分类的头文件。
即使类本身不是太大,我们也可以使用分类机制将其切割成几块,把相应的代码归入不同的“功能区”(functional area)中。
之所以要将类代码打散到分类中还有个原因,就是便于调试:对于某个分类中的所有方法来说,分类名称都会出现在其符号中。例如,“addFriend:”方法的“符号名”(symbol name)如下:
-[EOCPerson(Friendship) addFriend:]
根据调试器回溯信息中的分类名称,很容易就能定位到类中方法所属的功能区,这对于应该视为私有的方法来说更是极为有用。可以创建名为Private
的分类,把这些方法全部放在里面。这个分类里的方法一般只在类或者框架内部使用,而无须对外公布。这可以算是一种编写“自我描述式代码”(self-documenting code)的方法。
分类机制通常用于向无源码的既有类中新增功能。这个特性极为强大,但在使用时容易忽视其中可能产生的问题。问题在于,分类中的方法是直接添加在类里面的,他们就好比这个类中的固有方法。将分类方法加入类中这一操作是在运行期系统加载分类时完成的。运行期系统会把分类中所实现的每个方法都加入类的方法列表中。如果类中本来就有此方法,而分类又实现了一次,那么分类中的方法会覆盖原来那一份实现代码。实际上可能发生多次覆盖,多次覆盖后的结果以最后一个分类为准。
要解决此问题,一般的做法是:以命名空间来区别各个分类的名称与其中所定义的方法。想实现命名空间功能,只有一个办法,就是给相关名称都加上某个共用的前缀。与给类名加前缀考虑的因素相似。如:
@interface NSString(ABC_HTTP)
- (NSString*)abc_urlEncodedString;
- (NSString*)abc_urlDecodedString;
@end
从技术角度讲,并不是非得用命名空间把各个分类的名称区隔开不可。即使两个分类重名了,也不会出错。然而这样做不好,编译器会发出警告信息。
此外要记住,如果某个类的分类中加入了方法,那么在应用程序中,该类的每个实例均可调用这些方法。此外,刻意地覆写分类中的方法也不好。
属性是封装数据的方式。尽管从技术上来说,分类里也可以声明属性,但这种做法还是要尽量避免。原因在于,除了“class-continuation”分类之外,其他分类都无法向类中新增实例变量。因此,它们无法把实现属性所需的实例变量给合成出来。
对于前面的问题,若是把代表朋友列表的那项属性也放到Friendship分类里去了。
@interface Person (Friendship)
@property (nonatomic, strong) NSArray *friends;
- (void)addFriend:(Person*)person;
- (void)removeFriend:(Person*)person;
- (BOOL)isFriendWith:(Person*)person;
@end
编译时,会给出警告信息:
Property 'friends' requires method 'friends' to be defined - use @dynamic or provide a method implementation in this category
Property 'friends' requires method 'setFriends:' to be defined - use @dynamic or provide a method implementation in this category
意思是说此分类无法合成与friends属性相关的实例变量,所以开发者在分类中实现存取方法。此时可以把存取方法声明为@dynamic
,也就是说,这些方法等到运行期再提供,编译器目前是看不见的。如果决定使用消息转发机制在运行期拦截方法调用,并提供其实现,或许可以采用这种做法。
关联对象能够解决在分类中不能合成实例变量的问题。这样可行,但不是太理想。要把相似代码写很多遍,而且在内存管理问题上容易出错,因为我们在为属性实现存取方法时,经常会忘记遵从其内存管理语义。由于各种问题,把属性定义在“主接口”(main interface)中要比定义在分类中清晰得多。类所封装的全部数据都应该定义在主接口中,这里是唯一能够定义实例变量(数据)的地方。而属性只是实例变量及相关存取方法所用的“语法糖”,所以也应该遵循同实例变量一样的规则。至于分类,应该将其理解为一种手段,目标在于扩展类的功能,而非封装数据。
此外,有时候,只读的属性还是可以在分类中使用的。
类中经常会包含一些无须对外公布的方法及实例变量。其实这些内容也可以公布,并且写明为私有,开发者不应该依赖于它们。OC动态消息系统的工作方式决定了其不可能实现真正的私有方法或私有实例变量。然而,我们最好还是只把确实需要对外公布的那部分内容公开。那么,这种不需对外公布但却应该具有的方法及实例变量应该怎么写呢?这个时候,这个特殊的“class-continuation分类”就派上用场了。
“class-continuation分类”和普通的分类不同,它必须定义在其所接续的那个类的实现文件里。重要之处在于,这是唯一能够声明实例变量的分类,而且此分类没有特定的实现文件,其中的方法都应该定义在实现文件里。与其他分类不同,“class-continuation分类”没有名字。
@interface Person ()
//...
@end
为什么需要这种分类呢?因为其中可以定义方法和实例变量。为什么能在其中定义方法和实例变量呢?只因为有“稳固的ABI”这一机制,使得我们无须知道对象大小即可使用它。由于类的使用者不一定需要知道实例变量的内存布局,所以,它们也就未必要定义在公共接口中了。基于上述原因,我们可以像在类的实现文件里那样,于“class-continuation分类”中给类新增实例变量:
@interface Person () {
NSString *_anInstanceVariable;
}
//方法定义
@end
@implementation Person {
int _anotherInstanceVariable;
}
//方法实现
把实例变量定义在“class-continuation分类”中或“实现快”中可以将其隐藏起来,只供本类使用。即使在公共接口里将其标注为private
,也还是会泄露实现细节。
由于“class-continuation分类”中还能够定义一些属性,所以在这里额外声明一些实例变量也很合适。这些实例变量并非真的私有,因为在运行期总可以调某些方法绕过此限制。不过,从一般意义来说,它们还是私有的。
在编写Objective-C++代码时“class-continuation分类”也尤为有用。可以把头文件里的C++代码隐藏到实现文件,从而展现一套简洁的OC接口,使得可以不需要按照Objective-C++来编译。
“class-continuation分类”还有一种合理用法,就是将public
接口声明为“只读”的属性扩展为“可读写”,以便在类的内部设置其值。我们通常不直接访问实例变量,而是通过设置访问方法,因为可以触发KVO通知。出现在“class-continuation分类”和其他分类的属性必须同类接口里面的属性具有相同的特质,不过其“只读”状态可以扩充为“可读写”。如:
@interface Person : NSObject
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
@end
一般会在“class-continuation分类”中把它们扩充为可读写:
@interface Person ()
@property (nonatomic, copy, readwrite) NSString *firstName;
@property (nonatomic, copy, readwrite) NSString *lastName;
@end
现在,其实现代码可以随意调用“setFirstName:
”或“setLastName:
”这两个设置方法,也可以使用点语法设置属性。这样,封装在类中的数据就由实例本身来控制,而外部代码无法修改其值。注意,若观察者正在读取属性值而内部代码又在写入该属性时,则有可能引发“竞争条件”(race condition)。合理使用同步机制可以缓解此问题。
只有在类的实现代码中才会用到的方法也可以声明在“class-continuation分类”中。那么做比较合适。新版编译器不强制要求必须先声明。然而在“class-continuation分类”里面先声明一下还是有好处的,因为这样可以把相关方法都统一描述于此。
最后,若对象遵从的协议只应视为私有,则可以在“class-continuation”中声明。如:
#import <Foundation/Foundation.h>
#import "ABCSecreatDelegate.h"
@interface Person : NSObject <ABCSecreatDelegate>
@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;
@end
协议定义了一系列方法,遵从此协议的对象应该实现它们(不是可选的则必须实现)。于是,我们可以用协议把自己所写的API之中的实现细节隐藏起来,将返回的对象设计为遵从此协议的纯id
类型。这样的话,想要隐藏的类名就不会出现在API之中了。若是接口背后有多个不同的实现类,而又不想指明具体使用哪个类,那么可以考虑这个办法,因为有时候这些类可能会变,有时候它们又无法容纳于标准的类继承体系中,因而不能以某个公共基类来统一表示。
此概念经常称为“匿名对象”(anonymous object),这与其他语言的“匿名对象”不同,在其他语言中,该词是指以内联形式所创建出来的无名类,而此词在OC中则不是这个意思。前面讲过的委托与数据源对象,其中就曾用到这种匿名对象。如:
@property (nonatomic, weak) id <ABCDelegate> delegate;
由于该属性的类型是id
,所以实际上任何类的对象都能充当这一属性,即使该类不继承自NSObject
也可以,只要遵循ABCDelegate
协议即可。对于具备此属性的类来说,delegate
就是“匿名的”(anonymous)。如有需要,可以在运行期查出此对象所属的类型。然而这样不太好。
NSDictionary
也能实际说明这一点:在字典中,键的标准内存管理语义是“设置时拷贝”,而值的语义是“设置时保留”。因此,在可变字典中,设置键值对应的方法的签名是:
- (void)setObject:(id)object forKey:(id<NSCopying>)key
表示键的那个参数类型是id
,作为参数值的对象,它可以是任何类型,只要遵从NSCopying协议就好,这样的话,就能向该对象发送拷贝信息了。这个key参数可以视为匿名对象。与delegate属性一样,字典也不关心key对象所属的具体类。而且它也决不应该依赖于此。字典对象只要能确定它可以给此实例发送拷贝信息就行了。
处理数据库连接(detabase connection)的程序库也用这个思路,以匿名对象来表示从另一个库中所返回的对象。对于处理连接所用的那个类,你也许不想叫外人知道其名字,因为不同的数据库可能要用不同的类来处理。如果没办法令其都继承自同一基类,那么就得返回id
类型的东西了。不过我们可以把所有数据库连接都具备的那些方法放到协议中,令返回的对象遵从此协议。协议可以这样写:
@protocol EOCDatabaseConnection
- (void)connect;
- (void)disconnect;
- (BOOL)isConnected;
- (NSArray*)performQuery:(NSString*)query;
@end
然后就可以用“数据库处理器”(database handler)单例来提供数据库连接了。这个单例的接口可以写成:
#import <Foundation/Foundation.h>
@protocol ABCDatabaseConnection;
@interface ABCDatabaseManager : NSObject
+ (id)sharedInstance;
- (id<ABCDatabaseConnection>)connectionWithIdentifier:(NSString*)identifier;
@end
这样的话,处理数据库连接所用的类的名称就不会泄露了,有可能来自不同框架的那些类现在均可以经由一个方法来返回了。使用此API的人仅仅要求所返回的对象能用来连接、断开并查询数据库即可。
有时对象类型并不重要,重要的是对象有没有实现某些方法,在这个情况下,也可以用这些“匿名类型”(anonymous type)来表达这一概念。即便实现代码总是使用固定的类,你可能还是会把它写成遵从某协议的匿名类型,以表示类型在此处并不重要。