[OC学习笔记]接口与API设计

Posted Billy Miracle

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[OC学习笔记]接口与API设计相关的知识,希望对你有一定的参考价值。

我们如果决定重用代码,那么我们在编写接口时就会将其设计成易于复用的形式。这就要用到OC语言中常见的编程范式(paradigm)。

一、用前缀避免命名冲突

OC没有其它语言那种内置的命名空间(namespace)机制。鉴于此,我们在起名时要设法避免潜在的命名冲突,否则就很容易重名了。如果发生了命名冲突(naming clash),那么应用程序的 链接过程就会出错,因为其中出现了重复符号。
SomeClass对应的类符号和“元类”符号各被定义了两次,就会发生错误。比无法链接更糟糕的情况是,在运行期载入了含有重名类的程序库。此时,“动态加载库”(dynamic loader)就遭遇了“重名符号错误”(duplicate symbol error),很可能会令整个应用程序崩溃。
避免此问题的唯一办法就是变相实现命名空间:为所有名称都加上适当前缀。但是,即便加了前缀,也难保不出现命名冲突,但是其几率会小很多。
使用Cocoa创建应用程序时一定要注意,Apple宣称其保留使用所有“两字母前缀”(two-letter prefix)的权利,所以我们自己选用的前缀应该是三个字母的。
不仅仅是类名,应用程序中的所有名称都应该加前缀。如果要为既有类新增“分类”(category),那么一定要给“分类”及“分类”中的方法加上前缀。开发者可能会忽视另外一个容易引发命名冲突的地方,那就是类的实现文件中所用的纯C函数及全局变量,这个问题必须要引起注意。在编译好的目标文件中,这些文件是要算做“顶级符号”(top-level symbol)的。
如果要用第三方库编写自己的代码,并准备将其再发布为程序库供他人开发应用程序所用,那么尤其要注意重复符号问题。若应用程序自身和其所用的程序库都引入了同名的第三方库,则后者应该加前缀以避免命名冲突。

二、提供全能初始化方法

所有对象均要初始化,在初始化时,有些对象可能无须开发者向其提供额外的信息,不过一般来说还是需要的。通常情况下,对象若不知道必要的信息,则无法完成其工作。我们把可以为对象提供必要信息以便其能完成工作的初始化方法叫做“全能初始化方法”(designated initializer)。
如果创建类的实例的方式不止一种,那么这个类就会有多个初始化方法。比如NSDate

- (id)init
- (id)initWithString:(NSString *)string
- (id)initWithTimeIntervalSinceNow:(NSTimeInterval)seconds
- (id)initWithTimeInterval:(NSTimeInterval)seconds sinceDate:(NSDate*)refDate
- (id)initWithTimeIntervalSinceReferenceDate:(NSTimeInterval)seconds
- (id)initWithTimeIntervalSince1970:(NSTimeInterval)seconds

正如该类的文档描述的那样,在上面几个初始化方法中,“initWithTimeIntervalSinceReferenceDate:”是全能初始化方法。也就是说,其余的初始化方法都要调用它。于是,只有在全能初始化方法中,才会存储内部数据。这样的话,当底层数据存储机制改变时,只需修改此方法的代码就好了,无需改动其他初始化方法。例:
编写一个表示矩形的类:

@interface Rectangle : NSObject

@property (nonatomic, assign, readonly) float width;
@property (nonatomic, assign, readonly) float height;

- (id)initWithWidth:(float)width andHeight:(float)height;

@end

注意,我们把属性设为了只读,这样一来,外界就无法设置Rectangle的属性了,可以提供以下方法:

- (id)initWithWidth:(float)width andHeight:(float)height 
    if (self = [super init]) 
        _width = width;
        _height = height;
    
    return self;

但是,如果有人使用[[Rectangle alloc] init]来创建矩形会如何呢?这是合乎规定的,会将其高度与宽度设置为0。但是我们一般希望能够使用我们来默认的值,或者是抛出异常,指明本类实例必须使用“全能初始化方法”来初始化。可以使用下面的方法来初始化:

//using default values
- (id)init 
    return [self initWithWidth:5.0f andHeight:10.0f];

//throw an exception
- (id)init 
    @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Must use initWithWidth:andHeight: instead" userInfo:nil];

