Effective Objective-C 读书笔记(二) | 初步理解OC运行时

Class 类型对象

OC本身是一种强类型语言,但其运行时功能让它又有了动态语言的特点。OC中对象的类型和对象所执行的方法都是在运行时阶段进行查找并确认的,这种机制被称为动态绑定。想要弄清楚运行时如何能够实现动态绑定机制,首先要了解OC中对象的本质。

OC是C语言的超集,所以OC中面向对象的功能在底层也是使用C语言来实现。我们在OC中使用的对象,通常指的是储存该对象内存地址的一个指针变量(Java中称为引用),因此我们在OC中声明对象时通常使用类型名称加一个*号,稍微了解C语言的人都知道*号代表该变量是一个指针变量。OC中还有一个特殊的类型id,它可以表示通用类型的OC对象,因为它本身就被定义为一种特殊的指针变量,所以不需要在id后面再加一个*号。

NSString *someString = @"Some String";
id otherString = @"Other String";

[someString count]; // 编译期报错
[otherString count]; // 运行时报错

使用上述两种方式声明对象,在语法意义上其实完全相同,因为对象的具体类型只在运行时才会被确认。唯一的区别在于,如果声明时使用了具体类型信息,编译器会在编译期间查找对象所能执行的方法,找不到就会报错;而id代表通用类型的对象,编译器默认它能够执行任何已存在的方法。

我们可以在苹果官方的运行时库的头文件中查看id类型的定义:

struct objc_object {
    Class isa;
};
typedef struct objc_object *id;

可以看出id本质是一个C语言结构体,该结构体只有一个Class类型的成员isa(取意is a,是一个),代表着对象所属的具体类型。其实在NSObject类的头文件中,同样声明有一个这样的实例变量isa。因此,可以说OC中任何对象,都会默认带有一个实例变量isa用来储存对象的具体类型信息。

Class的定义也可以在运行时库的头文件中查看:

struct objc_class {
    Class isa;
    Class super_class;
    const char *name;
    long version;
    long info;
    long instance_size;
    struct objc_ivar_list *ivars;
    struct objc_method_list **methodLists;
    struct objc_cache *cache;
    struct objc_protocol_list *protocols;
};
typedef struct objc_class *Class;

此结构体可以储存类的诸多信息,例如类型名、父类类型、实例变量列表、方法列表等,这些信息被称作类的元数据(metadata)。该结构体也有一个Class类型的成员isa,说明Class本身也是一个OC对象(被称为类对象或类型对象),而它的对象类型(isa所指向的类型)被称为元类(metaclass),元类中储存的是类对象的元数据,比如类方法就储存在这里。每个类可以有无数个对象,但仅有一个类对象,也仅有一个与之对应的元类。

对象、类对象和元类的关系如下图所示:

由于类对象和isa指针的存在,OC中的所有对象都可以在运行时查找自己的真实类型,并确定自己所能执行的方法。当真正给对象发送一条消息(或称为调用方法)时,运行时机制会对该消息进行一系列复杂的处理,接下来我们就继续讨论运行时的消息处理。

Message Dispatch 消息派发

调用对象的某个方法(或称为给对象发送某个消息)是面向对象编程中最常使用的功能。在OC中,由于动态绑定机制使得程序直到运行时才能清楚那个方法需要被执行,甚至通过使用底层的运行时函数,就可以更改调用的方法或改变方法内部的功能实现,这些特性使得OC成为一门真正的动态语言。

id returnValue = [someObject messageName:param];

OC的消息处理,在底层也是使用C语言函数来实现,与消息处理功能相对应的函数叫做objc_msgSend,该函数在头文件中的声明如下:

id objc_msgSend(id self, SEL op, ...)

可以看出objc_msgSend是一个可变参数函数,其中第一个参数代表消息的接收者,第二个参数代表消息的选择器,后续参数表示消息发送时附带的参数。编译器在编译期间就会将发送消息的代码转换为objc_msgSend函数。

id returnValue = objc_msgSend(someObject, @selector(messageName:), param);

在运行时阶段,objc_msgSend函数内部会根据消息的接收者和选择器来选择调用适当的方法。为完成此操作,objc_msgSend函数首先会根据消息接收者对象的isa指针找到它的真实类型,然后在该类对象的方法列表中查找是否有与当前选择器相对应的方法,如果有则跳转到该方法执行;如果没有找到,则会按照类的继承体系向上继续查找,一旦找到就跳转过去执行目标方法。当最终都没有找到与当前选择器相对应的方法时,运行时机制则会开启消息转发流程,我们接下来就继续讨论运行时的消息转发。

