面试题
Category 的实现原理, 以及 Category 为什么只能加方法不能加属性.
Category 中有 load 方法吗? load 方法是什么时候调用的? load 方法能继承吗?
load,initialize 在 category 中的调用的顺序, 以及出现继承时他们之间的调用的过程.
load,initialize 的区别, 以及它们在 category 重写的时候的调用的次序.
Category 的本质
首先我们写一段简单的代码, 之后的分析都基于这段代码.
Presen 类
- // Presen.h
- #import <Foundation/Foundation.h>
- @interface Preson : NSObject
- {
- int _age;
- }
- - (void)run;
- @end
- // Presen.m
- #import "Preson.h"
- @implementation Preson
- - (void)run
- {
- NSLog(@"Person - run");
- }
- @end
Presen 扩展 1
- // Presen+Test.h
- #import "Preson.h"
- @interface Preson (Test) <NSCopying>
- - (void)test;
- + (void)abc;
- @property (assign, nonatomic) int age;
- - (void)setAge:(int)age;
- - (int)age;
- @end
- // Presen+Test.m
- #import "Preson+Test.h"
- @implementation Preson (Test)
- - (void)test
- {
- }
- + (void)abc
- {
- }
- - (void)setAge:(int)age
- {
- }
- - (int)age
- {
- return 10;
- }
- @end
Presen 分类 2
- // Preson+Test2.h
- #import "Preson.h"
- @interface Preson (Test2)
- @end
- // Preson+Test2.m
- #import "Preson+Test2.h"
- @implementation Preson (Test2)
- - (void)run
- {
- NSLog(@"Person (Test2) - run");
- }
- @end
我们之前讲到过实例对象的 isa 指针指向类对象, 类对象的 isa 指针指向元类对象, 当 p 调用 run 方法时, 类对象的 isa 指针找到类对象的 isa 指针, 然后在类对象中查找对象方法, 如果没有找到, 就通过类对象的 superclass 指针找到父类对象, 接着去寻找 run 方法.
那么当调用分类的方法时, 步骤是否和调用对象方法一样呢? 分类中的对象方法依然是存储在类对象中的, 同对象方法在同一个地方, 那么调用步骤也同调用对象方法一样. 如果是类方法的话, 也同样是存储在元类对象中. 那么分类方法是如何存储在类对象中的, 我们来通过源码看一下分类的底层结构.
分类的底层结构
如何验证上述问题? 通过查看分类的源码我们可以找到 category_t 结构体.
- struct category_t {
- const char *name;
- classref_t cls;
- struct method_list_t *instanceMethods; // 对象方法
- struct method_list_t *classMethods; // 类方法
- struct protocol_list_t *protocols; // 协议
- struct property_list_t *instanceProperties; // 属性
- // Fields below this point are not always present on disk.
- struct property_list_t *_classProperties;
- method_list_t *methodsForMeta(bool isMeta) {
- if (isMeta) return classMethods;
- else return instanceMethods;
- }
- property_list_t *propertiesForMeta(bool isMeta, struct header_info *hi);
- };
从源码基本可以看出我们平时使用 categroy 的方式, 对象方法, 类方法, 协议, 和属性都可以找到对应的存储方式. 并且我们发现分类结构体中是不存在成员变量的, 因此分类中是不允许添加成员变量的. 分类中添加的属性并不会帮助我们自动生成成员变量, 只会生成 get set 方法的声明, 需要我们自己去实现.
通过源码我们发现, 分类的方法, 协议, 属性等好像确实是存放在 categroy 结构体里面的, 那么他又是如何存储在类对象中的呢? 我们来看一下底层的内部方法探寻其中的原理. 首先我们通过命令行将 Preson+Test.m 文件转化为 c++ 文件, 查看其中的编译过程.
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc Preson+Test.m
在分类转化为 c++ 文件中可以看出_category_t 结构体中, 存放着类名, 对象方法列表, 类方法列表, 协议列表, 以及属性列表.
紧接着, 我们可以看到_method_list_t 类型的结构体, 如下图所示
上图中我们发现这个结构体 **
_OBJC_$_CATEGORY_INSTANCE_METHODS_Preson_$_Test
** 从名称可以看出是 INSTANCE_METHODS 对象方法, 并且一一对应为上面结构体内赋值. 我们可以看到结构体中存储了方法占用的内存, 方法数量, 以及方法列表. 并且从上图中找到分类中我们实现对应的对象方法, test , setAge, age 三个方法
接下来我们发现同样的_method_list_t 类型的类方法结构体, 如下图所示
同上面对象方法列表一样, 这个我们可以看出是类方法列表结构体
_OBJC_$_CATEGORY_CLASS_METHODS_Preson_$_Test
, 同对象方法结构体相同, 同样可以看到我们实现的类方法, abc.
接下来是协议方法列表
通过上述源码可以看到先将协议方法通过_method_list_t 结构体存储, 之后通过_protocol_t 结构体存储在 **
_OBJC_CATEGORY_PROTOCOLS_$_Preson_$_Test
** 中同_protocol_list_t 结构体一一对应, 分别为 protocol_count 协议数量以及存储了协议方法的_protocol_t 结构体.
最后我们可以看到属性列表
属性列表结构体 **
_OBJC_$_PROP_LIST_Preson_$_Test
** 同_prop_list_t 结构体对应, 存储属性的占用空间, 属性属性数量, 以及属性列表, 从上图中可以看到我们自己写的 age 属性.
最后我们可以看到定义了 **
_OBJC_$_CATEGORY_Preson_$_Test
** 结构体, 并且将我们上面着重分析的结构体一一赋值, 我们通过两张图片对照一下.
上下两张图一一对应, 并且我们看到定义_class_t 类型的
OBJC_CLASS_$_Preson
结构体, 最后将
_OBJC_$_CATEGORY_Preson_$_Test
的 cls 指针指向
OBJC_CLASS_$_Preson
结构体地址. 我们这里可以看出, cls 指针指向的应该是分类的主类类对象的地址.
通过以上分析我们发现. 分类源码中确实是将我们定义的对象方法, 类方法, 属性等都存放在 catagory_t 结构体中. 接下来我们在回到 runtime 源码查看 catagory_t 存储的方法, 属性, 协议等是如何存储在类对象中的.
首先来到 runtime 初始化函数
接着我们来到 &map_images 读取模块 (images 这里代表模块), 来到 map_images_nolock 函数中找到_read_images 函数, 在_read_images 函数中我们找到分类相关代码
从上述代码中我们可以知道这段代码是用来查找有没有分类的. 通过_getObjc2CategoryList 函数获取到分类列表之后, 进行遍历, 获取其中的方法, 协议, 属性等. 可以看到最终都调用了 remethodizeClass(cls); 函数. 我们来到 remethodizeClass(cls); 函数内部查看.
通过上述代码我们发现 attachCategories 函数接收了类对象 cls 和分类数组 cats, 如我们一开始写的代码所示, 一个类可以有多个分类. 之前我们说到分类信息存储在 category_t 结构体中, 那么多个分类则保存在 category_list 中.
我们来到 attachCategories 函数内部.
上述源码中可以看出, 首先根据方法列表, 属性列表, 协议列表, malloc 分配内存, 根据多少个分类以及每一块方法需要多少内存来分配相应的内存地址. 之后从分类数组里面往三个数组里面存放分类数组里面存放的分类方法, 属性以及协议放入对应 mlist,proplists,protolosts 数组中, 这三个数组放着所有分类的方法, 属性和协议. 之后通过类对象的 data() 方法, 拿到类对象的 class_rw_t 结构体 rw, 在 class 结构中我们介绍过, class_rw_t 中存放着类对象的方法, 属性和协议等数据, rw 结构体通过类对象的 data 方法获取, 所以 rw 里面存放这类对象里面的数据. 之后分别通过 rw 调用方法列表, 属性列表, 协议列表的 attachList 函数, 将所有的分类的方法, 属性, 协议列表数组传进去, 我们大致可以猜想到在 attachList 方法内部将分类和本类相应的对象方法, 属性, 和协议进行了合并.
我们来看一下 attachLists 函数内部.
上述源代码中有两个重要的数组 array()->lists: 类对象原来的方法列表, 属性列表, 协议列表. addedLists: 传入所有分类的方法列表, 属性列表, 协议列表.
attachLists 函数中最重要的两个方法为 memmove 内存移动和 memcpy 内存拷贝. 我们先来分别看一下这两个函数
- // memmove : 内存移动.
- /* __dst : 移动内存的目的地
- * __src : 被移动的内存首地址
- * __len : 被移动的内存长度
- * 将__src 的内存移动__len 块内存到__dst 中
- */
- void *memmove(void *__dst, const void *__src, size_t __len);
- // memcpy : 内存拷贝.
- /* __dst : 拷贝内存的拷贝目的地
- * __src : 被拷贝的内存首地址
- * __n : 被移动的内存长度
- * 将__src 的内存移动__n 块内存到__dst 中
- */
- void *memcpy(void *__dst, const void *__src, size_t __n);
下面我们图示经过 memmove 和 memcpy 方法过后的内存变化.
经过 memmove 方法之后, 内存变化为
- // array()->lists 原来方法, 属性, 协议列表数组
- // addedCount 分类数组长度
- // oldCount * sizeof(array()->lists[0]) 原来数组占据的空间
- memmove(array()->lists + addedCount, array()->lists,
- oldCount * sizeof(array()->lists[0]));
经过 memmove 方法之后, 我们发现, 虽然本类的方法, 属性, 协议列表会分别后移, 但是本类的对应数组的指针依然指向原始位置.
memcpy 方法之后, 内存变化
- // array()->lists 原来方法, 属性, 协议列表数组
- // addedLists 分类方法, 属性, 协议列表数组
- // addedCount * sizeof(array()->lists[0]) 原来数组占据的空间
- memcpy(array()->lists, addedLists,
- addedCount * sizeof(array()->lists[0]));
我们发现原来指针并没有改变, 至始至终指向开头的位置. 并且经过 memmove 和 memcpy 方法之后, 分类的方法, 属性, 协议列表被放在了类对象中原本存储的方法, 属性, 协议列表前面.
那么为什么要将分类方法的列表追加到本来的对象方法前面呢, 这样做的目的是为了保证分类方法优先调用, 我们知道当分类重写本类的方法时, 会覆盖本类的方法. 其实经过上面的分析我们知道本质上并不是覆盖, 而是优先调用. 本类的方法依然在内存中的. 我们可以通过打印所有类的所有方法名来查看
- - (void)printMethodNamesOfClass:(Class)cls
- {
- unsigned int count;
- // 获得方法数组
- Method *methodList = class_copyMethodList(cls, &count);
- // 存储方法名
- NSMutableString *methodNames = [NSMutableString string];
- // 遍历所有的方法
- for (int i = 0; i < count; i++) {
- // 获得方法
- Method method = methodList[i];
- // 获得方法名
- NSString *methodName = NSStringFromSelector(method_getName(method));
- // 拼接方法名
- [methodNames appendString:methodName];
- [methodNames appendString:@","];
- }
- // 释放
- free(methodList);
- // 打印方法名
- NSLog(@"%@ - %@", cls, methodNames);
- }
- - (void)viewDidLoad {
- [super viewDidLoad];
- Preson *p = [[Preson alloc] init];
- [p run];
- [self printMethodNamesOfClass:[Preson class]];
- }
通过下图中打印内容可以发现, 调用的是 Test2 中的 run 方法, 并且 Person 类中存储着两个 run 方法.
总结: 分类的实现原理是将 category 中的方法, 属性, 协议数据放在 category_t 结构体中, 然后结构体内的方法列表拷贝到类对象的方法列表中. 并且 category_t 结构体中并不存在成员变量. 通过之前对对象的分析我们知道成员变量是存放在实例对象中的, 并且编译的那一刻就已经决定好了. 而分类是在运行时才去加载的. 那么我们就无法再程序运行时将分类的成员变量中添加到实例对象的结构体中. 因此分类中不可以添加成员变量.
load 和 initialize
load 方法会在程序启动就会调用, 当装载类信息的时候就会调用. 调用顺序看一下源代码.
通过源码我们发现是优先调用类的 load 方法, 之后调用分类的 load 方法.
我们通过代码验证一下: 我们添加 Student 继承 Presen 类, 并添加 Student+Test 分类, 分别重写只 + load 方法, 其他什么都不做通过打印发现
确实是优先调用类的 load 方法之后调用分类的 load 方法, 不过调用类的 load 方法之前会保证其父类已经调用过 load 方法.
之后我们为 Preson,Student ,Student+Test 添加 initialize 方法. 我们知道当类第一次接收到消息时, 就会调用 initialize, 相当于第一次使用类的时候就会调用 initialize 方法. 调用子类的 initialize 之前, 会先保证调用父类的 initialize 方法. 如果之前已经调用过 initialize, 就不会再调用 initialize 方法了. 当分类重写 initialize 方法时会先调用分类的方法. 但是 load 方法并不会被覆盖, 首先我们来看一下 initialize 的源码.
上图中我们发现, initialize 是通过消息发送机制调用的, 消息发送机制通过 isa 指针找到对应的方法与实现, 因此先找到分类方法中的实现, 会优先调用分类方法中的实现.
我们再来看一下 load 方法的调用源码
我们看到 load 方法中直接拿到 load 方法的内存地址直接调用方法, 不在是通过消息发送机制调用.
我们可以看到分类中也是通过直接拿到 load 方法的地址进行调用. 因此正如我们之前试验的一样, 分类中重写 load 方法, 并不会优先调用分类的 load 方法, 而不调用本类中的 load 方法了.
通过上面对源码的分析, 我们可以对上面的面试题有一个清晰的认识, 面试题的答案也都在文中可以找到. 这里不在赘述了.
本文是对底层原理学习的总结, 如果有不对的地方请指正, 欢迎大家一起交流学习 xx_cc .
来源: https://juejin.im/post/5aef0a3b518825670f7bc0f3