Effective Objective-C 读书笔记-上

了解Objc语言的起源

Objc使用消息结构(messaging structure)而非函数调用(fucntion calling),由Smalltalk(消息型语言的鼻祖)演化而来。消息和函数调用之间的区别看上去像这样:

// Messaging (Objc)
Object *obj = [Object new];
[obj performWith:para1 and:para2];

// Fucntion calling(C++)
Object *obj = new Object;
obj->perform(para1, para2);

程序运行时执行的代码,函数调用型语言是由编译时编译器决定,消息结构型语言是由运行时运行环境决定。如果上述函数是多态,函数调用型语言要按照虚方法表(virtual table)查找最终具体执行的函数,消息结构型语言无论是否多态都会在运行时进行查找。实际上,消息结构型语言的编译器甚至不关心接受消息的对象是什么类型。


在类的头文件中尽量少引入其他头文件

@class向前声明(forward declaring)能告诉编译器有什么类型存在,而不告诉其类型的细节。当没有必要把依赖的类型细节暴露出来的时候,在头文件使用@class在实现文件使用#import,能延后引入文件的时机,减少类的使用者需要引入的头文件总数,减少编译时间。

同时,向前声明还解决了多个类型互相引用引起的循环引用(chicken-and-egg situation)问题:当解析头文件A是,编译器发现它引入了头文件B,而头文件B又回过头来引入了头文件A。使用#import而非#include虽然不会导致死循环,但无法正确编译。

有些时候头文件必需知道依赖的类型的细节,比如父类类型和协议类型。对于自定义协议类型,最好单独写在一个头文件中,防止引入不必要的额外类型。对于系统协议类型,可以在实现文件里引入并声明。

每次引入头文件时,先问问自己这样做是否有必要。处理得当的话,不仅可以缩减编译时间,还能降低依赖程度。


多用字面量语法

对于NSString,NSNumber,NSArray,NSDictionary这些类型,常见的alloc及init方法来分配并初始化对象又冗长又麻烦。而使用字面量语法(literal syntax)可以缩减代码长度,使其更为易读。

// NSString
NSString *someString = @"Effective Objective-C 2.0";

// NSNumber
NSNumber *initNumber = @1;
NSNumber *floatNumber = @2.5f;
NSNumber *doubleNumber = @3.1415926;
NSNumber *boolNumber = @YES;
NSNumber *charNumber = @'a';
int x = 5;
float y = 6.32f;
NSNumber *expressionNumber = @(x * y);

// NSArray
NSArray *animals = @[@"cat", @"dog", @"mouse", @"badger"];
NSString *dog = animals[1];
这也叫**取下标操作**(subcripting),更为简洁更为易懂。

// NSDictionary
NSDictionary *personData = @{
    @"firstName":@"Matt",
    @"lastName":@"Galloway",
    @"age":@28
};
NSString *lastName = personData[@"lastName"];

// NSMutableArray && NSMutableDictionary
NSMutableArray *mutableArray = [@[@"cat", @"dog", @"mouse", @"badger"] mutableCopy];
mutableArray[1] = @"dog";

NSMutableDictionary *mutableDictionary = [@{
    @"firstName":@"Matt",
    @"lastName":@"Galloway",
    @"age":@28
} mutableCopy];
mutableDictionary[@"lastName"] = @"Hello";

注意,用字面量语法创建数组和字典时,如果出现nil会抛出异常。数组取下标操作时,如果越界也会抛出异常。


多用类型常量,少用#define预处理命令

使用类型常量代替以前使用的#define宏,因为它有类型信息。宏在替换时由于没有类型信息,有很多潜在的意想不到的错误发生。

#define ANIMATION_DURATION 0.3

// EOCAnimatedView.h
extern NSString *const EOCStringConstant;

// EOCAnimatedView.m
NSString *const EOCStringConstant = @"VALUE";
static const NSTimeInterval kAnimationDuration = 0.3;

const修饰的变量表示该变量在程序运行期间是可读不可写的,如果代码中试图修改const修饰符声明的变量就会编译出错,因为我们不希望有人更改它的值。

