第三章 接口与API设计
一份好的代码,不光自己能够看懂,也应该让别人很容易理解,并且我们要确保代码添加的别的工程中的时候,不会影响其他代码,这时候,好的接口和API设计就非常有用了。
15. 用前缀避免命名空间冲突
简单的说就是不能在同一个工程中出现相同的类名,解决的办法就是加前缀。Apple宣称保留所有“两字母前缀”,所以大部分代码的前缀都是三个大写字母,这些字母可以是任意的,可以根据公司名、项目名等。应用程序中所有的名称都应该加前缀,包括“分类”及“分类”中的方法等。
另外需要注意的一点,我们在写一份第三方应用的时候,如果在我们的文件中引入了其他第三方代码,一定要给第三方代码加前缀,虽然这是一个很枯燥的过程,因为引用我们代码的人可能也引入了那个第三方代码,这个时候就会起冲突,所以这一点一定要注意,例如Reachability文件的引用。
16. 提供“全能初始化方法”
对象的产生需要初始化,有时候一个类可能存在多个初始化方法,这样做可以让我们根据自己的需求创建出我们想要点的实例对象,不过这样做,我么要在这些初始化方法中选择一个“全能初始化方法”,令其他的初始化方法都来调用发。全能初始化方法的定义是这样的,我们把这种可为对象提供必要信息以便其能完成工作的初始化方法,叫做全能初始化方法。
其实运用全能初始化方法的最直观的好处就是,当底层数据存储机制变动的时候,只要修改全能初始化方法就可以了。
下面用例子说明如何创建一个类的全能初始化方法,以及如何创建这个类的子类的全能初始化方法:
首先定义一个矩形类:
.h文件
@interface EOCRectangle : NSObject
@property (nonatomic, assign, readonly) float width;
@property (nonatomic, assign, readonly) float height;
- (id)initWithWidth:(float)width height:(float)height;
@end
.m文件
@implementation EOCRectangle
- (id)initWithWidth:(float)width height:(float)height{
if ((self = [super init])) {
_width = width;
_height = height;
}
return self;
}
@end
只这么写还是不行的,因为有时候可能会用[[EOCRectangle alloc] init]
的方法创建实例(这个方法是从NSObject继承过来的),这时候我们应该覆写init初始化方法,如下:
// 第一种方案
- (instancetype)init{
@throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"必须用initWithWidth:Height: 初始化方法" userInfo:nil];
}
// 第二种方案
- (instancetype)init{
return [self initWithWidth:5.0 height:10.0];
}
个人推荐第一种方案,因为第二种方案在底层数据变动的时候还是要修改,并且我们可能不想要一个默认的值。
下面定义一个正方形类EOCSquare,继承于上面的矩形类:
.h文件
@interface EOCSquare : EOCRectangle
- (id)initWithDimension:(float)dimension;
@end
.m文件
@implementation EOCSquare
- (id)initWithDimension:(float)dimension{
return [super initWithWidth:dimension height:dimension];
}
@end
这样写很合理,在子类的全能初始化方法中调用了父类全能初始化方法,我们在继承体系中,一定刚要确保这样的链式结构延续下去,这个时候我们还可能用initWithWidth:Height:
和init
方法初始化EOCSquare类,这时候我们还要覆写这两个方法,如下:
覆写initWithWidth:Height:
方法
// 第一种方案
- (id)initWithWidth:(float)width height:(float)height{
@throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"必须用initWithDimension:初始化方法" userInfo:nil];
}
// 第二种方案
- (id)initWithWidth:(float)width height:(float)height{
float dimension = MAX(width, height);
return [self initWithDimension:dimension];
}
个人还是推荐第一种方案
覆写init
方法
// 第一种方案
- (instancetype)init
{
@throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"必须用initWithDimension:初始化方法" userInfo:nil];
}
// 第二种方案
- (instancetype)init
{
return [self initWithDimension:0.5];
}
个人还是推荐第一种方案
有时候,一个类的实例可能来自完全不同的两种初始化方式,例如我们上面定义的矩形类,如果遵循了NSCoding协议,那么我们就要另外添加一种全能初始化方法
- (instancetype)initWithCoder:(NSCoder *)aDecoder{
if ((self = [super init])) {
_width = [aDecoder decodeFloatForKey:@"width"];
_height = [aDecoder decodeFloatForKey:@"height"];
}
return self;
}
一个类的父类遵循了NSCoding协议,按照上面的例子,此时若EOCSquare类也遵循NSCoding协议的话,应该也增加全能化初始化方法,并且可以调用父类的全能初始化方法,如下:
- (instancetype)initWithCoder:(NSCoder *)aDecoder{
if ((self = [super initWithCoder:aDecoder])) {
// 处理子类自己的需求
}
return self;
}
我们始终要遵循子类的全能初始化方法调用父类全能初始化方法这一规定,例如本例中,如果不调用EOCRectangle的initWithCoder:
方法的话,就无法将_width
和_height
两个实例变量解码。
17. 实现description方法
description方法定义在NSObject协议里,NSObject也实现了他,所以如果不在类里覆写description方法,打印信息就会调用NSObject类实现的默认方法(NSProxy基类也遵循NSObject协议)。description方法主要的用处就是在打印信息的时候调用这个方法获取信息,如果不在自己的类里覆写description方法,打印出来的信息只有类名和内存地址,很显然这不能满足我们的需求,这时候就要覆写description方法,增加对象的描述信息。
下面提几个点:
1.在新实现的description方法中,也应该像默认实现的那样,打印出类名和内存地址。
2.在自定义信息内容的时候可以遵循字典那样的格式,这样方便以后增加和删除打印项,并且字典形式看起来更简洁。
不过怎么定义打印信息全看我们自己,没有固定的格式,只要自己用着方便就好。
NSObject协议中还有一个debugDescription方法,这个方法是开发者在调试器中以控制台命令打印对象调用的(就是我们在控制台中输入po时调用),在NSObject类的默认实现中debugDescription方法直接调用description方法,和description方法的覆写一样,具体怎么设置打印信息没有具体标准,只要自己用着方便就行。
18. 尽量使用不可变对象
设计类的时候,应该尽量用属性来封装数据,而在使用属性时,则应该尽量将属性声明为只读(readonly),为什么要这样呢?通常一个类中数据都是由网络获取的,即使我们修改这些属性,也不会发送回服务器,所以没有必要,另外,有的时候我们不知道属性的内部结构,比如集合中是否包含可变对象等,如果包含,这些可变对象是否可以更改,这些都是未知的,所以我们在设计属性的时候尽量都声明为只读,这样可以避免很多麻烦,当然这不是固定的,真正的开发中还是看实际的需要,只是在多数情况下建议这样做。
为了把属性对外设置成只读,通常将readonly的属性在对象内部声明为readwrite,通常都是在分类中从新声明一下,不过这么做需要注意一点,当属性是nonatomic的时候,可能产生“竞争条件”(内部写入属性时,外部也许正在读取属性),若想避免这个问题可能在必要时通过“派发队列”手段,将所有数据存储操作设为同步操作。
需要提一点的是,即使属性声明为readonly,值也是可以通过外部修改的,可以通过KVC直接进行键值编码,更暴力一点可以直接用类型信息查询功能查出属性所多对应的实例变量在内存布局中的偏移量,以此来人为设置这个实例变量的值,不过这么做都是不推荐的。
另外还需要注意一点,当我们的属性是集合类型的时候,我们应该将属性设置成可变还是不可变,通过下面例子说明:
.h文件
@interface EOCPerson : NSObject
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
@property (nonatomic, copy, readonly) NSSet *friends;
- (id)initWithFirstName:(NSString *)firstName andLastName:(NSString *)lastName;
- (void)addFiriend:(EOCPerson *)person;
- (void)removeFiriend:(EOCPerson *)person;
@end
.m文件
@interface EOCPerson ()
@property (nonatomic, copy, readwrite) NSString *firstName;
@property (nonatomic, copy, readwrite) NSString *lastName;
@end
@implementation EOCPerson{
NSMutableSet *_internalFriends;
}
- (id)initWithFirstName:(NSString *)firstName andLastName:(NSString *)lastName{
if ((self = [super init])) {
_firstName = firstName;
_lastName = lastName;
_internalFriends = [NSMutableSet new];
}
return self;
}
- (void)addfriend:(EOCPerson *)person{
[_internalFriends addObject:person];
}
- (void)removefriend:(EOCPerson *)person{
[_internalFriends removeObject:person];
}
- (NSSet *)friends{
return [_internalFriends copy];
}
@end
注意这个例子中的friends属性,有人可能会问,为什么不用NSMutableSet来实现friensds属性呢?这样做还可以省去addFriend:和removeFriend:两个方法,我们要知道一点,如果换成NSMutableSet,这种过分解耦的数据很容易出现bug,如果换成NSMutableSet省去添加朋友和删除朋友这两个方法,那么相当于从底层直接修改了内部存放朋友对象的set,在EOCPerson对象不知情时,很容易使对象内个数据之间不一致。
19. 使用清晰而协调的命名方式
首次接触OC的人都认为OC的命名太长了,可能有的人喜欢有的人不喜欢,不过一种形式设计出来并且大家都在遵守就必然有其存在的理由。
和OC接触多了我们会发现,大家的代码基本都遵循这样的一套规范,方法与变量名使用“驼峰命名法”(以小写字母开头,其后每个单词首字母大写),类名也用驼峰命名法,不过首字母大写,而且通常有前缀字母。
对于方法的命名来说,最好能答到的目标是开发者能根据方法名知道这个方法有什么作用,并且了解其中的各个参数所表达的具体意思,新手可能不习惯写这种长的方法名,但是慢慢的就会喜欢这种命名方式,不过有一点就是写习惯OC代码的人,对其他的语言可能会很不习惯,因为他们对方法名产生了依赖性。
类与协议名通常要加前缀,以避免命名冲突,这在前面已经说过,另外我们可以模仿UIKit类库的整体命名体系,模仿源代码总是不会错的。
20. 为私有方法名加前缀
可能有人会说,私有方法为什么还要加前缀,私有方法又不是暴露在外面,只要自己用着方便就行,其实为私有方法加前缀也是有好处的。为私有方法加固定的前缀可以将私有方法和其他的区分开,方便查找修改,另外多提一点,私有方法尽量写在一起。另外注意一点,不要模仿苹果用单一下划线作为私有方法的前缀,并且这样做也是苹果不推荐的,因为有时候我们可能继承苹果原有的类写了一个子类,如果这样命名的话很可能无意间覆写了父类的方法。总之我们最好保证以下两点为私有方法命名,第一保证自己的私有方法名是独一无二的,第二尽量使自己的私有方法方便查找。
21. 理解Objective_C错误模型
OC有自己的异常信息处理方式,通常有三种方法。
1.抛出异常
主要用的是exceptionWithName: reason: userInfo:
方法,将错误信息标出,然后抛出异常。这种处理只应该应用于极其严重的错误,比如前面举例的利用了不该用的初始化方法,这种抛出异常的方法还是不建议用的,因为这样写的代码很有可能因为抛出异常而变的不安全,例如下面的例子:
id someResource = /*···*/;
if (/*check for error*/) {
@throw [NSException exceptionWithName:ExceptionName reason:@"There was an error" userInfo:nil];
}
[someResource doSomething];
[someResource release];
从例子中可以看出当抛出异常之后后面的释放语句没有执行,这样写的代码是不安全的,在ARC下可以通过修改编译器标志避免这种情况(打开编译器标志叫做-fobjc-arc-exceptions),但是这样会让程序运行不必要的代码。在非ARC下我们可以手动把释放代码添加都前面,但是当代码结构复查的时候显然有很大的弊端,所以抛出异常这种做法只应该在很严重的情况下应用,并且异常抛出后无须考虑修复,程序直接退出。
2.令返回值为nil/0
这是更不推荐的一种做法,范式就是通过条件判断语句,在不能达到我们要求的情况的时候返回nil/0,这种方法是极力不推荐的,因为这种代码有时候会让给我们造成一些不必要的困扰。
3.使用NSError
这种方法是推荐的,并且NSError用法灵活,经由这种方法,可以把导致错误的原因回报给调用者,让调用者按照错误信息查找原因。
Error对象内部通常会封装三种信息:
- Error domain(错误范围,类型为字符串)
产生错误的根源,通常用特有的全局变量来定义,比如NSURLErrorDomain。 - Error code(错误码,类型为整数)
独有的错误代码,用于指明某个特定范围可能发生的一系列错误,这些错误通常采用枚举定义,最长见得就是我们通常看到的HTTP请求出错时的状态码。 - User info(用户信息,类型为字典)
有关错误的一些附加信息,包含错误的描述,或许还含有导致该错误发生的另外一个错误,经由这些信息,可以将相关错误串成一条“错误链”。
通常在写代码的时候,最常用的方法是通过委托协议来传递NSError对象,这样可以把错误模型传递给其他委托对象,这样委托对象可以根据需要判断是不是需要处理这个错误信息,相信这种方式大家都比较熟悉,这里不再举例。
另一种方法是将NSError对象经由方法的“输出参数”返回给调用者,范式通常如下:
-(BOOL)doSomething:(NSError**)error
参数是个指针,该指针本身指向另外一个指针,那个指针指向NSError对象。可以通过如下的例子把NSError对象传递到输出参数中:
-(BOOL)doSomething:(NSError **)error{
// Do something that may cause an error
if (/*There was an error*/) {
if (error) {
// Pass the 'error' through the out-parameter
*error = [NSError errorWithDomain:domain code:code userInfo:userInfo];
}
return NO;
} else {
return YES;
}
}
代码中通过*error
语法为参数error
“解引用”,也就是说error
所指的那个指针现在要指向一个新的NSError对象了。里面用了一个判断语句,这样做的目的是如果我们对一个空指针“解引用”会造成程序崩溃,所以要保护一下,因为有的时候调用者可能不关系具体错误,会给error
参数传nil。
NSError对象的内部错误信息可以如下定义:
.h文件
extern NSString *const ErrorDomain;
typedef NS_ENUM(NSUInteger, Error){
ErrorUnknow = -1,
ErrorInternalInconsistency = 100,
ErrorGeneralFault = 105,
ErrorBadInput = 500,
};
.m 文件
NSString *const ErrorDomain = @"ErrorDomain";
枚举中可以标注出相应的错误意思,至于userInfo就看错误的情况自行定义了。
22. 理解NSCopying协议
注意是NSCopying不是NSCoding协议
OC中对象如果要是能被copy,就要实现NSCopying协议,该协议只有一个方法:
- (id)copyWithZone:(NSZone *)zone;
首先不用纠结zone这个参数,以前开发程序时,会根据NSZone把内存分成不同的区,而对象会创建在区里面,现在不用了,现在是每个程序只在一个默认区。下面看一下NSCopying协议的具体实现,还是以EOCPerson为例:
.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
.m文件(实现协议中方法)
- (id)copyWithZone:(NSZone *)zone{
EOCPerson *copy = [[[self class] allocWithZone:zone] initWithFirstName:_firstName andLastName:_lastName];
return copy;
}
这是一个最基本的实现NSCopying的例子(注意我们直接把拷贝对象交给了“全能初始化方法”),看下面一种情况,假如EOCPerson类中有一个集合,该集合和朋友的添加和删除有关
.h文件
@interface EOCPerson : NSObject<NSCopying>
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
- (id)initWithFirstName:(NSString *)firstName andLastName:(NSString *)lastName;
- (void)addFiriend:(EOCPerson *)person;
- (void)removeFiriend:(EOCPerson *)person;
@end
.m文件
@implementation EOCPerson{
NSMutableSet *_internalFriends;
}
- (id)initWithFirstName:(NSString *)firstName andLastName:(NSString *)lastName{
if ((self = [super init])) {
_firstName = firstName;
_lastName = lastName;
_internalFriends = [NSMutableSet new];
}
return self;
}
- (void)addFiriend:(EOCPerson *)person{
[_internalFriends addObject:person];
}
- (void)removeFiriend:(EOCPerson *)person{
[_internalFriends removeObject:person];
}
- (id)copyWithZone:(NSZone *)zone{
EOCPerson *copy = [[[self class] allocWithZone:zone] initWithFirstName:_firstName andLastName:_lastName];
copy->_internalFriends = [_internalFriends mutableCopy];
return copy;
}
@end
注意这里使用的->
语法,因为internalFriends不是属性,只是一个实力变量,我们令copy对象的internalFriends实例变量指向这个复制过的集合。我们也可以声明一个属性,但是在这里internalFriends不对外使用,所以没必要这么做。这里提一下为什么要拷贝internalFriends实例变量,而不是让两个对象共享一个集合,这样做显然是不行的,如果公用一个,那么改变其中一个对象,另外一个对象就随之改变,这是我们不想要的。另外,如果本例中的集合是不可变集合,那么就不用复制。上面的两个例子都是用的“全能初始化方法”,这么做不是必须的,有时候可能不适用,比如全能初始化方法中涉及到复杂的数据结构,而拷贝后的对象内部数据可能没必要这么复杂。
上面的例子中有一个方法[_internalFriends mutableCopy]
,通过这个方法引出一个叫NSMutableCopying的协议,这个协议与NSCopying类似,也只有一个方法:
-(id)mutableCopyWithZone:(NSZone *)zone;
在解释NSMutableCopying之前我们要知道,一个类的可变和不可变版本要遵循下面的规则,以NSArray和NSMutableArray为例:
[NSArray mutableCopy] => NSMutableArray
[NSMutableArray copy] => NSArray
通过上面的规则,在结合实际情况,我们就可以实现NSMutableCopying协议,这里不再多举例。
还有一个深拷贝和浅拷贝的问题,有时候我们要考虑是不是要给一个类添加深拷贝方法deepCopy,特别是容器类,例如NSSet类中就有一个方法:
- (instancetype)initWithSet:(NSSet<ObjectType> *)set copyItems:(BOOL)flag;
当参数flag为YES的时候,该方法会向集合中的每个元素发送copy消息,用拷贝好的元素创建新集合,并返回给调用者,这个时候我们就要考虑编写一个deepCopy方法,以EOCPerson类为例,添加深拷贝方法:
- (id)deepCopy{
EOCPerson *copy = [[[self class] alloc] initWithFirstName:_firstName andLastName:_lastName];
copy->_internalFriends = [[NSMutableSet alloc] initWithSet:_internalFriends copyItems:YES];
return copy;
}
深拷贝没有具体的协议,在写的时候我们要依照具体的类来决定,另外需要注意一点,在执行NSCopying协议的类中,大部分默认情况下都是执行的浅拷贝,深拷贝只在特殊需要时才会提出来。
最后科普一下,深拷贝和浅拷贝区别就是,深拷贝会将对象的底层数据也一起拷贝,浅拷贝只拷贝容器对象自身,对其内部数据不进行拷贝,Foundation框架中默认都是浅拷贝。