Message Forward 消息转发

消息转发流程比较复杂,主要分三个步骤,首先我们来看一张消息转发完整的流程图。

第一步

当消息派发流程最终在对象的类和父类中都没有找到对应选择器的方法时,就会开启消息转发流程。首先,第一步会先调用消息接收者所在类的resolveInstanceMethod:方法,该方法返回一个BOOL值,表示是否动态添加一个方法来响应当前消息选择器。如果发送的消息是一个类方法,则会调用另一个类似的方法resolveClassMethod:

+ (BOOL)resolveInstanceMethod:(SEL)sel;
+ (BOOL)resolveClassMethod:(SEL)sel;

以上两个方法均声明在NSObject类中,如果消息接收者所在类重写了resolveInstanceMethod:方法并返回YES,也就意味着想要动态添加一个方法来响应当前的消息选择器,可以在重写的方法内使用class_addMethod函数来为当前类添加方法。

BOOL class_addMethod(Class cls, SEL name, IMP imp, const char *types)

该函数第一个参数代表为哪个类添加方法,第二个参数是方法所对应的选择器,第三个参数是C语言的函数指针,用来指向待添加的方法,最后一个参数表示待添加方法的类型编码(详情可查看苹果官方文档:Objective-C Runtime Programming Guide)。

第二步

如果上一步过程中,并没有新方法能响应消息选择器,则会进入消息转发流程的第二步。在第二步中系统会调用当前消息接收者所在类的forwardingTargetForSelector:方法,用以询问能否将该条消息发送给其他接收者来处理,方法的返回值就代表这个新的接收者,如果不允许将消息转发给其他接收者则返回nil

- (id)forwardingTargetForSelector:(SEL)aSelector;

利用这个方法,我们可以使用组合的方式模拟出多重继承的特性。比如可以在一个类中拥有一系列其他类型的属性,然后重写forwardingTargetForSelector:方法,根据这些属性所能响应的消息选择器返回对应的属性对象,这样在外界看起来,该类的对象就好像是能够处理多种不同类型的方法了。

第三步

如果forwardingTargetForSelector:方法的返回值为nil,那么消息转发机制还要继续进行最后一步。在这一步中,系统会将尚未处理的消息包装成一个NSInvocation对象,其内部包含与该消息相关的所有信息,比如消息的选择器、目标接收者、参数等。之后系统会调用消息接收者所在类的forwardInvocation:方法,并将生成的NSInvocation对象作为参数传入。

- (void)forwardInvocation:(NSInvocation *)anInvocation;

forwardInvocation:方法同样声明在NSObject类中,我们可以重写该方法的实现。比如将NSInvocation对象的target属性设置为其他接收者,此操作可以实现与上一步操作同样的效果,但明显在效率上没有第二步的操作高,所以很少有人在这一步中仅仅只是改变消息的接收者。NSInvocation类中还提供了许多属性和方法用于修改其对应方法的信息,比如可以修改方法的参数和返回值,或者直接更改消息选择器转而调用其他方法。

如果消息接收者在这一步中仍然无法响应消息选择器,那么系统会自动调用doesNotRecognizeSelector:方法,该方法默认实现为抛出异常,也就是我们在开发中经常见到的unrecognized selector sent to instance

-[ViewController count]: unrecognized selector sent to instance

消息转发示例

现在再回头看我们之前消息转发完整的流程图,应该能够更清晰地了解系统执行每一步操作的目的和作用了。接下来我们用一个示例来演示如何利用消息转发机制来自定义一个字典类,该字典类的对象可以直接使用属性方式来存取内容。完整的示例代码如下。

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

@interface WXGAutoDictionary : NSObject

// 可供存储的属性,可以为任意OC对象
@property (nonatomic, strong) id obj;

@end

// WXGAutoDictionary.m
#import "WXGAutoDictionary.h"
#import <objc/runtime.h>

@interface WXGAutoDictionary ()

@property (nonatomic, strong) NSMutableDictionary *backStore; // 后台存储用字典

@end

@implementation WXGAutoDictionary

@dynamic obj; // 禁止编译器自动生成getter和setter方法

- (instancetype)init {
    if (self = [super init]) {
        _backStore = @{}.mutableCopy; // 初始化字典
    }
    return self;
}

// 重写此方法,允许动态添加方法来响应指定的消息选择器
+ (BOOL)resolveInstanceMethod:(SEL)sel {
    NSString *selString = NSStringFromSelector(sel);
    
    // 类型编码:v->void  @->OC对象  :->SEL选择器
    // 响应setter方法的选择器
    if ([selString hasPrefix:@"set"]) {
        class_addMethod(self, sel, (IMP)autoDictionarySetter, "v@:@");
    } else { // 响应getter方法的选择器
        class_addMethod(self, sel, (IMP)autoDictionaryGetter, "@@:");
    }
    
    return YES;
}

// 处理setter方法的函数
void autoDictionarySetter(id self, SEL sel, id value) {
    WXGAutoDictionary *autoDict = (WXGAutoDictionary *)self;
    NSMutableDictionary *backStore = autoDict.backStore;
    
    NSString *selString = NSStringFromSelector(sel);
    NSMutableString *key = selString.mutableCopy;
    [key deleteCharactersInRange:NSMakeRange(key.length - 1, 1)];
    [key deleteCharactersInRange:NSMakeRange(0, 3)];
    [key replaceCharactersInRange:NSMakeRange(0, 1) withString:[[key substringToIndex:1] lowercaseString]];
    
    if (value) {
        [backStore setObject:value forKey:key];
    } else {
        [backStore removeObjectForKey:key];
    }
}

// 处理getter方法的函数
id autoDictionaryGetter(id self, SEL sel) {
    WXGAutoDictionary *autoDict = (WXGAutoDictionary *)self;
    NSMutableDictionary *backStore = autoDict.backStore;
    
    NSString *key = NSStringFromSelector(sel);
    return [backStore objectForKey:key];
}

@end

在外部使用该类非常简单,示例代码如下。

//  main.m
#import <Foundation/Foundation.h>
#import "WXGAutoDictionary.h"

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        WXGAutoDictionary *dict = [[WXGAutoDictionary alloc] init];
        dict.obj = [NSDate date];
        
        NSLog(@"%@", dict.obj); // 控制台输出当前日期
    }
    return 0;
}

在程序开始运行后,dict对象所在的类中并没有响应setter和getter选择器的方法,消息派发阶段无法在类对象的方法列表中找到合适的方法,所以会进入消息转发流程。我们在resolveInstanceMethod:方法中返回YES,并为不同选择器指定了不同的方法去处理,从而实现通过属性的setter和getter方法对字典进行存取操作。当有另一个类型的属性需要使用同样的功能时,只需在WXGAutoDictionary类中添加属性,并将属性声明为@dynamic即可,属性的存取操作会由运行时系统动态指定方法来完成。

Method Swizzing 方法调配

我们已经了解了OC中对象的类型和消息处理机制,这些有助于我们进一步了解OC运行时的其他功能和特性。接下来就介绍其中一种叫做Method Swizzing(方法调配)的技术,该技术经常被称为iOS开发中的黑魔法。

在介绍方法调配技术之前,我们首先来了解一下OC中方法和消息选择器之间的关系,因为我们经常会将他们混为一谈。在运行时头文件中,我们可以找到方法的底层结构定义。

struct objc_method {
    SEL method_name;
    char *method_types;
    IMP method_imp;
}

可以看出,每一个方法内部都包含三个成员,第一个是选择器代表方法的名字,第二个是方法的类型,其值是一个C语言字符串,可以参考前文讲过类型编码,最后一个是C语言中的函数指针,用以指向方法具体执行的函数。我们可以把方法的内部结构理解为每一个SEL选择器(可以当做是方法名)对应一个具体的IMP函数(可以当做是方法的实现),这也是SEL被称为选择器的原因。这样我们就可以更加清楚地理解消息派发时,系统是如何根据消息选择器来查找对应的方法并跳转到方法的具体实现的了。

首先,当对象接收到某个消息时,编译器首先将代码转换为objc_msgSend函数,并将消息的接收者和选择器当做函数的参数传入,接下来系统会根据接收者的isa指针找到它所对应的类,在类的元数据信息中找到该类所拥有的方法列表,然后遍历方法列表,将每一个方法内部的SEL选择器同传入的消息选择器进行匹配,当找到相同的选择器后,就根据方法内部的IMP函数指针跳转到方法的具体实现。当然,为了提高方法多次执行的效率,系统会将遍历查询的结果缓存起来,储存在类的元数据信息中,此处就不再继续深入讨论。