static修饰的变量表示该变量尽在被定义的编译单元(即实现文件)内可见。编译器将每个编译单元编译成一个目标文件(object file),然后连接器链接各个目标文件依赖的符号。没有static的话,编译器会为它创建一个外部符号(external symbol),产生隐藏的符号冲突:

duplicate symbol _kAnimationDuration in:
    EOCAnimatedView.o
    EOCOtherView.o

所以同时使用static与const来声明,能实现我们的目标。实际上,对于此类情况,编译器根本不会为它创建符号,而是会像#define一样替换所有的变量。

extern修饰的变量表示该变量需放在全局符号表(global symbol table)中,以便在定义该变量的编译单元之外使用。也就是说编译器无需查看其定义,就允许代码使用它,因为编译器知道链接成二进制文件后肯定能找到这个变量。此类常量必需定义且只能定义一次,通常在相关的实现文件里。因为在全局符号表里,所以命名时需要谨慎。由于Objc没有名称空间(namesapce),需要考虑添加前缀使其唯一。


用枚举表示状态、选项和状态码

应该用枚举来表示状态机的状态、传递给方法的选项以及状态码等值,并起个易懂的名字。

Foudation中定义了一些辅助宏,用这些宏来定义枚举类型时,可以指定用于保存枚举值的底层数据类型确保实现。这些宏能够向后兼容(backward compatibility),如果目标平台编译器支持新标准,就使用新式语法,否则使用旧式语法:

typedef NS_ENUM(NSUInteger, EOCConnectionState) {
    EOCConnectionStateDisconnected,
    EOCConnectionStateConnecting,
    EOCConnectionStateConnected        
};
typedef NS_OPTIONS(NSUInteger, EOCPermittedDirection) {
    EOCPermittedDirectionUp     = 1 << 0,
    EOCPermittedDirectionDown     = 1 << 1,
    EOCPermittedDirectionLeft     = 1 << 2,
    EOCPermittedDirectionRight = 1 << 3,
};

NS_OPTIONS语法和NS_ENUM完全相同,但这个宏提示编译器值是如何通过位掩码|组合在一起的。


理解属性这一概念

@interface EOCPerson : NSObject {
@public
    NSString *_firstName;
    NSString *_lastName;
@private
    NSString *_someData;
}

对象布局在编译器(compile time)就已经固定了。比如所有用到_firstName变量的代码,编译器就把它替换成硬编码的偏移量(hardcode offset),表示该变量距离对象内存区域起始位置有多远。但是如果类的设计在未来有变化的话:

@interface EOCPerson : NSObject {
@public
    NSDate *_birthday;
    NSString *_firstName;
    NSString *_lastName;
@private
    NSString *_someData;
}

原来只想_firstName的偏移量现在都错误地指向了_birthday。所以,如果使用编译器计算的偏移量,那么在修改类定义后必需重新编译。Objc的做法是,把变量当作一种存储偏移量所用的特殊变量(special variable),交给类对象(class object)统一管理。偏移量在运行期动态查找,如果类的定义变了,那么存储的偏移量也就变了,甚至在运行期间也可以向类中添加变量,无论何时访问变量都是正确的偏移量。这就是稳固的应用程序二进制接口(Application Binary Interface,ABI)
。ABI定义了很多内容,其中一项就是生成代码时所应遵循的规范。有了它,我们就可以在类的分类中定义额外的变量了,不用将所有的变量公开声明在外部类定义中,以便保护私有信息。

尽量不直接访问实例变量,使用存取方法来间接访问实例变量,这不仅为数据提供了简洁的封装,而且有利于控制实力变量的影响范围。


在对象内部如何访问变量

在对象外部访问变量时,总应该通过属性完成,但是在对象内部呢?这是个一直激烈争论的问题。该书作者的建议是,内部读取时采用直接访问,内部设置时采用属性访问。

  • 由于不经过Objc的方法派发,直接访问变量的速度要比读取方法快
  • 通过属性设置变量会线程安全(atomic),会管理内存(retain和copy)
  • 通过属性设置变量会保证外部KVO正常响应(是否这么做取决于具体目的)
  • 通过属性设置变量会统一观察变量变化路径(setter中添加断点)

