Objective-C(三)接口与API设计

这是Objective-C系列的第3篇。

一、最佳实践

  • 为所有类名都加上适当的前缀

  • 提供”全能初始化方法”

    • 在类中提供一个全能初始化方法,并与文档中指明。其他初始化方法均应调用此方法;
    • 若全能初始化方法与超类方法不同,则需要覆写超类中对应的方法;
    • 如果超类方法的初始化方法不适用于子类,那么应该覆写这个超类方法,并在其中抛出异常。
  • description方法

    • 实现description方法返回一个有意义的字符串,用于描述该实例;
    • 若想在调试时,打印出更详尽的对象描述信息,则应实现debugDescription方法。
    • 可以去寻找实现这样的插件来快速生成该方法。
  • 尽量创建不可变对象;

    • 若属性仅可用于对象内部修改,则在“class-continuation分类”中将其有readonly属性扩展为readwrite
    • 不要把可变的collection作为属性公开,而应提供相关方法,以此修改对象中可变的collection。
  • 命名

    参考使用清晰而协调的命名方式

  • copy

    • 若想令自己的所写的对象具有拷贝功能,则需实现NSCopying协议。
    • 如果自定义对象分为可变版本和不可变版本,那么就要同时实现NSCopying与NSMutableCopying。
    • 复制对象时需决定采用浅拷贝还是深拷贝,一般情况下应该尽量执行浅拷贝。
    • 如果你所写的对象需要深拷贝,那么可考虑新增一个专门执行深拷贝的方法。

二、实践详解

2.1 用前缀避免命名空间的冲突

遇上如下错误吗?

duplicate symbol _OBJC_METACLASS_$DuplicateTheClass in 
     build /somethings.o
     build /something_else.o

以上错误是由于在应用程序中两份代码都各种实现了DuplicateTheClass的类,导致该类符号和元类各定义了两次。

避免此问题的唯一办法就是变相实现命名空间:为所有名称都加上适当前缀

前缀可以是公司、应用程序或者功能模块,甚至是个人标识等关联的词汇。但要注意的是,Apple宣称其保留所有使用“两字母前缀”的权利,所以你自己选用的前缀应该是三个字母的。

还有一个要注意的问题是,类的实现文件中所用的纯C函数以及全局变量,在编译好的目标文件中,这些都是“顶级符号(top-level symbol)”。

同时,也要注意在引入第三方库的时候,注意别使得自己的命名与其他库命名重合。

2.2 提供“全能初始化方法”

  • 在类中提供一个全能初始化方法,并与文档中指明。其他初始化方法均应调用此方法;
  • 若全能初始化方法与超类方法不同,则需要覆写超类中对应的方法;
  • 如果超类方法的初始化方法不适用于子类,那么应该覆写这个超类方法,并在其中抛出异常。

2.3 实现description方法

  • 实现description方法返回一个有意义的字符串,用于描述该实例;
  • 若想在调试时,打印出更详尽的对象描述信息,则应实现debugDescription方法。
  • 可以去寻找实现这样的插件来快速生成该方法。

2.4 尽量使用不可变对象

设计类的时候,应充分使用属性来封装数据。而在使用属性时,则可将其声明为readonly。默认的属性都是readwrite

在设计类的时候,将某些不需要改变的属性设置为readonly,以防止被改动。

@interface HOReadOnlyProperty : NSObject
@property (nonatomic ,readonly ,copy)NSString *name;
@end

在这里,虽然指定了readonly,就没有设置方法,但还是指定了内存管理语义,是为了以后方便将其改动为可读写属性。

有时可能想修改封装在对象内部的数据,但是却不想令这些数据为外人所改动。通常是在对象内部将readonly属性重新声明为readwrite。如下:

1
2
3
4
5
6
@interface HOReadOnlyProperty()
@property (nonatomic ,readwrite ,copy)NSString *name;
@end

@implementation HOReadOnlyProperty
@end

这样就可以实现只在内部改变属性值了。

!但是!并不能保证外部不会改变属性值,因为仍然可以通过KVC实现改变。通过setValue:forKey:来修改。

另外,在定义表示各种collection的那些属性时,一般会提供一个readonly属性供外界使用,该属性返回一个不可变的set,而此set是内部那个可变set的一个拷贝。比如下面:

