对象应当有明确定义的任务, 比如作为特定信息的模型/展示视觉内容/控制信息流等等.
如之前的文章中提到的那样, 类接口定义了对象如何与外界的交互, 并帮助外界完成那些任务.
有些时候, 我们可能想要为一些已有的类在特定情况下添加/扩展一些行为. 比如, 我们可能常常需要在用户界面上显示一个字符串. 相比每次都去创建一个绘制字符串的对象而言, 如果可能为NSString类提供一个把自己绘制到屏幕上的功能就更好了.
在这种情况下, 在原有类的接口上添加通用功能不是一个最佳的选择. 绘制能力对于字符串对象来说大多数情况下不会用到, 而且对于NSString类来说, 由于它是一个framework中的类, 我们不能修改它原始的接口和实现.
继承已有的类也不是一个好的解决方案 -- 我们可能希望不仅仅在NSString类上, 而是在所有的NSString类的子类上都可以拥有这个功能(比如NSMutableString). 并且, 虽然NSString在OS X和iOS 上都是可用的, 但是在不同的平台上, 绘制的代码可能会不同, 那样就需要在每一个平台都使用一个不同的子类.
因此, Objective-C 提供了分类(Category)和扩展(Extension) 的方式, 允许我们在已有类上添加我们定制的方法.
分类(Category)
如果我们需要向已有的类添加方法, 使其在我们的应用内可以更加便捷地添加一些功能, 最简单的方法就是使用分类(Category).
声明一个分类的语法和声明类接口的语法很像, 都使用了@interface 关键字, 但是和类接口不同的是, 声明分类时不会指定继承关系, 而是通过括号()将分类名称包起来.
@interface ClassName (CategoryName)
@end
我们可以为任何类声明分类, 即使我们没有这个类的实现源码(比如Cocoa 或者 Cocoa Touch 类). 任何我们在分类中声明的方法, 在对应的类及其子类中都是可以使用的. 在运行时, 在原有类中实现的方法和在分类中实现的方法是没有什么区别的.
拿之前文章中的XYZPerson类举例, 我们可能频繁地需要展示人名列表:
Appleseed, John
Doe, Jane
Smith, Bob
Warwick, Kate
我们可以通过分类来实现这个需求:
#import "XYZPerson.h"
@interface XYZPerson (XYZPersonNameDisplayAdditions)
- (NSString *)lastNameFirstNameString;
@end
在这个例子中, XYZPersonNameDisplayAdditions 分类声明一个返回字符串的新方法 lastNameFirstNameString
通常分类的声明会放在单独的头文件中, 实现的代码也会放置在单独的源码文件中. 在XYZPerson的例子中, 我们或许会把上面的分类声明放置在名为XYZPerson+XYZPersonNameDisplayAddtions.h的头文件中.
虽然从分类中添加的方法对于它所有的实例以及子类实例都可见, 但是我们在使用这些方法时, 仍然是需要引入对应头文件的, 否则就会在编译时出现警告和错误.
分类的实现如下:
#import "XYZPerson+XYZPersonNameDisplayAdditions.h"
@implementation XYZPerson (XYZPersonNameDisplayAdditions)
- (NSString *)lastNameFirstNameString {
return [NSString stringWithFormat:@"%@, %@", self.lastName, self.firstName];
}
@end
一旦我们完成了分类的声明和实现, 我们就可以在其它类中使用这些方法了:
#import "XYZPerson+XYZPersonNameDisplayAdditions.h"
@implementation SomeObject
- (void)someMethod {
XYZPerson *person = [[XYZPerson alloc] initWithFirstName:@"John"
lastName:@"Doe"];
XYZShoutingPerson *shoutingPerson =
[[XYZShoutingPerson alloc] initWithFirstName:@"Monica"
lastName:@"Robinson"];
NSLog(@"The two people are %@ and %@",
[person lastNameFirstNameString], [shoutingPerson lastNameFirstNameString]);
}
@end
分类不仅仅可以为我们提供为已有类添加方法的功能, 我们也可以使用分类将一个复杂的类的实现分开成多个文件管理. 比如当我们要自定义一个UI元素时, 如果几何计算, 颜色, 渐变等特别复杂时, 我们就可以把绘制相关的代码抽离出来, 由单独的分类管理. 我们也可以根据不同的平台(OS X/iOS)创建不同的分类以提供不同的实现方法.
分类可以用来声明实例方法或者类方法, 但是大多数情况下不适宜声明额外的属性. 虽然从语法角度看在分类的声明中声明属性是可行的, 但是却不能够在分类中声明额外的实例变量. 这就意味着编译器不会为我们自动合成任何实例变量, 也不会合成任何属性的访问方法(setter/getter). 我们虽然可以在分类实现中自己实现getter/setter方法, 但是我们没有办法存储属性对应的实例变量, 除非是原有类中已有的实例变量.
唯一的向已有类添加属性且可以生成对应实例变量的办法就是使用后文即将讲解的类扩展(Extension).
Note: Cocoa 和 Cocoa Touch 中包含了大量的为框架中的已有的类添加的分类.
在本文介绍中提及的为NSString类提供的绘制功能在OS X中是通过NSStringDrawing分类实现的, 在其中包含了drawAtPoint:withAttributes: 和drawInRect:withAttributes: 方法. 在iOS中是通过UIStringDrawing分类实现的, 包含drawAtPoint:withFont: 和drawInRect:withFont: 等方法.
避免分类方法名冲突
由于在分类中声明的方法是向已有的类中添加的, 所以对于方法名称我们需要格外小心.
如果在分类中声明的方法名称和原有类中的方法名称一致,或者和其它分类中的方法一致(甚至是父类中的方法名称), 在运行时调用此方法的行为是不确定的. 让我们为自己的类编写分类时, 出现这种问题的可能性不大, 但是如果是在为Cocoa 或者 Cocoa Touch 添加分类, 就可能引发问题.
假设我们在编写一个需要和远程web服务交互的应用, 我们就可能需要经常对字符串进行Base64编码. 因此我们可能会为NSString定义一个分类, 添加一个返回 Base64 编码后的字符串方法, 因此我们可能添加一个叫做base64EncodedString的分类方法.
如果我们又链接了另外一个framework, 而这个framework 恰巧也定义了一个叫做base64EncodedString的方法, 那么问题就出现了: 在运行时调用base64EncodedString方法时, 这两个方法只有一个会被调用, 而至于哪一个最终会被调用, 就是不确定的了.
还有另外一种情况可能产生问题: 如果我们为Cocoa/Cocoa Touch类添加了分类方法, 而后Cocoa/Cocoa Touch 类在后续更新中在原类中添加了此方法. 比如NSSortDescriptor类, 它的功能是描述集合对象应当如何排序, 一直以来就拥有initWithKey:ascending:的初始化方法. 但是在早期OS X 和 iOS 版本并没有提供对应的类工厂方法.
按照习惯, 这个类工厂方法应当叫做sortDescriptorWithKey:ascending:, 因此我们可能已经添加了NSSortDescriptor的分类, 提供了这个工厂方法. 这样, 在早期的OS X或者iOS版本上, 代码是可以按照我们的预期运行的, 但是在Mac OS X 10.6 和 iOS 4.0 之后NSSortDescriptor类就添加了sortDescriptorWithKey:ascending:方法, 此时我们分类中的方法和系统类的方法就产生了方法名称的冲突.
为了避免这种问题, 在为framework中的类添加分类时最好在方法名称前添加前缀, 正如我们命名我们自定义的类时一样. 我们可以使用和类前缀相同的三个字母, 将其转为小写后与方法本身的名称以下划线_连接, 以遵循方法名称命名规则. 对于前面的NSSortDescriptor为例, 我们为其定义的分类可能是如下形式的:
@interface NSSortDescriptor (XYZAdditions)
+ (id)xyz_sortDescriptorWithKey:(NSString *)key ascending:(BOOL)ascending;
@end
这就意味着我们可以确信我们的代码会按照预期在运行时被调用. 由于我们的代码形式类似如下, 原有的歧义也因此被消除:
NSSortDescriptor *descriptor =
[NSSortDescriptor xyz_sortDescriptorWithKey:@"name" ascending:YES];
类扩展(Extension)
类扩展和分类有些相似, 但是我们只能在编译时为我们已有源码的类添加类扩展(类和类扩展在编译时同时被编译). 在类扩展中声明的方法是在原有类的@implementation块中被实现的. 因此我们无法为一个framework中的类(比如Cocoa/Cocoa Touch 中的类, 像是NSString)添加类扩展.
声明类扩展的语法和分类很相似:
@interface ClassName ()
@end
由于在括号中没有名称, 类扩展又常常被称为匿名分类(anonymous category)
和普通分类不同的是, 类扩展可以为类添加属性和实例变量. 如果我们在一个类扩展中像这样添加一个属性:
@interface XYZPerson ()
@property NSObject *extraProperty;
@end
编译器会自动在类的实现中合成相关的访问方法(setter/getter)以及实例变量.
如果我们在类扩展中添加了方法, 那么这些方法必须在类的实现中实现.
我们也可以在类扩展的{}中添加自定义的实例变量 :
@interface XYZPerson () {
id _someCustomInstanceVariable;
}
...
@end
使用类扩展隐藏私有信息
类的主要接口用来定义如何和外界交互, 也就是说它是类的公共接口(public interface).
类扩展常常使用一些额外的私有方法和属性以更好地为扩展公共接口. 比如在类接口中定义一个属性为readonly, 但是在类扩展中将其定义为readwrite, 这样既能在类内部直接修改属性值,对外又可以控制属性的写入权限.
比如XYZPerson类可能添加了一个叫做uniqueIdentifier的属性, 用来存储身份证号码.
uniqueIdentifier对外只读, 并且类接口提供了一个分配uniqueIdentifier的方法.
@interface XYZPerson : NSObject
...
@property (readonly) NSString *uniqueIdentifier;
- (void)assignUniqueIdentifier;
@end
这就意味着其它无法直接设置uniqueIdentifier. 如果需要重新分配uniqueIdentifier,需要调用assignUniqueIdentifier方法.
为了让XYZPerson内部拥有修改属性的值的能力, 我们可以在实现文件上方添加类扩展重新定义uniqueIdentifier属性:
@interface XYZPerson ()
@property (readwrite) NSString *uniqueIdentifier;
@end
@implementation XYZPerson
...
@end
Note: readwrite attribute 是可选的, 因为它是默认的attribute, 当重新定义一个属性的时候可以显式声明以达到强调澄清的作用.
这样的话, 编译器也会自动合成一个setter方法, 因而XYZPerson类的实现就可以直接使用setter方法或者点语法来设置属性值了.
在XYZPerson类的实现文件中添加类扩展, 类扩展中的信息对于XYZPerson类是私有的. 如果其它对象尝试设置属性, 编译器将产生错误.
Note: 上述例子通过添加类实例, 重新声明了uniqueIdentifier并将其设置为readwrite . 无论其它源文件是否知道它是类扩展中重新声明的, 它的setter方法: setUniqueIdentifier: 方法在运行时对于每个对象都是存在的.
当尝试在其它源文件中调用私有方法或者尝试为readonly修饰的属性赋值时, 编译器会产生错误, 但是可以通过动态运行时调用这些方法来规避编译器报错, 比如使用NSObject类提供的performSeletor:...方法. 当必要的时候, 我们可以通过这种办法绕开类的限制, 但是公共类接口应该总是正确地定义对外的行为.
如果我们想要让"私有"方法或者属性对个别类可见时, 比如同framework中的相关类. 我们可以在一个新的头文件中声明类扩展, 并在需要使用它的类中导入头文件. 同一个类有两个头文件的情况也时有发生, 比如可以定义XYZPerson.h和XYZPersonPrivate.h. 但是当我们发布framework时, 我们只应当对外开放XYZPerson.h头文件.
使用其它方式为已有类添加功能
分类和扩展使得为已有的类添加功能十分方便, 但是有时候他们不是最好的解决方案.
面向对象语言的主要目标之一就是写出可复用的代码, 也就是使类在尽可能多的情况下可以复用.如果我们正在创建一个视图类以描述一个对象并将其信息展示在屏幕上, 我们应当多多考虑这个类是否可以在多种情况下复用.
为了避免硬编码布局和UI内容的代码, 其中一个方法就是使用继承, 让子类重写一些方法, 把那些决定留到子类去做. 虽然这样可以使得类可以轻松地被复用, 但是在我们每次想要使用这个类时都需要创建一个新的子类.
另外一个方式就是为类创建一个代理对象. 所有可能限制复用性的决定都由代理对象处理, 这样决议就推迟到了运行时. 一个常见的例子就是TableView和TableView的代理. 为了提高TableView的复用性, TableView把对于它内容的决定都交给了另外一个对象在运行时决定. 代理会在后面的文章中详细讲述, 详细可以参考官方文档: Working with Protocols.
直接通过Objective-C运行时添加
Objective-C 通过它的 Objective-C 运行时系统提供了动态的行为.
很多决定 -- 比如当消息发送出去之后, 要调用那个方法 -- 在编译时是不确定的, 但是当程序运行起来之后, 会在运行时决议. Objective-C 不只是一个编译成机器码的语言, 它提供了一个运行时系统去动态执行那些机器码.
我们可以通过直接与运行时系统交互, 比如给对象添加关联引用(associative reference). 和类扩展不同的是, 关联引用并不影响原有类的声明和实现, 这就意味着我们可以使用关联引用修改我们没有权限访问的源码(比如framework中的类).
关联引用把一个对象和另一个对象关联起来, 和属性或实例变量的实现方式很相似. 更多关于关联引用请参考官方文档Associative References. 更多关于运行时的信息, 请参考官方文档Objective-C Runtime Programming Guide. (之后这部分文档也会陆续翻译出来的.)
练习
- 为
XYZPerson类添加一个分类, 添加一些额外的方法, 比如以不同方式展示人名. - 为
NSString添加一个分类, 在分类中添加一个方法以实现在某一个点出绘制该对象代表的全大写字符串. 可以通过调用NSStringDrawing分类中的方法来完成实际的绘制. - 为原有的
XYZPerson类添加两个只读属性分别代表这个人的身高和体重, 并添加measureWeight和measureHeight方法. 使用类扩展并且重新声明这些属性为可读写, 并实现上述方法为属性设置合适的值.