值得注意的是,在init中应该尽量直接访问实例变量。因为初始化时,对象的内存数据还没有完全确定,而如果通过属性访问调用设置方法(子类可能也override了设置方法),会有潜在影响对象数据的可能性。在dealloc中也尽量如此,因为如果这时还通过属性访问调用设置方法,有些依赖另外一些已经释放了变量的具体实现会有潜在出错的可能性,另外KVO也可能潜在引出别的问题。总而言之,init时对象尚未完整确定,dealloc对象理应无效,这两个状态都不应该有别的行为。

但是在某些情况下又必需通过属性访问,比如想要初始化的实例变量声明在父类中不能在子类中直接访问。另外,惰性初始化(lazy initialization)必须通过属性访问,否则其影响的实例变量永远不会初始化。


理解“对象等同性”

如果想监测对象的等同性,需要实现isEqual:和hash这两个方法。equal的对象必须有相同的hash,但是有相同hash的对象不一定equal。不过在实现hash时,需要注意运行速度和hash碰撞(collision)率。例如,使用hash实现索引的collection,可能会根据hash把对象分装到不同的数组。当向collection添加新对象时,会根据对象hash找到相关的数组,再依次检查每个元素是否与新对象相等,最后决定是否添加到collection中。

如果hash碰撞率过高,那么看起来就是,collection内部分组集中且每个分组元素很多,每次扫描需要判定的数目很多:

- (NSUInteger)hash {
    return 2016;
}

如果hash碰撞率不高,那么看起来就是,collection内部分组分散切每个分组元素很少,每次扫描需要判定的数目很少:

- (NSUInteger)hash {
    NSString *strToHash = [NSString stringWithFormat:@"%@:%@:%i", _firstName, _lastName_, _age];
    return [strToHash hash];
}

这是上面的实现速度不够快,可能产生性能问题,可以这样:

- (NSUInteger)hash {
    NSUInteger firstNameHash = [_firstName hash];
    NSUInteger lastNameHash = [_lastName_ hash];
    NSUInteger ageHash = _age;
    return firstNameHash^lastNameHash^ageHash;
}

这样技能保持较高效率,又能使生成的hash在一定范围内,不会过于频繁地重复。创建等同性判断时,是将判断整个对象还是对象的一部分可以根据需要来决定。比如NSArray先检查个数是否相同,再检查每个对应位置的对象是否相等,如果都相等则这两个数组就相等,这叫做深度等同性判断(deep equality)。又比如根据数据库里的数据创建的对象,可以只根据对象的数据库ID进行等同性判断。

添加可变对象到collection时需要注意,collection根据其hash放置,如果期间可变对象的hash变化了,那么collection可能就会错误地判定等同性。所以,应该确保可变对象hash的不变性,要么根据可变对象的不变部分计算hash,要么保证加入collecton后不再改变,这从另一个方面解释了将对象设计成不变的优点之一。


理解objc_msgSend的的作用

在objc中给对象发送消息:

id returnValue = [someObject mseeageName:parameter];

someObject叫做接收者(receiver),messageName叫做选择子(selector),选择子和所有参数组合起来称为消息(message)。编译器将其转换成一条标准的C函数调用,消息传递机制中的核心函数,叫做objc_msgSend:

id objc_msgSend(id self, SEL_ cmd, ...)
id returnValue = objc_msgSend(semeObject, @selector(messageName:), parameter);

objc_msgSend等函数一旦找到应该调用的方法实现之后,就会“跳转过去”。因为objc对象的每个方法都可以视为简单的C函数:

 <return_type> Class_selector(id self, SEL _cmd, ...)
 

其原型和objc_msgSend函数很像,这不是巧合,而是利用了尾调用优化(tail call optimization)。

如果某函数的最后一项操作仅仅是调用另一个函数,且不会将其返回值另作他用时,就可以运用尾调用优化技术。编译器会生成调转至另一函数所需的指令,而不会向调用栈中推入新的栈帧(frame stack)。这项优化对objc_msgSend非常关键,如果不这么做的话,那么每次调用objc的方法之前,都需要为objc_msgSend准备栈帧,栈踪迹(stack trace)中就会有很多没有必要的objc_msgSend栈帧出现。此外,还可以大量减少调用栈的内存消耗,尽量避免栈溢出(stack overflow)的问题发生。


尽量使用不可变对象