如使用init抛出异常,则会这样:

Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Must use initWithWidth:andHeight: instead'

现在假定创建Square类,令其继承Rectangle

@interface Square : Rectangle
- (id)initWithDimension:(float)dimension;
@end
@implementation Square

- (id)initWithDimension:(float)dimension 
    return [super initWithWidth:dimension andHeight:dimension];


@end

全能初始化方法的调用链一定要维系。调用者可能会使用“init”或者“initWithWidth:andHeight:”来初始化Square对象。这样可能导致创建出“宽度”与“高度”不想等的正方形。于是就引出一个问题:如果子类的全能初始化方法与超类方法的名称不同,那么总应该覆写超类的全能初始化方法。在这个例子中,这样改写:

- (id)initWithWidth:(float)width andHeight:(float)height 
    float dimension = MAX(width, height);
    return [self initWithDimension:dimension];

有时我们不想覆写超类的全能初始化方法,因为那样做可能没有道理。我们也可以覆写超类的全能初始化方法并使其抛出异常:

- (id)initWithWidth:(float)width andHeight:(float)height 
    @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Must use initWithDimension: instead" userInfo:nil];

这样做看起来似乎显得突兀,不过有时却是必须的,因为那种情况下创建出来的对象,其内部数据有可能相互不一致(inconsistent internal data)。如果这么做了,那么在这个例子中,调用init方法也会抛出异常,因为init方法也得调用“initWithWidth:andHeight:”。此时可以覆写init方法。
不过,在OC程序中,只有当发生严重错误时,才应该抛出异常
有时候可能需要编写多个全能初始化方法,比如,如果某对象的实例有两种完全不同的创建方式,必须分开处理,那么就会出现这种情况。以NSCoding为例,此协议提供了“序列化机制”(serialization mechanism),对象可以依次指明其自身的编码(encode)和解码(decode)方式。NSCoding协议定义了下面这个初始化方法,遵从该协议者都应该实现此方法:

- (id)initWithCoder:(NSCoder*)decoder;

我们在实现此方法时一般不调用平常所使用的那个全能初始化方法,因为该方法要通过“解码器”(decoder)将对象数据解压缩,所以和普通的初始化方法不同。而且,如果超类也实现了NSCoding,那么还需调用超类的“initWithCoder:”方法。于是,子类中有不止一个初始化方法调用了超类的初始化方法,因此,严格地说,在这种情况下出现了两个全能初始化方法。具体到Rectangle上:

@interface Rectangle : NSObject<NSCoding>

@property (nonatomic, assign, readonly) float width;
@property (nonatomic, assign, readonly) float height;

- (id)initWithWidth:(float)width andHeight:(float)height;

@end
@implementation Rectangle

//Designated initializer
- (id)initWithWidth:(float)width andHeight:(float)height 
    if (self = [super init]) 
        _width = width;
        _height = height;
    
    return self;


//Superclass's designated initializer
- (id)init 
    return [self initWithWidth:5.0f andHeight:10.0f];


//Initializer from NSCoding
- (id)initWithCoder:(NSCoder*)decoder 
    //Call through to super's designated initializer
    if (self = [super init]) 
        _width = [decoder decodeFloatForKey:@"width"];
        _height = [decoder decodeFloatForKey:@"height"];
    
    return self;


@end

注意,NSCoding协议的初始化方法没有调用本类的全能初始化方法,而是调用了超类的相关方法。然而,若是超类也实现了NSCoding,则需要改为调用超类的“initWithCoder:”初始化方法。例如,在此情况下,Square类就要这么写:

@interface Square : Rectangle
- (id)initWithDimension:(float)dimension;
@end
@implementation Square

//Designated initializer
- (id)initWithDimension:(float)dimension 
    return [super initWithWidth:dimension andHeight:dimension];


//Superclass's designated initializer
- (id)initWithWidth:(float)width andHeight:(float)height 
    float dimension = MAX(width, height);
    return [self initWithDimension:dimension];


//Initializer from NSCoding
- (id)initWithCoder:(NSCoder *)coder 
    if (self = [super initWithCoder:coder]) 
        //Square 的特定初始化
    
    return self;


@end

每个子类的全能初始化方法都应该调用其超类的对应方法,并逐层向上

