IOS 底层面试题

321 阅读10分钟

前言

前面都是各种枯燥乏味的底层,今天整点轻松的探究下经典的面试题

面试题

【1】loadinitialize顺序

load 方法调用

在类的加载中已经探究了load方法的调用顺序,现在做一个总结,在探究load方法注意点 类的load方法和分类的load方法

  • load方法调用是是通过load_images调用。过程中底层有两张表,一张是类的load方法表,一张是分类的load方法。类的load方法先于分类的load方法
  • 如果类之间不存在继承关系,那么优先加载的类,其load方法先调用。即按照编译顺序进行进行调用
  • 如果类之间存在继承关系,那么在load方法调用时,底层会先去查找父类的load方法的递归,然后将其添加到类的load方法表中。即父类的load方法先于类中的load方法调用
  • 分类之间的load方法调用是按照编译的顺序进行调用
  • load方法的调用顺序:父类 > > 分类 这个顺序是大体的排序,细节可以按照上面的顺序继续细分

initialize方法调用

initialize方法是第一次消息发送的时候调用,也是系统底层主动调用,在消息慢速查找是时进行调用

  • 显而易见initialize是在load方法之后调用的
  • initialize其实和普通的方法一样,如果主类和分类中的有相同的普通方法只会调用分类的方法
  • 如果类之间存在继承关系,在initialize方法调用底层处理中也是先递归调用父类的initialize方法
  • 如果父类实现了initialize方法,类没有实现,那么回去调用父类的initialize方法
  • initialize调用顺序:父类 > 分类 或者 父类 >

【2】Runtime是什么

  • runtime是由CC++汇编实现的一套API,为OC添加了面前对象,以及运行时的功能
  • 运行时是将数据类型的确定由编译时推迟到了运行时,比如分类
  • 平时编写的OC代码,在程序运行时的过程中,其实最终会转换成runtimeC语言代码,runtimeObject—C的幕后工作者