1
2
3
4
@interface HOPerson : NSObject
@property (nonatomic ,copy)NSString *name;
@property (nonatomic ,strong ,readonly)NSSet *friends;
@end

实现文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@interface HOPerson()
{
NSMutableSet *_internalFriends;
}

@end

@implementation HOPerson

- (instancetype)initWithName:(NSString *)name
{
if (self = [super init]) {
_name = name;
_internalFriends = [NSMutableSet set];
}
return self;
}

- (NSSet *)friends
{
return [_internalFriends copy];
}

- (void)addFriend:(HOPerson *)person
{
[_internalFriends addObject:person];
}

- (void)removeFriend:(HOPerson *)person
{
[_internalFriends removeObject:person];
}

@end
  • 尽量创建不可变对象;
  • 若属性仅可用于对象内部修改,则在“class-continuation分类”中将其有readonly属性扩展为readwrite
  • 不要把可变的collection作为属性公开,而应提供相关方法,以此修改对象中可变的collection。

2.5 使用清晰而协调的命名方式

2.5.1 方法命名规则

  • 如果方法的返回值是新创建的,那么方法名的首个词应是返回值的类型,比如stringWithString。除非前面有修饰语,比如localizedString。属性的存取方法不遵循这种命名方式,因为一般认为这些方法不会创建新对象,即便有时返回内部对象的一份拷贝,我们也认为那相当于原有的对象。这些存取方法应该按照其所对应的属性来命名;
  • 应该把表述参数类型的名词放在参数前面。比如-(void)getCharacters:(unichar*)buffer range:(NSRange)aRange中的range表述的参数类型;
  • 如果方法要在当前对象执行操作,那么久应该包含动词;若执行操作还需要参数,则应该在动词后面再加上一个或多个名词,比如:lowercaseString
  • 不要使用str这种简称,应该使用string这样的全称;
  • Boolean属性应该加上is前缀。如果某方法返回非属性的Boolean值,那么应该根据其功能,选用hasis为前缀;

2.5.2 类与协议的命名

应该为类与协议的名称加上前缀,以避免命名空间冲突。同时添加ViewViewControllerDelegateProtocol等这样标识类与协议的名词。

  • 方法名要言简意赅,从左到右读起来要像个日常用语中的句子才好;
  • 方法名里不要用缩略后的类型名称;
  • 给方法起名的第一要务就是要确保其风格与你自己的代码或所要集成的框架相符。

2.5.3 为私有方法名加前缀

一个类所做的事情通常都要比从外面看到的更多。编写类的实现代码时,经常要编写一些只在内部使用的方法。应该为这中方法加上某些前缀,这有助于调试,因为据此很容易就能把公共方法和私有方法区分开。

另外,前缀的存在便于修改方法名或方法签名,即这些有前缀的方法可以随时修改,而不用担心会影响面向外界的那些API。

具体使用何种前缀可根据个人喜好而定,但是最好包括下划线与字母p。p即private,下划线是为了与真正的方法名区分开。所以私有方法名会写成:

1
2
3
- (void)p_privateMethod {
/.../
}
  • 给私有方法的名称加上前缀,这样可以很容易地将其同公共方法区分开;

  • 不要单用一个下划线前缀,因为这种方法是预留给苹果公司用的。

2.6 理解Objective-C错误模型

​ Objective-C 也有异常机制,但是在默认情况下不是“异常安全的(exception safe)”。这意味着:如果抛出异常,那么本应在作用于末尾释放的对象选择却不会自动释放了。

​ 如果想生成“异常安全”的代码,可以通过设置编译标志来实现,不过这将引入一些额外的代码,在不抛出异常时,也照样要执行这部分代码。需要打开的编译器标志是:-fobjc-arc-exceptions。

​ 上面说的是ARC的情形,那么在MRC呢?

​ 即使MRC时,也很难写出抛出异常不会导致内存泄漏的代码。比如,先创建好某资源,使用完之后再将其释放,但是在释放之前如果抛出异常,这部分资源就不会被释放了。这种问题的解决方案之一,就是在抛出异常前先释放资源。这样的确能解决问题,不过万一资源过多,执行路径复杂,那么在抛出什么异常时该释放哪些资源就会杂乱无章。

​ 针对上面的问题,Objective-C的解决办法是:只在极其罕见的情况下抛出异常,异常抛出之后,无须考虑恢复问题,而且应用程序也应该退出。也就是说,无须编写复杂的“异常安全”的代码了。

​ 异常只应该应用于极其严重的错误。

​ 对于那些“不那么严重的错误(nonfatal error,非致命错误)”,Objective-C所用的编程范式:令方法返回nil/0,或是使用NSError,以表明有错误发生。

​ 比如,在init时,返回nil,意指初始化失败。

​ NSError的用法灵活,经由此对象,可以把导致错误的原因回报给调用者。

1
-(instancetype)errorWithDomain:(NSErrorDomain)domain code:(NSInteger)code userInfo:(nullable NSDictionary *)dict;
  • Error domain(错误范围,其类型为字符串)

    即错误产生的根源,通常用一个特有的全局变量来定义。系统提供了一些,参考NSError.h头文件。

  • Error code(错误码,其类型为整数)

  • User info(用户信息,其类型为字典)

​ 那么如何将错误告知调用者,即通过什么途径传递出去?

​ 第一种常见用法是通过委托协议来传递此错误。

1
- (void)URLSession:(__unused NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error

​ 另外一种常见用法是:经由方法的“输出参数”返回给调用者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (BOOL)doSomething:(NSError **)error 
{
if(/*此处是错误逻辑*/){
if(error) {
//必须先判断error,假如nil时,解引用会导致“段错误(segmentation fault)”,导致程序崩溃
*error = [NSError errorWithDomain:domain code:code userinfo:userinfo];
}
return NO;
} else {
return YES;
}
}

//调用
NSError *error = nil;
BOOL ret = [object doSomething:&error];
if(error){
//处理错误
}

下面示例了一个domain以及code的代码:

1
2
3
4
5
6
7
8
9
10
//HOErrors.h
extern NSString *const HOErrorDomain;

typedef NS_ENUM(NSUInteger, HOError) {
HOErrorUnknown = -1,
HOErrorInternalInconsistency = 100,
}

//HOErrors.m
NSString *const HOErrorDomain = @"HOErrorDomain"

2.7 理解NSCopying协议

​ 使用对象时,经常需要拷贝。在Objective-C中,此操作通过copy方法完成。如果要想令某个类支持copy,需要实现NSCopying协议,该协议只有一个方法:

1
- (id)copyWithZone:(NSZone*)zone;

​ 其中,zone在之前开发中会据此把内存分成不同的“区(zone)”,而对象创建在某个区里面。现在不用了,每个程序只有一个区:默认区(default zone)。所以,不用管zone这个参数。

​ 有时候,需要实现可变版本的拷贝,遵循NSMutableCopying协议,实现:

1
- (id)mutableCopyWithZone:(NSZone*)zone;

​ 在编写拷贝方法是,还要觉得是执行“深拷贝(deep copy)”还是“浅拷贝(shallow copy)”。深拷贝时,在拷贝对象自身是,将其底层数据也一并复制过去。Foundation框架中所有的collection类在默认情况下都执行浅拷贝,即只拷贝对象本身,而不复制其中数据。

​ 一般情况下,我们在实现拷贝时,遵循系统所使用的那种样式,即以浅拷贝实现“copyWithZone”方法。但是如果有必要的话,增加一个执行深拷贝的方法。以NSSet为例,该类实现深拷贝的方法:

1
- (id)initWithSet:(NSArray*)array copyItems:(BOOL)copyItems;

​ 若copyItems为YES,则该方法会向数组中的每个元素都发送copy消息,用拷贝好的元素创建新的set,并返回给调用者。

  • 若想令自己的所写的对象具有拷贝功能,则需实现NSCopying协议。
  • 如果自定义对象分为可变版本和不可变版本,那么就要同时实现NSCopying与NSMutableCopying。
  • 复制对象时需决定采用浅拷贝还是深拷贝,一般情况下应该尽量执行浅拷贝。
  • 如果你所写的对象需要深拷贝,那么可考虑新增一个专门执行深拷贝的方法。