三、实现description方法

调试程序时,经常需要打印并查看对象信息。

NSLog(@"object = %@", object);

在构建需要打印到日志的字符串时,object对象会收到description消息,该方法所返回的描述信息将取代“格式字符串”(format string)里的“%@”。
对于一个数组:

NSArray *object = @[@"String", @123];
NSLog(@"object = %@", object);

输出:

object = (
    String,
    123
)

然而如果在自定义的类上这么做,那么输出的信息却会成为:

object = <FKApple: 0x100709ba0>

为了显示地址外的更多信息,我们可以覆写description方法:
相关内容在此有稍有介绍:OC处理对象
在新实现的description方法中也应该像默认的那样,打印出名字和指针地址,因为这些内容有时也会用到。
有个简单的方法,可以在description中输出很多互不相同的信息,那就是借助NSDictionary类的description方法。此方法输出信息的格式如下:


	key: value;
	foo: bar;

在自定义的description方法中,把带打印的信息放到字典里面,然后将字典对象的description方法所输出的内容包含在字符串里面返回,这样就可以实现精简的信息输出方式了。例:一个表示地点名称和坐标的类。

@interface Location : NSObject

@property (nonatomic, copy, readonly) NSString *title;
@property (nonatomic, assign, readonly) float latitude;
@property (nonatomic, assign, readonly) float longitude;

- (id)initWithTitle:(NSString*)title latitude:(float)latitude longitude:(float)longitude;

@end
@implementation Location

- (id)initWithTitle:(NSString *)title latitude:(float)latitude longitude:(float)longitude 
    if (self = [super init]) 
        _title = [title copy];
        _latitude = latitude;
        _longitude = longitude;
    
    return self;


@end

要是这个类的description方法能够打印出地名和经纬度就好了。我们可以用下面的方法编写description方法,用NSDictionary来实现此功能:

- (NSString*)description 
    return [NSString stringWithFormat:@"<%@: %p, %@>",
            [self class], self,
            @@"title":_title,
              @"latitude":@(_latitude),
              @"longitude":@(_longitude),
            ];

输出为:

loc = <Location: 0x1007090c0, 
    latitude = "55.2";
    longitude = "34.98";
    title = "Some Place";
>

NSObject协议中还有个方法要注意,那就是debugDescription。它和description的区别在于:debugDescription方法是开发者在调试器(debugger)中以控制台命令打印对象时才调用的。在NSObject类的默认实现中,此方法只是直接调用了description。这样,通过重写这两个方法就可以区分描述信息与调试器po命令打印的信息了。若想在调试时打印出更详尽的对象描述信息,应实现debugDescription方法

四、尽量使用不可变对象

设计类的时候,应充分应用属性来封装数据。而在使用属性时,则可以将其声明为“只读”(readonly)。默认情况下,属性是“既可读又可写的”(read-write),这样设计出来的类都是“可变的”(mutable)。不过,一般情况下我们要建模的数据未必需要改变。
在编程实践中,应该尽量把对外公布的属性设为只读,而且只有在确有必要时才将属性对外公布。
讲一个类做成不可变类,需要把所有属性都声明成readonly:

@interface LocationOfInterest : NSObject

@property (nonatomic, copy, readonly) NSString *identifier;
@property (nonatomic, copy, readonly) NSString *title;
@property (nonatomic, assign, readonly) float latitude;
@property (nonatomic, assign, readonly) float longitude;

- (id)initWithIdentifier:(NSString*)identifier title:(NSString*)title latitude:(float)latitude longitude:(float)longitude;
@end

虽然这些属性都没有设置方法(setter),但我们还是应该在文档里指明实现所用的内存管理语义,这样的话,以后想把它变成可读写的属性时就会简单一些
有时可能想修改封装在对象内部的数据,但是却不想令这些数据为外人所改动。这种情况下,通常做法是在对象内部重新声明为readwrite。当然,如果该属性是nonatomic的,那么这样做可能会产生“竞争条件”(read condition)。在对象内部写入某属性时,对象外的观察者也许正在读取该属性。若想避免此问题,我们可以在必要时通过“派发队列”(dispatch queue)等手段,将(包括对象内部的)所有数据存储操作都设为同步操作
将属性在对象内部重新声明为readwrite这一操作可在“class-continuation分类”中完成,在公共接口中声明的属性可于此处重新声明,属性的其他特质必须保持不变,而readonly可以扩展为readwrite。
例:

#import "LocationOfInterest.h"

@interface LocationOfInterest ()

@property (nonatomic, copy, readwrite) NSString *identifier;
@property (nonatomic, copy, readwrite) NSString *title;
@property (nonatomic, assign, readwrite) float latitude;
@property (nonatomic, assign, readwrite) float longitude;

@end

@implementation LocationOfInterest

/*...*/

@end

现在,只能于其实现代码内部设置这些属性值了。更准确的说,在对象外部,仍然能够通过“键值编码”(Key-Value Coding, KVC)技术设置这些属性值,比如:

[locationOfInterest setValue:@"abc" forKey:@"identifier"];

这样做可以改动identifier属性,因为KVC会在类里查找“setIdentifier:”方法,并借此修改此属性。不过,这样做相当于违规绕过了本类所提供的API。
此外,还要注意对象内的各种collection应该设成可变的还是不可变的。若属性在功能上要使用可变的来实现,可以提供一个readonly属性供外界使用,该属性将返回不可变的set,而此set则是内部那个可变set的一个拷贝,比如:

//  Person.h
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface Person : NSObject

@property (nonatomic, copy, readonly) NSString *name;
@property (nonatomic, strong, readonly) NSSet *friends;

- (id)initWithName:(NSString*)name;
- (void)addFriend:(Person*)person;
- (void)removeFriend:(Person*)person;

@end

NS_ASSUME_NONNULL_END

//  Person.m
#import "Person.h"

@implementation Person 
    NSMutableSet *_internalFriends;


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


- (void)addFriend:(Person *)person 
    [_internalFriends addObject:person];


- (void)removeFriend:(Person *)person 
    [_internalFriends removeObject:person];


- (id)initWithName:(NSString *)name 
    if (self = [super init]) 
        _name = name;
        _internalFriends = [[NSMutableSet alloc] init];
    
    return self;


@end

这里还要强调:不要在返回的对象上查询类型已确定其是否可变

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

类、方法以及变量的命名是OC编程的重要环节。方法与变量名使用“驼峰式大小写命名法”(camel casing)。类名也用驼峰命名法,不过其首字母大写,而且前面通常还有两三个前缀字母。按照驼峰命名法写出来的代码更容易为其他OC开发者所接受。

方法命名

 - (id)initWithSize:(float)width :(float)height;
 - (id)initWithWidth:(float)width andHeight:(float)height;

观察上下两个方法名,下面的命名方式要好得多。把方法名起的稍微长一点,可以保证其能准确传达出方法所执行的任务。然而方法名也不能长的太过分了,应该尽量言简意赅
给方法命名的注意事项可以总结成下面几条规则:

  • 如果方法的返回值是新创建的,那么方法名的首个词应是返回值的类型,除非前面还有修饰语,如localizedString。属性的存取方法不遵循这种命名方式,因为一般认为这些方法不会创建新对象,即使有时返回内部对象的一份拷贝,我们也认为那相当于原有的对象。这些存取方法应该按照其对应的属性来命名。
  • 应该把表示参数类型的名词放在参数前面。
  • 如果方法要在当前对象上执行操作,那么就应该包含动词;若执行操作时还需要参数,则应该在动词后面加上一个或者多个名词。
  • 不要使用str这种简称,应该用string这样的全称。
  • Boolen属性应该加is前缀。如果某方法返回非属性的Boolen值,那么应该根据其功能,选用has或者is当前缀。
  • 将get这个前缀留给那些借由“输出参数”来保存返回值的方法。

类与协议的命名

应该为类与协议的名称加上前缀,以避免命名空间冲突,而且应该像给方法起名那样把语句组织好,使其从左至右读起来比较通顺。最重要的一点就是,命名方式应该协调一致,而且如果要从其他框架中继承子类,那么务必遵循其命名惯例。

六、为私有方法名加前缀

