编写高质量iOS与OS X代码的52个有效方法
第2章 对象、消息、运行期
6.理解“属性”这一概念
使用属性,编译器会自动编写访问这些属性所需要的方法,这个过程由编译器在编译期执行。除了生成方法代码外,编译器还会自动向类中添加适当类型的实例变量,并且在属性名前面加下划线。
可以使用 @synthesize 语法来指定实例变量的名字。
@dynamic 关键字会告诉编译器:不要自动创建实现属性所用的实例变量,也不要为其创建存取方法。而且,在编译器访问属性的代码时,即使编译器发现没有定义存取方法,也不会报错,它相信这些方法能在运行期找到。
属性特质
属性可以拥有的特质分为四类:原子性、读/写权限、内存管理语义、方法名
1.原子性
具备 atomic 特质的获取方法会通过锁定机制来确保其操作的原子性。也就是说,如果两个线程读写同一属性,那么不论何时,总能看到有效的属性值,若是不加锁的话(nonatomic),那么当其中一个线程正在改写某属性值时,另外一个线程也许会突然闯入,把尚未修改好的属性值读取出来,发生这种情况时,线程读到的属性值可能不对。
开发 iOS 程序,属性都声明为 nonatomic。历史原因是:在 iOS 中使用同步锁的开销较大,会带来性能问题。并且并不能保证线程安全。例如,一个线程在连续多次读取某属性值的过程中有别的线程在同时改写该值,那么即便将属性声明为 atomic,也还是会读到不同的属性值。
2.读写/权限
readwrite readonly
3.内存管理语义
- assign 针对“纯量类型”的简单赋值操作。
- strong 拥有关系。为这种属性设置新值时,设置方法会先保留新值,并释放旧值,然后再将新值设置上去。
- weak 非拥有关系。设置方法既不保留新值,也不释放旧值。此特性同 assign 类似,然而在属性所指的对象遭到销毁时,属性值也会清空(nil out)。
- unsafe_unretained 语义和 assign 相同,但它适用于“对象类型”,非拥有关系,当目标对象遭到摧毁时,属性值不会自动清空,这一点与 weak 有区别。
- copy 与 strong 类似。然而设置方法并不保留新值,而是将其拷贝(copy)。只要实现属性所用的对象是可变的(mutable),就应该在设置新属性值时拷贝一份。
4.方法名
getter=<name>
指定“获取方法”的方法名。例如 UISwitch 类中的 switch 是否打开的属性@property (nonatomic, getter=isOn) BOOL on
;
setter=<name>
指定“设置方法”的方法名。不常见。
若是自己来实现这些存取方法,那么应该保证其具备相关属性所声明的特质。例如,如果将某个属性声明为 copy,那么就应该在“设置方法”中拷贝相关对象,否则会误导该属性的使用者,而且还会令程序产生 bug。
在实现自定义初始化方法时,如果属性是 copy 修饰的,初始化方法中赋值时也要 copy 一下。
atomic 为什么不能保证线程安全?例:一个线程在连续多次读取某属性值的过程中有别的线程在同时改写该值,那么即便将属性声明为atomic,也还是会读到不同的属性值。
要点
- 可以使用 @property 语法来定义对象中所封装的数据。
- 通过“特质”来指定存储数据所需的正确语义。
- 在设置属性所对应的实例变量时,一定要遵从该属性所声明的语义。
- 开发 iOS 程序时应该使用 nonatomic 属性,因为 atomic 属性会严重影响性能。
7.在对象内部尽量直接访问实例变量
在对象之外访问实例变量,总是应该通过属性来做。
通过属性访问与直接访问的区别
- 直接访问实例变量不经过 Objective-C 的“方法派发”(method dispatch)步骤,所以直接访问实例变量的速度当然比较快。在这种情况下,编译器所生成的代码会直接访问保存对象实例变量的那块内存。
- 直接访问实例变量,不会调用其“设置方法”,这就绕过了为相关属性所定义的“内存管理语义”。比方说,如果在 ARC 下直接访问一个声明为 copy 的属性,那么并不会拷贝该属性,只会保留新值并释放旧值。
- 如果直接访问实例变量,那么不会触发“键值观测”(Key-Value Observing,KVO)通知。
- 通过属性来访问有助于排查与之相关的错误,因为可以给“获取方法”和/或“设置方法”中新增断点,监控该属性的调用者及其访问时机。
要点
在对象内部读取数据时,应该直接通过实例变量来读,而写入数据时,则应该通过属性来写(set方法),(1)首要原因在于,这样做能够确保相关属性的“内存管理语义”得以贯彻。(2)为了触发 KVO。
在初始化方法和dealloc方法中,总是应该直接通过实例变量来读写数据。
- 使用懒加载时,需要通过属性来读取数据。(get方法)
8.理解“对象等同性”这一概念
== 比较的是两个指针本身,而不是所指的对象。
isEqual 比较的两个对象。
要点
若想检测对象的等同性,请提供“isEqual:”与 hash 方法。
相同的对象必须具有相同的哈希码,但是两个哈希码相同的对象却未必相同。
- 不要盲目地逐个检测每条属性,而是应该依照具体需求来制定监测方案。
- 编写 hash 方法时,应该使用计算速度快而且哈希码碰撞几率低的算法。
9.以“类族模式”隐藏实现细节
类族(class cluster)是一种很有用的模式(pattern),可以隐藏“抽象基类”(abstract base class)背后的实现细节。比如 UIButton 的类方法:
1 | + (UIButton*)buttonWithType:(UIButtonType)type; |
该方法所返回的对象,其类型取决于传入的按钮类型。然而,不管返回什么类型的对象,它们都继承自同一个基类:UIButton。这么做的意义在于:UIButton 类的使用者无需关心创建出来的按钮具体属于哪个子类,也不用考虑按钮的绘制方式等实现细节。
工厂模式(Factory pattern)是创建类族的办法之一。
要点
- 类族模式可以把实现细节隐藏在一套简单的公共接口后面。
- 系统框架中经常使用类族。
- 从类族的公共抽象基类中继承子类时要当心,若有开发文档,则应首先阅读。
10.在既有类中使用关联对象存放自定义数据
1 | void objc_setAssociatedObject(id object, void*key, id value, objc_AssociationPolicy policy) |
此方法以给定的键和策略为某对象设置关联对象值。1
void objc_getAssociatedObject(id object, void*key)
此方法根据给定的键从某对象中获取相应的关联对象值。1
void objc_removeAssociatedObject(id object)
此方法移除指定对象的全部关联对象。
要点
- 可以通过“关联对象”机制把两个对象连起来。
- 定义关联对象时可指定内存管理语义,用以模仿定义属性时所采用的“拥有关系”与“非拥有关系”。
- 只有在其他做法不可行时才应选用关联对象,因为这种做法通常会引入难于查找的 bug。
11.理解objc_msgSend的作用
void objc_msgSend(id self, SEL cmd, …)
这是个参数可变的函数,能接受两个或两个以上的参数。第一个参数代表接收者,第二个参数代表选择子(SEL是选择子的类型),后续参数就是消息中的那些参数,其顺序不变。选择子指的就是方法的名字。
objc_msgSend 函数会根据接收者与选择子的类型来调用适当的方法。为了完成此操作,该方法需要在接收者所属的类中搜寻其“方法列表”(list of methods),如果能找到与选择子名称相符的方法,就跳转到其实现代码。若是找不到,那就沿着继承体系继续向上查找,等找到合适的方法之后再跳转。如果最终还是找不到相符的方法,那就执行“消息转发”(message forwarding)操作。
objc_msgSend 会将匹配结果缓存在“快速映射表”(fast map)里面,每个类都有这样一块缓存。
边界情况
- objc_msgSend_stret。如果待发送的消息要返回结构体,那么可交由此函数处理。
- objc_msgSend_fpret。如果消息返回的是浮点数,那么可交由此函数处理。
- objc_msgSendSuper。如果要给超类发消息,那么就交由此函数处理。
要点
- 消息由接收者、选择子及参数构成。给某对象发送消息也就相当于在该对象上调用方法。
- 发给某对象的全部消息都要由“动态消息派发系统”(dynamic message dispatch system)来处理,该系统会查出对应的方法,并执行其代码。
12.理解消息转发机制
动态方法解析
1 | + (BOOL)resolveInstanceMethod:(SEL)selector |
假如尚未实现的方法是类方法 resolveClassMethod:
备援接收者
1 | - (id)forwardingTargetForSelector:(SEL)selector |
通过此方案,我们可以用组合来模拟出多重继承(multiple inheritance)的某些特性。
完整的消息转发
首先创建 NSInvocation 对象,把与尚未处理的那条消息有关的全部细节都封于其中。此对象包含选择子、目标(target)及参数。在触发 NSInvocation 对象时,“消息派发系统”(message-dispatch system)将亲自出马,把消息指派给目标对象。
1 | - (void)forwardInvocation:(NSInvocation*)invocation |
要点
- 若对象无法响应某个选择子,则进入消息转发流程。
- 通过运行期的动态方法解析功能,我们可以在需要用到某个方法时再将其加入类中。
- 对象可以把其无法解读的某些选择子转交给其他对象来处理。
- 经过上述两步后,如果还是没办法处理选择子,那就启动完整的消息转发机制。
13.用“方法调配技术”调试“黑盒方法”
1 | void method_exchangeImplementations(Method m1, Method m2) |
此函数的两个参数表示待交换的两个方法实现,而方法实现则可通过下列函数获得:
1 | Method class_getInstanceMethod(Class aClass, SEL aSelector) |
要点
- 在运行期,可以向类中新增或替换选择子所对应的方法实现。
- 使用另一份实现来替换原有的方法实现,这道工序叫做“方法调配”,开发者常使用此技术向原有实现中添加新功能。
- 一般来说,只有调试程序的时候才需要在运行期修改方法实现,这种做法不宜滥用。
14.理解“类对象”的用意
Objective-C 对象的本质
每个 Objective-C 对象实例都是指向某块内存数据的指针。
描述 Objective-C 对象所用的数据结构定义在运行期程序库的头文件里,id 类型本身也定义在这里:
1 | typedef struct objc_object { |
由此可见,每个对象结构体的首个成员是 Class 类的变量。该变量定义了对象所属的类。
Class 对象也定义在运行期程序库的头文件中:
1 | typedef struct objc_class *Class; |
此结构存放类的“元数据”(metadata)。此结构体的首个变量也是 isa 指针,这说明 Class 本身亦为 Objective-C 对象。结构体里还有个变量叫做 super_class,它定义了本类的超类。类对象所属的类型(也就是 isa 指针所指向的类型)是另外一个类,叫做“元类”(metaclass),用来表述类对象本身所具备的元数据。“类方法”就定义于此处,因为这些方法可以理解成类对象的实例方法。每个类仅有一个“类对象”,而每个“类对象”仅有一个与之相关的“元类”。
isMemberOfClass:
能够判断出对象是否为某个特定类的实例,而isKindOfClass:
则能够判断出对象是否为某类或其派生类的实例。
要点
- 每个实例都有一个指向 Class 对象的指针,用以表明其类型,而这些 Class 对象则构成了类的继承体系。
- 如果对象类型无法在编译期确定,那么就应该使用类型信息查询方法来探知。
- 尽量使用类型信息查询方法来确定对象类型,而不要直接比较类对象,因为某些对象可能实现了消息转发功能。