设计类是,应尽量把对外公布出来的属性设为只读(readonly),而且只有在确有必要的时候才讲属性对外公布。对于向修改又不像为外人所改动的内部数据,可以将对应的属性在实现文件里重新声明为可写(readwrite)。

// EOCExample.h
@interface EOCExample : NSObject
@property (nonatomic,readonly,copy) NSString *title;
@end

// EOCExample.m
@interface EOCExample ()
@property (nonatomic,readwrite,copy) NSString *title;
@end
@implementation EOCExample
@end

当然,如果该属性是nonatomic则可能产生竞态条件(race condition)。在对象内部写入是,在对象外部也许正在读取,那么就需要一些方法同步属性的读取操作。

设计类时还需要注意,如果对象内部有可变collection,不应该将其直接暴露在外面,而是将其封装在类的内部。这样所有的操作在内部可控,也可以保证线程安全。如果外部需要collection的信息和操作,则可以公开返回其不变版本,将各种操作用API封装暴露出去。

// EOCPerson.h
@interface EOCPerson : NSObject
@property (nonatomic,readonly,copy) NSString *firstName;
@property (nonatomic,readonly,copy) NSString *lastName;
@property (nonatomic,readonly,retain) NSArray *friends;
- (void)addFriend:(EOCPerson *)person;
- (void)removeFriend:(EOCPerson *)person;
@end

// EOCPerson.m
@interface EOCPerson ()
@property (nonatomic,readwrite,copy) NSString *firstName;
@property (nonatomic,readwrite,copy) NSString *lastName;
@property (nonatomic,readwrite,retain) NSMutableArray *mtFriends;
@end
@implementation EOCPerson
- (NSArray *)friends {
    return [NSArray arrayWithArray:self.mtFriends];
}
- (void)addFriend:(EOCPerson *)person {
    [self.mtFriends addObject:person];
}
- (void)removeFriend:(EOCPerson *)person {
    [self.mtFriends removeObject:person];
}
@end

为私有方法添加前缀

一个类所做的事通常都要比外面看起来的更多。实现类时,经常要写一些内部使用的私有方法。Objc和C++、Java不一样,其方法没有真正意义上的私有方法,每个方法都可以在运行时查询并运行,所以将私有方法隐藏在实现文件里是良好的习惯。为他们添加前缀,因为很容易把公开方法和私有方法区别开来有助于调试。同时,因为类的公开API已经暴露出去供外界使用,所以不能随意改动方法名,否则所有使用了API的代码都要变动。私有方法有了前缀,就很容易看出哪些方法可以随意修改,哪些不应该轻易修改。本书作者推荐**p_**:

#import<Foundation/Foundation.h>
@interface EOCExample : NSObject
- (void)publicMethod;
@end

@implementation EOCExample
- (void)publicMethod {
    ...
}
- (void)p_privateMethod {
    ...
}

Apple喜欢用**_**作为私有方法的前缀。不应该照着Apple的方法实现自己的私有方法的前缀,因为极有可能覆写(override)了Apple的私有实现从而引起莫名其妙的问题。

理解Objc的错误模型

默认情况下,ARC不是异常安全的(exception safe)。这意味着,如果抛出异常,那么本应该在作用域末尾释放的的对象将不能释放了。如果想生成异常安全的代码,通过设置编译器标示来**-fobjc-arc-exception**实现。

Objc的理解是,只有在极其罕见和严重的情况下抛出异常,异常抛出之后无须考虑恢复,而且程序此时也应该退出。对于非致命错误(nonfatal error),建议方法返回nil/0,或者使用NSError已表明其中有错误发生了。NSError的用法更加灵活,因为痛过它我们可以把出错原因回报给调用着:

  • 错误范围(Error domain)描述产生错误的根源
  • 错误码(Error code)指明具体发生了何种错误
  • 用户信息(User info)有关此错误的额外信息

设计API时可以已委托协议或者输出参数的形式来传递错误信息:

- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error;
- (BOOL)doSomething:(NSError *)error;

延伸阅读

尾调用优化


Effective Objective-C 读书笔记-上
https://hllovesgithub.github.io/2016/01/10/2016-01-10-Effective-Objective-C读书笔记-上/
作者
Hu Liang
发布于
2016年1月10日
许可协议