一个类所做的事情通常要比从外面看到的多。编写类的代码时,经常要写一些只在内部使用的方法。我们应该为这种方法的名称加上某些前缀,这有助于调试,因为可以很容易把公共方法和私有方法区别开。
为私有方法名加前缀还有个原因,就是便于修改方法名或者方法签名。对于公共方法来说,修改其代码或签名前要三思,因为类的API不便随意改动。对内部方法来说,修改其名称或者签名,不会影响到面向外界的API。用前缀把私有方法标起来,就可以很容易能看出哪些方法可以随意修改,哪些不应轻易改动。
具体使用何种前缀可根据个人喜好,其中最好包含下划线与字母pp表示“private”(私有的),而下划线可以把它和真正的方法名区隔开。下划线后面的部分按照常用的驼峰命名法来命名即可。例如:

@interface SomeObject : NSObject
- (void)publicMethod;
@end
@implementation SomeObject
- (void)publicMethod 
	/*...*/

- (void)p_privateMethod 
	/*...*/

与公共方法不同,私有方法不出现在接口定义中,私有方法一般只在实现的时候声明。
OC没有办法将方法标为私有,如前面提到的,每个对象都可以响应任何消息,而且可以在运行期检视某个对象所能直接相应的的消息。根据给定的消息查出其对应的方法,这一工作要在运行期才能完成。
苹果公司喜欢单用一个下划线作为私有方法的前缀,鉴于此,苹果公司在文档里说,开发者不应该单用一个下划线做前缀

七、理解Objective-C错误模型

当前很多编程语言都有“异常”(exception)机制,OC也不例外。
首先要注意的是,“自动引用计数”(Automatic Reference Counting, ARC)在默认情况下不是“异常安全的”(exception safe)。具体来说,这意味着:如果抛出异常,那么本应在作用域末尾释放的对象现在不会自动释放了。如果想生成“异常安全”的代码,可以通过设置编译器的标志来实现,不过这将引入一些额外代码,在不抛出异常时,也照样要执行这部分代码。需要打开的编译器标志叫做-fobjc-arc-exceptions
即使不用ARC,也很难写出在抛出异常时不会导致内存泄露的代码。比如说,设有段代码先创建好了某个资源,使用完之后再将其释放。可是在释放资源之前如果抛出异常了,那么该资源就不会被释放了:

id someResource = /*...*/;
if (/*check for error*/) 
	@throw [NSException exceptionWithName:@"ExceptionName" reason:@"There was an error" userInfo:nil];

[someResource doSomething];
[someResource release];

在抛出异常前先释放someResource,这样做当然能解决此问题,不过要是待释放的资源有很多,而且代码的执行路径更为复杂的话,那么释放资源的代码就容易写的很乱。此外,代码中加入了新的资源之后,开发者经常会忘记在抛出异常前先把它释放掉。
OC语言现在采用的方法是:只在极其罕见的情况下抛出异常,异常抛出之后,无须考虑恢复问题,而且应用程序此时也应该退出。也就是说,不用再编写复杂的“异常安全”的代码了。
异常只应该用于极其严重的错误。比如有人直接使用了一个抽象基类,那么可以考虑抛出异常。OC没有办法将某个类标识为“抽象类”。想达成类似的效果,最好的办法是在那些子类必须覆写的超类方法里抛出异常。这样的话,只要有人直接创建抽象基类的实例并使用它,即会抛出异常:

- (void)mustOverrideMethod 
	NSString *reason = [NSString stringWithFormat:@"%@ must be overriden", NSStringFromSelector(_cmd)];
	@throw [NSException exceptionWithName:NSInternalInconsistencyException reason:reason userInfo:nil];

异常只用来处理严重错误(fatal error,致命错误);对于“不那么严重的错误”(nonfatal error,非致命错误),OC语言所采用的编程范式为:令方法返回nil/0,或是使用NSError,以表明其中有错误发生。如:

 - (id)initWithValue:(id)value 
	if (self = [super init]) 
		if (/*Value means instance can't be created*/) 
			self = nil;
		 else 
			//Initialize instance
		
	
	return self;

这种情况下,如果if语句发现无法用传入的参数值来初始化当前实例,那么就把self设置成nil,这样的话,整个方法的返回值也就是nil了。调用者发现初始化方法并没有2把实例创建好,于是便可以知道其中发生了错误。
NSError的用法更加灵活,因为经由此对象,我们可以把导致错误的原因回报给调用者。NSError对象里封装了三条消息: