【iOS】Effective Objective-C第三章
- 前言
- 用前缀避免命名空间冲突
- 提供"全能初始化方法"
- 实现description方法
- 尽量使用不可变对象
- 使用清晰而协调的命名方式
- 为私有方法名加前缀
- 理解Objective-C错误模型
- 理解NSCopying协议
前言
我们在iOS开发中构建应用程序时,经常会在开发自己的应用程序时将其中的部分代码用于后续项目或供他人使用。这需要用到OC中常见的编程范式,同时还需了解各种可能碰到的陷阱。
用前缀避免命名空间冲突
OC没有内置的命名空间机制,因此我们在起名时要设法避免潜在的命名冲突,否则很容易重名。这样会出错:
- 发生命名冲突,应用程序链接过程出错,无法链接。

- 在运行期载入了含有重名类的程序库,动态加载器遭遇重名符号错误,整个程序崩溃。
- 避免此问题唯一的办法就是变相实现命名空间:为所有名称都加上适当前缀。
所选前缀可以是与公司、应用程序或二者皆有关联之名。Apple宣称其保留使用所有"两字母前缀"的权利,所以我们自己选用的前缀应该是三个字母的。
- 容易忽视的,类的实现文件中所用的纯C函数及全局变量也应加上前缀。这样做有两个好处:
- 避免在链接时发生重复符号错误。
- 若此符号在栈回溯信息中,则很容易判明问题来自哪块代码。
- 自己开发的程序库中用到的第三方库,也应为其中的名称加上前缀。
提供"全能初始化方法"
所有对象均要初始化。一般情况下,开发者需向对象提供额外信息。通常情况下,对象若不知道必要的信息,则无法完成其工作。例如:初始化UITableViewCell类对象时,需要指明其样式及标识符,用来区分不同类型的单元格。我们把为对象提供必要信息以便其完成工作的初始化方法叫做"全能初始化方法"。
创建类实例的方法不止一种,我们要在其中选定一个作为全能初始化方法,令其他初始化方法都来调用它。只有在全能初始化方法中,才会存储内部数据。当底层数据存储机制改变时,只需修改此方法的代码就好,无须改动其初始化方法。
于是,引出类继承时需要注意的一个重要问题:如果子类的全能初始化方法与其超类方法的名称不同,那么总应该覆写超类的全能初始化方法 。例如:
开发者提供EOCRectangle类初始化设置属性:

覆写init方法来指明本类实例必须使用全能初始化方法来初始化:

现在创建EOCRectangle的子类EOCSquare,该子类的全能初始化方法:

我们注意到,它调用了超类的全能初始化方法。全能初始化方法的调用链一定要维系。
然而调用者可能会使用init方法等来初始化EOCSquare对象,为了避免出现宽高不等的正方形这种情况,我们应该覆写其超类EOCRectangle的全能初始化方法:

如果超类的初始化方法不适用于子类,那么常用的办法是覆写超类的全能初始化方法并于其中抛出异常:

这种方法看似突兀,实则有时是必需的,因为那种情况下创建出来的对象,其内部数据有可能相互不一致。
不过,在OC程序中,只有当发生严重错误时,才抛出异常。因此,初始化方法抛出异常是不得已之举。
有时我们可能需要编写多个全能初始化方法,那么每个子类的全能初始化方法都应该调用其超类的对应方法,并逐层向上,最后再执行与本类有关的任务。
实现description方法
调试程序时,经常要打印并查看对象信息。最常用的做法是:
在构建需要打印到日志的字符串时,object对象会收到description消息,该方法返回的描述信息将取代格式字符串里的%@。例如:

NSArray是系统类,已经帮我们写好了description,会输出:

那么,如果在自定义类上这么做,却会输出:

除非在自己的类里覆写description方法,否则打印信息时会调用NSObject类所实现的默认方法。解决办法就是覆写description方法并将描述此对象的字符串返回即可。
可以借助NSDictionary类的description方法,使得在description中输出很多互不相同的信息。这样也可使得代码更易维护:如果以后要向类中新增属性并在description方法中打印,只需修改字典内容即可。
NSObject协议中还有另一个方法值得注意,那就是debugDescription,此方法与description方法相似。区别在于:debugDescription方法是开发者在调试器中以控制台命令打印对象时才调用的 。在NSObject类的默认方法中,此方法只是直接调用了description。
具体演示:
定义一个Person类,重写description和debugDescription方法:
objc
@interface Person : NSObject
@property (nonatomic, copy) NSString *name;
@end
- (NSString *)description {
return [NSString stringWithFormat:@"description: %@", self.name];
}
- (NSString *)debugDescription {
return [NSString stringWithFormat:@"debug: %@", self.name];
}
objc
Person *p = [Person new];
p.name = @"Jack";
NSLog(@"%@", p);
运行可以看出调用description方法:

设置断点再运行可以看出调用debugDescription方法。控制器输入po p:

尽量使用不可变对象
设立类时,充分运用属性来封装数据。默认情况下,属性是既可读也可写的,这样设计出来的类都是可变的。笔者建议大家尽量减少对象中的可变对象。原因在于:
- 一般情况下我们要建模的数据未必需要改变。例如某数据所表示的对象源自一项只读的网络服务,其中可能包含一系列需要显示在地图上的相关点,像这种对象就没有必要改变其内容。即使修改,新数据也不会推送回服务器。
- 如果把可变对象放入集合之后又修改其内容,那么很容易破坏set的内部数据结构,使其失去固有的语义。
具体到编译实践中,则应尽量把对外公布出来的属性设为只读,而且在有必要时才将属性对外公布。可是readonly不是不变,是外部不可变。因此如果有人试着改变属性值,那么编译会报错。
有时我们想修改封装在对象内部的数据并不想令外人所改动,通常做法是在对象内部将readonly属性重新声明为readwrite。对外可读,内部可写。当然,如果该属性是nonatomic的,那么这样做可能会产生"竞争条件"。在对象内部写入某属性时,对象外的观察者也许正读取该属性。若想避免此问题,我们可以在必要时通过派发队列等手段,将所有数据存取操作都设为同步操作。
将属性在分类中重新声明,属性的其他特质必须保持不变。这样声明的属性在对象的外部仍然能通过键值编码技术设置这些属性。
objc
[pointOfInterest setValue:@"abc" forKey:@"identifier"];
KVC会在类中查找setIdentifier方法,并借此修改属性。这样等同于绕过了本地所提供的API。
在定义类的公共API时,要注意对象里表示各种集合的那些属性是应该设成可变还是不可变的。如果内部有需要设置为可变类型的,那么在外部调用方法返回时,应该传入内部属性拷贝:
objc
@interface EOCPerson : NSObject
@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;
- (void)addFriend:(EOCPerson *)person;
- (void)removeFriend:(EOCPerson *)person;
@end
objc
#import "EOCPerson.h"
@interface EOCPerson ()
@property (nonatomic, copy, readwrite) NSString *firstName;
@property (nonatomic, copy, readwrite) NSString *lastName;
@property (nonatomic, strong) NSMutableSet *internalFriends; // 内部持有的可变集合
@end
@implementation EOCPerson
- (NSSet *)friends {
return [_internalFriends copy]; // 浅拷贝,返回不可变类型
}
- (void)addFriend:(EOCPerson *)person {
[_internalFriends addObject:person];
}
- (void)removeFriend:(EOCPerson *)person {
[_internalFriends removeObject:person];
}
- (id)initWithFirstName:(NSString *)firstName andLastName:(NSString *)lastName {
if ((self = [super init])) {
_firstName = [firstName copy];
_lastName = [lastName copy];
_internalFriends = [NSMutableSet new];
}
return self;
}
@end
因此我们在编写代码时,应通过提供相关方法来修改对象中的可变集合,尽量不要将可变集合作为属性公开。
使用清晰而协调的命名方式
类、方法及变量的命名是OC编程的重要环节。类名(首字母大写、通常有两三个前缀字母)、方法与变量名(以小写字母开头、其后每个单词首字母大写)都使用驼峰式大小写命名法。
给方法命名时有以下注意事项:
- 如果方法的返回值是新建的,那么方法名的首个词应是返回值的类型,除非前面还有修饰语。
- 应该把表示参数类型的名词放在参数前面。
- 如果方法要在当前对象上执行操作,那么就应该包含动词;若执行操作时还需要参数,则应在动词后面加上一个或多个名词。
- 不要使用str这种简称。
- Boolean属性应加上is前缀。如果某方法返回非属性的Boolean值,那么应根据其功能选用has或is当前缀。
- 将get前缀给那些借由"输出参数"来保存返回值的方法。
类与协议的命名也应注意:
为类与协议名称加上前缀,以避免命名空间冲突。
为私有方法名加前缀
为私有方法名加前缀的原因有:
- 有助于调试,据此很容易能把公共方法和私有方法区别开。
- 便于修改方法名或方法签名。
前缀最好用p_(p表示private,下划线可把这个字母和真正的方法名区隔开),下划线后面的部分按照驼峰法来命名即可。
理解Objective-C错误模型
自动引用计数在默认情况下不是"异常安全"的。这意味着:如果抛出异常,那么本应在作用域末尾释放的对象现在却不会自动释放了。如果想生成"异常安全"的代码,可以通过设置编译器的标志来实现,该标志叫做-fobjc-arc-exceptions。不过这将引入一些额外代码,在不抛出异常时,也照样要执行这部分代码。
-fobjc-arc-exceptions标志的作用:开启后编译器会为每个可能抛出异常的作用域生成完善的清理逻辑。当异常发生时,ARC 会像正常退出一样,释放所有本该在作用域末尾释放的对象。但这种机制即使在程序正常运行、没有抛出任何异常的情况下,也会引入额外的运行时开销(比如维护栈上的数据结构、注册清理函数等),增加代码体积和运行时间。
即使不用ARC,也很难写出抛出异常时不会导致内存泄漏的代码。OC现在所采用的办法是:只在及其罕见的情况下抛出异常,抛出后无须考虑恢复问题,而且应用程序此时也应退出。
对于不那么严重的错误,OC语言所用的编程范式是:令方法返回nil/0,或是使用NSError。
NSError对象里封装了三条信息:
- Error domain(类型为字符串):错误范围,即产生错误的根源,通常用一个特有的全局变量来定义。
- Error code(类型为整数,定义为枚举类型最佳):错误码,用以指明在某个范围内具体发生了何种词错误。

