目录
- 第四章:协议与分类
- 第23条:通过委托与数据源协议进行对象间通信
- 第24条:将类的实现代码分散到便于管理的数个分类之中
- 第25条:总是为第三方类的分类名称加前缀
- 第26条:勿在分类中声明属性
- 第27条:使用“class-continuation分类”隐藏实现细节(扩展,Extension)
- 第28条:通过协议提供匿名对象
第四章:协议与分类
协议(protocol)和分类(category)是OC的两个重要的语言特性。理解并运用得当,可令代码易读、易维护且少出错。
第23条:通过委托与数据源协议进行对象间通信
可以通过委托(代理 delegate)与数据源(data source)协议进行对象间通信。
协议中可以定义方法和属性。
在协议中@optional
和@require
关键字来指定协议方法是可选择实现的还是必须实现的,如果适用方没有实现@require方法那么编译器就会给出警告。
委托模式
何为代理?一种软件设计模式,iOS中@protocol
形式体现,传递方式为一对一。
代理模式的主旨:定义一个委托协议,若对象想接受另一个对象(委托方)的委托,则需遵守该协议,以成为 “代理方”。而委托方则可以通过协议方法给代理方回传一些信息,也可以在发生相关事件时通知代理方。这样委托方就可以把应对某个行为的责任委托给代理方去处理了。
代理的工作流程:
委托方要求代理方把需要实现的方法全都定义在委托协议中;
代理方遵循协议并实现协议中的方法(若有返回值会返回给委托方);
委托方调用代理方遵从的协议方法。
delegate
属性一般定义为weak以避免循环引用,代理方强引用委托方,委托方弱引用代理方。
如果要向外界公布一个类遵守某协议,那么就在接口中声明;如果这个协议是委托协议,那么就在类扩展中声明,因为该协议通常只会在类的内部使用。
对于@optional
方法,在委托方法中调用时,需要先判断代理方是否能响应(是否实现了该方法),如果能响应才能给它发送协议消息,否则代理方可能没有实现该方法,调用时就会因找不到方法实现而导致Crash:
//最好先判断一下delegate是否存在提高执行效率
if (_delegate && [_delegate respondsToSelector:@selector(protocolOptionalMethod)]) {[_delegate protocolOptionalMethod];
}
数据源模式
委托模式的另一用法,旨在向类提供数据,所以也称“数据源模式”,其用协议定义一套接口,令某类经由该接口获取所需的数据。
在此模式中,信息从数据源流向委托方;
在常规模式中,信息从类(委托方)流向代理方(受委托方)。
通过UITableView就可以很好的理解Data Source和Delegate这两种模式:
-
通过UITableViewDataSource协议获取要在列表中显示的数据;
-
通过UITableViewDelegate协议来处理用户与列表的交互操作。
@protocol UITableViewDataSource <NSObject> @required - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section; - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath; @optional - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView; ... @end@protocol UITableViewDelegate <NSObject, UIScrollViewDelegate> @optional -(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath; ... @end
性能优化:
在实现委托模式和数据源模式时,如果协议方法时可选的,那么在调用协议方法时就需要判断其是否能响应。
if (_delegate && [_delegate respondsToSelector:@selector(networkFetcher:didReceiveData)]) {[_delegate networkFetcher:self didReceiveData:data];
}
如果我们需要频繁调用该协议方法,那么仅需要第一次判断是否能响应即可。以上代码可做性能优化,将代理方是否能响应某个协议方法这一信息存起来:
-
在委托方中嵌入一个含有位域(bitfield,又称“位段”、“位字段”)的结构体作为其实例变量,而结构体中每个位域则表示delegate对象是否实现了协议中的相关方法。该结构体就是用来缓存代理方是否能响应特定的协议方法的:
@interface EOCNetworkFetcher () {struct {unsigned int didReceiveData : 1;unsigned int didFailWithError : 1;unsigned int didUpdateProgressTo : 1;} _delegateFlags; }
有关位段的知识,看这篇文章:【C语言】位域(位段、位字段)。
-
重写delegate属性的
setter
方法,对_delegateFlags结构体里的标志进行赋值,实现缓存功能。- (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相关方法之前,就不用通过
respondToSelector:
方法来检测代理方法是否能响应特定协议方法了,而是直接查询结构体中的标志,提升了执行速度。if (_delegate && _delegateFlags.didReceiveData) {[_delegate networkFetcher:self didReceiveData:data]; }
若有必要,可实现含有位段的结构体,将委托对象是否能响应相关协议方法这一信息缓存至其中。
第24条:将类的实现代码分散到便于管理的数个分类之中
该篇讲解了分类的一种使用场合:分解体积庞大的类文件,可以将一个类按功能拆解成多个模块,方便代码管理。
之所以要将类代码打散到分类中还有个原因,就是便于调试:对于某个分类中的所有方法来说,分类名称都会出现在其符号中。例如,addFriend:
方法的符号名:
-[EOCPerson(Firendship) addFriend:]
在调试器的回溯信息中,可以根据符号名精确定位到该方法所属的功能区(分类),这对于某些应该视为私有的方法来说更是极为有用。可以创建名为Private的分类,把这种方法全都放在里面。
这个分类里的方法一般只会在类或框架内部使用,而无须对外公布。这样一来,类的使用者有时可能会在查看回溯信息时发现private一词,从而知道不应该直接调用此方法了。这可算作一种编写“自我描述式代码”(self-documenting code)的办法。
第25条:总是为第三方类的分类名称加前缀
- 向第三方类中添加分类时,总应给其名称加上你专用的前缀。
- 向第三方类中添加分类时,总应给其中的方法名加上你专用的前缀。
——
- 分类机制还通常用于向无源代码的既有类中新增功能。
- 分类是运行时决议。何为运行时决议?Category 编译之后的底层结构是 struct category_t,里面存储着分类的对象方法、类方法、属性、协议信息,这时候分类中的数据还没有合并到类中,而是在程序运行的时候通过 Runtime 机制将所有分类数据合并到类(类对象、元类对象)中去。这是分类最大的特点,也是分类和扩展的最大区别,扩展是在编译的时候就将所有数据都合并到类中去了。
——
-
需要注意的是:
- 分类方法会 “覆盖” 同名的宿主类方法,如果使用不当会造成问题。方法被覆盖会导致执行结果和你预期的不同,且这种 bug 很难排查;
- 同名分类方法谁能生效取决于编译顺序,最后参与编译的分类中的同名方法会最终生效;
- 名字相同的分类会引起编译报错。
-
为避免以上问题,可以以命名空间来区别各个分类的名称与分类中所定义的方法。而在 OC 中实现命名空间功能只有一个办法,就是给相关名称都加上某个共用的前缀。这样分类和宿主类中出现同名方法导致方法被 “覆盖” 的问题的几率就会小很多。
向第三方类中添加分类时,更应该注意这个问题。
第26条:勿在分类中声明属性
分类中可以添加属性,但应避免这样做。
类扩展是编译时决议,在编译的时候就将扩展中的所有数据都合并到类中去了,所以扩展中添加属性没有任何问题。
而分类是运行时决议,类的内存布局在编译时就已经确定,所以分类中无法添加实例变量,分类中添加的属性也不会自动生成实例变量以及setter和getter方法的实现(因为属性就是对实例变量的封装)。
如果把属性放在分类中:
@interface NSCalendar (EOC_Addtions)
@property (nonatomic, strong)NSArray* eoc_allMonths;
@end
编译器会报黄标:
警告为:属性的setter和getter方法没有实现。因为分类中的属性不会自动生成实例变量以及setter和getter方法的实现,这样外部调用该属性的存取方法就会Crash。有两种解决方式:
- 手动添加setter和getter方法的实现;
- 使用
@dynamic
告诉编译器,你会在运行时再提供这些方法的实现,以消除警告。你可以使用动态方法解析为这些方法动态添加方法实现。但如果你没有去处理的话,@dynamic
就仅仅是消除了警告,如果外部调用了该属性的存取方法还是会Crash。
由于分类底层结构的限制,不能直接给Category添加成员变量,但是可以通过关联对象间接实现Category有成员变量的效果。【🚩详见第10条】
需要注意的是,存储关联对象时其内存管理语义需要与属性的一致。如果属性的内存管理语义更改,那么关联对象的关联策略也要修改,这是容易忽略的地方。
只读属性可以在分类中使用,我们为其实现getter方法。由于实现属性所需的全部方法(只读属性只需实现 getter 方法)都已实现,所以编译器就不会再为该属性自动合成实例变量,也不会发出警告。
类接口与类扩展是真正能够定义实例变量的地方,而属性只是定义实例变量及相关存取方法所用的 “语法糖”,所以也应该遵循同实例变量一样的规则。尽管分类中可以通过关联对象的手段来实现分类中可以添加实例变量的效果,但其目标在于扩展类的功能,而非封装数据。属性是用来封装数据的,所以在分类中可以定义存取方法,但尽量不要定义属性。
第27条:使用“class-continuation分类”隐藏实现细节(扩展,Extension)
OC动态消息系统的工作方式决定了其不可能实现真正的私有方法或私有示例变量,但我们最好还是只把确实需要对外公布的那部分内容公开,而把无须对外公布的方法及属性、实例变量声明在类扩展中。
隐藏变量
公共接口里本来就能定义实例变量,不过,把它们定义在 “扩展”或“实现块”(从语法上看,加在这两个地方是等效的,加在哪看个人喜好)中可以将其隐藏起来:
@interface EOCPerson() {EOCSuperSecretClass* _secretInstance;
}
@end@implementation EOCPerson {int _anotherInstanceVariable;
}
@end
即便在公共接口里将其标注为private
,也还是会泄漏实现细节,假如说有个绝密类:
@class EOCSuperSecretClass;@interface EOCClass : NSObject {@privateEOCSuperSecretClass* _secretInstance;
}
@end
这样别人就会知道有个名叫EOCSuperSecretClass的类,可以将其类型改为id
,但这样的话,在类内部使用此实例时,无法得到编译器的帮助,没必要只因为想对外界隐藏某个内容就放弃编译器的辅助检查功能。
这些在扩展中添加的实例变量并非真的私有,因为在运行期总可以调用某些方法绕过此限制,不够由于没有声明在公共头文件里,其隐藏程度更好。
扩展属性可读写性
如果一个属性在类的内部需要进行存取值,而对外只允许使用方进行取值。那么可以在类声明中将该属性的读写权限设置为readonly
只读,而在类扩展中再次声明该属性并设置为readwrite
可读写。
这样,封装在类中的数据就由实例本身来控制,而外部代码则无法修改其值,【🚩第18条】曾详述了这一话题。
请注意,若观察者正读取属性值而内部代码又在写入该属性时,则有可能引发竞争条件,合理使用同步条件【参见🚩第41条】能缓解此问题。
隐藏协议
若类所遵循的协议只应视为私有,比如委托协议和数据源协议,不会让该协议在公共接口中泄漏,那么该协议就可以在类扩展中去遵从。
#import "EOCSecretDelegate.h"
@interface EOCPerson() <EOCSecretDelegate>
// ...
@end
你可能会说,只要在接口文件中向前声明该协议就可以不引入定义该协议的头文件了,但这样做编译器会提示找不到协议的定义:
还是得找扩展来帮忙。
私有方法
类扩展中除了可以声明属性、实例变量,遵从协议,还可以声明私有方法。虽然现在编译器不强制要求我们在使用方法之前必须先声明,直接在扩展中实现即可。但其实在类扩展中声明一下私有方法还是有好处的,这样可以把类里所含的相关方法都统一描述于此,使代码可读性更高。
第28条:通过协议提供匿名对象
- 协议可在某种程度上提供匿名类型。具体的对象类型可以淡化成遵从某协议的 id 类型,协议里规定了对象所应实现的方法。
- 使用匿名对象来隐藏类型名称(或类名)。
- 如果具体类型不重要,重要的是对象能够响应(定义在协议里的)特定方法,那么可使用匿名对象来表示。
——
-
可以通过协议提供匿名对象来隐藏类名,做法就是将对象声明为遵从某协议的id类型:
id<protocol> object;
-
比如delegate属性,其声明为:
@property(nonatomic, weak)id<EOCDelegate> delegate;
委托方无须关心代理方的具体类型,只需代理方遵守委托协议并实现协议方法即可,这样委托方就可以向代理方发送协议消息了。
-
在字典中,键和值的标准内存管理语义分别是设置时拷贝和设置时保留。因此
NSMutableDictionary
设置键值的方法为:
KeyType
可以是不为nil
的任何OC不可变类型,因此只要传入的对象遵守NSCopying
协议字典就能向该对象发送拷贝消息了,而这个key参数就可以视为匿名对象。
-
-
可以在运行期查出匿名对象所属类型,但这样做不好,因为匿名对象已经表明它的具体类型无关紧要了,你仅需要通过它来调用协议方法。
-
使用匿名对象的情况:
- 接口背后有多个不同的实现类,而你又不想指明具体使用哪个类。因为有时候这些类可能会变,有时候它们又无法容纳于标准的类继承体系中,因而不能以某个公共基类统一表示(比如UIButton)。
这样就可以将这些对象所具备的方法定义在协议中,用 id 类型指代并遵守该协议,即可调用协议中的方法,而在运行期则会根据对象具体类型,调用具体的方法实现。 - 对象具体类型不重要,重要的是对象能够响应(定义在协议里的)特定方法。即便该对象类型是固定的你也可以这么做,以表示类型在此处不重要。
- 接口背后有多个不同的实现类,而你又不想指明具体使用哪个类。因为有时候这些类可能会变,有时候它们又无法容纳于标准的类继承体系中,因而不能以某个公共基类统一表示(比如UIButton)。