【3】方法的本质,sel是什么?IMP是什么?两者之间的关系是什么?

  1. 方法的本质:发送消息,消息会有以下几个流程

    • 快速查找流程(objc_msgSend) ~ cache_t缓存查找
    • 慢速查找递归自己和父类 ~ lookUpImpOrForward
    • 查到不到消息:动态方法决议~ resolveInstanceMethod
    • 消息快速转发 ~ forwardingTargetForSelector
    • 消息慢速转发 ~ methodSignatureForSelector & forwardInvocation
  2. sel是方法编号在read_images期间就编译进入了内存,sel也是一个结构体指针类型

  3. imp就是函数实现指针,找imp过程就是找函数的过程

  4. sel相当于书本的目录,imp就是书本的页码

  5. 查找具体的函数就是想看这本书⾥⾯具体篇章的内容

    • 我们⾸先知道想看什么 ~ tittle (sel)
    • 根据⽬录对应的⻚码 (imp
    • 通过页码定位具体的内容,方法实现

【4】能否像编译后的内存中添加实例变量?能否向运行时创建的类中添加实例变量

  • 不能像编译后的类中添加实例变量
  • 主要类没有注册到内存还是可以添加属性和方法 原因:编译好的实例变量存储在ro,一旦编译完成,内存结构就完全确定,无法修改

动态创建类和添加变量和方法

int main(int argc, const char * argv[]) {
    @autoreleasepool {   
        const char * className = "newClass";
        Class newClass = objc_getClass(className);
        //动态创建类
        objc_allocateClassPair(class_getSuperclass(newClass), className, 0);
        //添加变量和方法
        class_addIvar(newClass, "helloWord", sizeof(NSString *), 
        log2(_Alignof(NSString *)), @encode(NSString *));
        class_addMethod(newClass, @selector(sayHello), (IMP)sayHello, "v@:");
    }
    return 0;
}

【5】[self class][super class]的区别以及原理分析

案例分析测试:创建LWSubPerson类继承LWPerson代码如下

@implementation LWSubPerson
- (instancetype)init{
    self = [super init];
    if (self) {
        NSLog(@"--%@ -- %@--",[self class],[super class]);
    }
    return self;
}
@end


int main(int argc, const char * argv[]) {
    @autoreleasepool {
        //调用init方法
        LWSubPerson * subPerson =  [[LWSubPerson alloc] init];
    }
    return 0;
}
2021-07-31 19:34:54.186162+0800 KCObjcBuild[7226:272861] --LWSubPerson -- LWSubPerson--

打印结果两个都是LWSubPerson[self class]的结果是LWSubPerson没有任何问题,[super class]为什么返回的也是LWSubPerson。很奇怪,下面来探究下class方法的源码

- (Class)class {
    return object_getClass(self);
}

Class object_getClass(id obj)
{
    if (obj) return obj->getIsa();
    else return Nil;
}

源码显示 object_getClass(self)中的self- (Class)class 方法的隐藏参数中的一个,这两个隐藏参数是id selfSEL _cmd。那么这个隐藏参数的self是哪里传过来的呢?方法的本质是消息发送通过objc_msgSend,在objc_msgSend方法中有两个参数一个是消息的接收者一个是sel,现在很清楚了[self class]的消息接收者是self也就是LWSubPerson类的实例化对象所以object_getClass(self)返回的就是LWSubPerson

现在只要搞清楚[super class]这个消息的接收者是谁那么这个问题就迎刃而解了。通过clang把LWSubPerson.m文件生成LWSubPerson.cpp文件

image.png

[super class]方法是通过objc_msgSendSuper进行消息发,参数的类型是__rw_objc_super类型和还有一个就是SEL。其实这里可以简单理解super的作用其实就是向父类发送消息。那么super可以说是一个关键字也可以说是标识符。在cpp文件中全局搜索__rw_objc_super

image.png __rw_objc_super是个结构体类型,里面有两个变量objectsuperClass,此时在结合objc_msgSendSuper方法的参数,在objc源码中全局搜索objc_msgSendSuper

objc_msgSendSuper(void /* struct objc_super *super, SEL op, ... */ )
    OBJC_AVAILABLE(10.0, 2.0, 9.0, 1.0, 2.0);

 
struct objc_super {
    /// Specifies an instance of a class.
    __unsafe_unretained _Nonnull id receiver;//消息接收者

    /// Specifies the particular superclass of the instance to message. 
#if !defined(__cplusplus)  &&  !__OBJC2__
    /* For compatibility with old objc-runtime.h header */
    __unsafe_unretained _Nonnull Class class;
#else
    __unsafe_unretained _Nonnull Class super_class; //父类
#endif
    /* super_class is the first class to search */
};

objc_super结构体类型对应cpp文件中的__rw_objc_super其实消息的接收者还是receiver就是self所以[super class]返回的就是LWSubPerson类,objc_msgSendSuper的作用就是去父类开始查找方法仅此而已,并不是返回父类。这就是为什么重写init方法,需要[super init]而不是[self init][self init]就会递归死循环 有点人好奇可以不写[super init]答案是可以,但是如果不写就不能继承父类的属性和方法,父类的一些自带的就不能用所以最好写[super init]

下面通过汇编在验证下 image.png

明明cpp文件是objc_msgSendSuper,汇编是调用的objc_msgSendSuper2这是什么原因。全局搜索objc_msgSendSuper

image.png

汇编中发现其实objc_msgSendSuper汇编也是调用了objc_msgSendSuper2的核心实现,而如果直接调用objc_msgSendSuper2方法会发现objc_msgSendSuper2的参数super_class其实是class,只不过底层自己调用了superClass,全局搜索objc_msgSendSuper2

image.png

objc_msgSendSuper2注释中发现里面的super_class是传的class而不是父类。实际上objc_msgSendSuperobjc_msgSendSuper2结果都是一样就是从父类开始找,这样的好处就是可以防止递归,还有就是明确告诉这个方法是我父类的,你不用在从你本类开始查找减少一步查找流程更快捷

【6】内存平移案例分析

内存平移问题,在我看来就是理解的问题,如果理解了其实就很简单。现在直接上案列

//ViewController类的实现
@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
   
    LWPerson * p = [LWPerson alloc];
    p.name = @"helloWord";
    [p sayHello];

    Class cls = [LWPerson class];
    void * lw_p = &cls;
    
    [(__bridge id)lw_p sayHello];
}
@end

//LWPerson类的实现
@implementation LWPerson

-(void)sayHello{
    NSLog(@"--%s--%@--",__func__,self.name);
}
@end

代码中出现两个问题

  • [p sayHello]这个方法可以调用,大家可以理解。但是[(__bridge id)lw_p sayHello]可以调用原理是什么?
  • [(__bridge id)lw_p sayHello]调用以后打印的结果是什么?