- User info(类型为字典):用户信息,即有关此错误的额外信息。
在设计API时,NSError有以下几种常见用法:
- 通过委托协议来传递此错误。有错误发生时,当前对象会把错误经由协议中的某个方法传给其委托对象。

- 经由方法的"输出参数"返回给调用者。例如:

传递给方法的参数是个指针,而该指针本身又指向另一个指针,那个指针指向NSError对象。或者也可以把他当成一个直接指向NSError对象的指针。这样不仅能有普通的返回值,也能经由输出参数把NSError对象回传给调用者。
实际上,在使用ARC时,编译器会把方法签名中的NSError**转换成NSError*_ _autoreleasing*,也就是说指针所指的对象会在方法执行完毕后自动释放。
理解NSCopying协议
NSCopying协议
如果想让自己的类支持拷贝操作,需要实现NSCopying协议。该协议只有一个方法:
objc
- (id)copyWithZone:(NSZone* )zone;
以前开发程序时,会据此把内存分成不同的"区"(zone),而对象会创建在某个区里面。现在每个程序只有一个区:"默认区"。尽管必须实现这个方法,也不必担心其中的zone参数。
copy方法由NSObject实现,该方法只是以"默认区"为参数来调用"copyWithZone:"。我们总是想覆写copy方法,其实真正需要实现的却是"copyWithZone"方法。若想使某个类支持拷贝功能,只需声明该类遵从NSCopying协议,并实现其中的方法即可。
objc
#import <Foundation/Foundation.h>
@interface EOCPerson : NSObject <NSCopying>
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
- (id)initWithFirstName:(NSString *)firstName andLastName:(NSString *)lastName;
@end
objc
- (id)initWithFirstName:(NSString *)firstName andLastName:(NSString *)lastName {
if (self = [super init]) {
_firstName = [firstName copy];
_lastName = [lastName copy];
}
return self;
}
- (id)copyWithZone:(NSZone *)zone {
EOCPerson *copy = [[[self class] allocWithZone:zone] initWithFirstName:_firstName andLastName:_lastName];
return copy;
}
上述例子中,我们采用全能初始化方法来初始化待拷贝对象,令其执行所有初始化工作。然而有时,除了要拷贝对象,还要完成其他一些操作。
NSMutableCopying协议
该协议也只定义了一个方法:
objc
-(id)mutableCopyWithZone:(NSZone* )zone;
无论当前实例是否可变,若需获取其可变版本的拷贝,均应调用mutableCopy方法;同理,若需要不可变的拷贝,则应通过copy方法来获取。
该关系总是成立的:

这样就能实现可变版本与不可变版本之间的自由切换。要达到该目的,另一个办法是提供三个办法:copy、immutableCopy、mutableCopy。其中copy所返回的拷贝对象与当前对象的类型一致,而另外两个方法则分别返回不可变版本与可变版本的拷贝。不过这种做法最好不要用在调用者并不知道其所用实例是否真的可变的情况下。
深浅拷贝
在编写拷贝方法时,还要决定应该执行深拷贝还是浅拷贝。
- 深拷贝:在拷贝对象自身时,将底层数据也一并复制过去。
- 浅拷贝:只拷贝容器对象本身,而不复制其中数据
集合类默认都执行浅拷贝,这是因为容器内的对象未必都能拷贝,而且调用者也未必想在拷贝容器时一并拷贝其中的每个对象。

objc
- (instancetype)initWithArray:(NSArray<ObjectType> *)array copyItems:(BOOL)flag;
若copyItem参数设为YES,则该方法会向数组中的每个元素发送copy消息。这个方法才是一个容器的深拷贝,而采用普通的copy方式只是一个浅拷贝,不会拷贝每一个set中的元素。
注意:不是遵从了NSCopying协议的对象都会执行深拷贝,绝大多数都是浅拷贝。除非该类的文档说明它是用深拷贝来实现NSCopying协议的。