了解清楚选择器和方法实现之间的一对一关系后,我们接下来开始介绍方法调配技术,它其实就是利用运行时提供的函数来动态修改选择器和方法实现之间的对应关系的一种技术。利用这种技术,我们可以在运行时为某个类添加选择器或更改选择器所对应的方法实现,甚至可以更换两个已有选择器所对应的方法实现,从而实现一种极其诡异的效果。下面就写一段示例程序,通过方法调配技术来更换NSString类的大小写转换方法的实现(仅供娱乐使用)。

//  main.m
#import <Foundation/Foundation.h>
#import <objc/runtime.h>

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Method lowercase = class_getInstanceMethod([NSString class], @selector(lowercaseString));
        Method uppercase = class_getInstanceMethod([NSString class], @selector(uppercaseString));
        
        method_exchangeImplementations(lowercase, uppercase);
        
        NSLog(@"%@ -- %@", [@"AbCd" lowercaseString], [@"AbCd" uppercaseString]);
        // 输出结果:ABCD -- abcd
    }
    return 0;
}

可以看到lowercaseString方法返回的是大写字母,而uppercaseString方法返回了小写字母。

方法调配技术的作用肯定不在于此,那么开发者通常如何使用这种技术呢?在总结方法调配技术的用处之前,我们先再来看一个示例程序。同样以NSString类为例,我们为其lowercaseString方法增加一些日志输出功能(不改变方法名,只是更改方法的实现)。你可能第一时间想到用继承来实现该需求,然而当项目中有多个类需要同样需求时,你需要每个类都去继承一下,然后还要保证别人都是去用你的子类而不是原本的父类,这样显然并不是一种很好的解决办法。此时我们就可以尝试使用方法调配技术,完整的示例代码如下。

//  NSString+Logging.h
#import <Foundation/Foundation.h>

@interface NSString (Logging)

- (NSString *)lowercaseStringWithLogging;

@end

//  NSString+Logging.m
#import "NSString+Logging.h"

@implementation NSString (Logging)

- (NSString *)lowercaseStringWithLogging {
    NSString *lowercaseString = [self lowercaseStringWithLogging];
    NSLog(@"%@ -> %@", self, lowercaseString);
    return lowercaseString;
}

//  main.m
#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#import "NSString+Logging.h"

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Method lowercase = class_getInstanceMethod([NSString class], @selector(lowercaseString));
        Method lowercaselogging = class_getInstanceMethod([NSString class], @selector(lowercaseStringWithLogging));
        
        method_exchangeImplementations(lowercase, lowercaselogging);
        
        [@"AbCd" lowercaseString];
        // 输出结果:AbCd -> abcd
    }
    return 0;
}
@end

我们为NSString类添加一个分类,在分类中添加一个带日志输出功能的方法,注意在该方法的实现中,我们调用了这句代码[self lowercaseStringWithLogging],这看上去应该会使程序陷入死循环,但不要忘了,我们在main方法中利用方法调配技术来交换原有类的方法和分类方法的实现,所以这句代码实际上执行的是原本的类中的实现,并不会造成死循环。

通过上文的示例程序,我们可以为那些完全不知道具体实现的方法(也称为黑盒方法)增加日志输出功能,这常用于程序的调试。实际上,还有很多与此类似的需求,既要增加功能,又需要与原有方法联系很紧密,例如增加权限验证和缓存功能,这类需求常被人们称为Aspect(切面),与之对应的编程概念叫做Aspect Oriented Programming(面向切面编程)。面向切面编程的概念有许多优点,它将那些琐碎的事物从主逻辑中分离出来,并将它们附加在与主逻辑相对应的横向切面中连带执行,是对面向对象编程的一种补充。在OC中,我们可以利用运行时特性和方法调配技术来实现这类面向切面编程的需求。

写在最后

本文主要是自己整理的读书笔记,并重新整理归纳,内容只是OC运行时的一部分,只为理顺OC运行时的基本概念,从而为理解其他运行时特性打下基础。比如OC中经常使用的KVC和KVO,在理解本文这些运行时基本概念后,应该更有助于理解它们的实现原理,感兴趣的可以参考以下文章:

还有OC运行时中的Associated Object(关联对象)概念,可以参考以下文章:

其他参考阅读:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容