[(__bridge id)lw_p sayHello]调用

探究[(__bridge id)lw_p sayHello]可以调用,首先要搞清楚&cls的含义是什么。我们知道sayHello是普通的实例方法,一般情况下是对象调用,所以&cls应该也是一个类似对象的类型。其中p的类型是LWPerson *类型,那么&cls应该也是LWPerson *类型才可以,不然没法调用sayHello方法,下面进行源码调试下

image.png

图中的结果显示:&clsClass *类型,而在代码中的Class就是LWPerson,所以Class *类型就是LWPerson *。其实理解起来也很简单cls里面是LWperson类,现在找到一个指针变量指向cls,怎么指向cls呢?那么指针变量里面必须存储的是cls地址即&cls,而这个指针变量就是代码中的lw_p。而lw_pp的区别,p开辟了一块内存用来存储数据,而lw_p只是一个指针

类中消息查找图

image.png

图中可以看出实例对象是通过isa去找到,然后在类中进行方法查找,而lw_p也是一个指针,这个指针指向了cls,而cls里面存放的是LWPerson类的地址,到最后都找打了LWPerson

[(__bridge id)lw_p sayHello]打印结果

image.png

图中显示[p sayHello]的调用结果就是name的真实值,而[(__bridge id)lw_p sayHello]的调用结果是一个指针地址,我们知道实例对象中的变量的值都是存储在实力对象的内存中正常情况下是通过内存偏移来获取对应的值

image.png

变量的值是按照编译的先后顺序进行存储,name的地址就是实例对象的首地址+0x8。那么 也会按照相同方式去获取对应地址里面的值

image.png

因为lw_p是没有开辟内存的,所以它会首先找到指针cls,获取cls的地址然后加上0x8首地址+0x8,然后就找到了p。在栈空间中地址是高地址到低地址即先进后出,先进入栈中的地址是高于后进入栈中的地址,所以最后打印的是指针p。其实现在回头看内存平移很简单,只要弄清楚原理,一切都不是问题

只要大家搞清楚堆栈进入的顺序,那么这道面试题就很简单,现在补充两个类型的压栈顺序

结构体压栈顺序

压栈顺序就是看谁的地址大,地址大的先压栈

image.png lldb调试很明显可以看出&p&numStruct地址相差16个字节,因为numStruct有两个指针类型,而指针类型大小字节是8字节。&numFirst地址小于&numLast,说明numLast先入栈。从lldb调试结果得出两个结论

  • 先入栈的地址大于后入栈的地址
  • 在结构体中后面的变量先入栈

[super viewDidLoad]在上面探究过,实际上是向父类发送消息,父类的第一个参数是结构体。就像上面的例子一样先创建结构体,然后在去发送消息。注意的向父类发送消失是通过objc_msgSendSuper2方法,而objc_msgSendSuper2第一个参数的结构体中的第二个变量的super_class传的是类而不是父类

函数参数压栈顺序

函数的参数也会保存在当前的栈区,目的是在函数的范围内供其使用。探究下函数参数的压栈顺序 OC的方法都会默认有两个参数id selfSEL _cmd。所以用C函数来做测试,避免默认参数的问题

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    testFunc(10, 20, 30);
}
void testFunc(int a, int b,int c){
    printf("----%p---\n",&a);
    printf("----%p---\n",&b);
    printf("----%p---\n",&c);
}
@end

image.png

打印的结果显示函数的参数是按照参数的正序进行压栈的,参数位置靠前的进行先压栈。 这样一个一个打印很费时间,下面提供一个方法打印入栈的地址大小

void *sp  = (void *)&self;// 开始的地址
void *end = (void *)&lw_p;// 结束的地址
long count = (sp - end) / 0x8;// 中间的地址

for (long i = 0; i<count; i++) {
    void *address = sp - 0x8 * i;
    if ( i == 1) {
        NSLog(@"%p : %s",address, *(char **)address);
    }else{
        NSLog(@"%p : %@",address, *(void **)address);
    }
}

image.png 打印的结果和上面的探究的结果是一样的,打印的顺序图中已经标明,有兴趣的可以继续给LWPerson添加属性会出现不同的结果,但是原理还是不会改变的

总结

这种面试题探究起来就很得劲,不过不知道的就是一脸懵逼。我就想问到底谁出的这种面试题啊,不是难为人嘛。继续学习,面试题也会不断进行更新