Runtime原理探究(六)—— Runtime综合面试题


Runtime系列文章

Runtime原理探究(一)—— isa的深刻体会(苹果对isa的优化)程序员

Runtime原理探究(二)—— Class结构的深刻分析面试

Runtime原理探究(三)—— OC Class的方法缓存cache_t缓存

Runtime原理探究(四)—— 刨根问底消息机制markdown

Runtime原理探究(五)—— super的本质架构

Runtime原理探究(六)—— 面试题中的Runtime函数


先上面试题

//***********♦️♦️CLPerson.h♦️♦️************

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface CLPerson : NSObject
@property (nonatomic, copy) NSString *name;
-(void)print;
@end

NS_ASSUME_NONNULL_END


//***********♥️♥️CLPerson.m♥️♥️************ 

#import "CLPerson.h"

@implementation CLPerson

-(void)print {
    NSLog(@"My name's %@", self.name);
}

@end

//***********🥝🥝ViewController.m🥝🥝************ 

#import "ViewController.h"
#import "CLPerson.h"

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    id cls = [CLPerson class];
    void *obj = &cls;
    [(__bridge id)obj print];  
}

@end
复制代码

问题1 [(__bridge id)obj print];中的print方法能够被正常调用吗?oop

问题2 print方法最终的打印结果是什么?布局

运行结果post

2019-08-13 17:10:58.075381+0800 iOS-Runtime[29076:3163099] My name's <ViewController: 0x7fce43e08aa0>
复制代码

从运行结果,print方法能够被成功调用,打印结果是My name's <ViewController: 0x7fce43e08aa0>,从代码到运行结果,彷佛莫名其妙。若是我在毫无防备的状况下碰到这样的面试题,我会选择选择直接起身,优雅离去,同时内心默念WHAT THE FUCK!!! 优化

如今,咱们就静下心来,好好来搞一搞。

[(__bridge id)obj print];中的print方法为何能够被正常调用?

咱们先回顾一下正常人是怎么调用方法的

CLPerson *person = [[CLPerson alloc] init];
[person print];
复制代码

相信对于上面的代码没有人会有疑问,咱们经过一张图来讲明一下,这两行代码运行时,内存里面的状况

再看看咱们面试题里面的代码

- (void)viewDidLoad {
    [super viewDidLoad];
    id cls = [CLPerson class];
    void *obj = &cls;
    [(__bridge id)obj print];  
}
复制代码

能够看出,cls指向CLPersonClass对象,而obj指向cls,以下图示

请看图中的文字说明,由于从本质上说, 指针person-->指针isa-->[CLPerson class] 指针obj-->指针cls-->[CLPerson class] 所以[person print]效果 == [(__bridge id)obj print]效果,这里须要仔细体会一下。

回想一下消息发送的本质[person print]是从person所指向的结构体(实例对象)取出第一个成员变量isa,而后根据isa找到对应Class对象的内存空间,最后在Class对象的方法列表里面进行方法查找,最后调用方法。

那么[(__bridge id)obj print],一样会听从上面的流程,由于obj所指向的是一个cls指针变量地址,恰巧,这个cls指针指向的就是CLPersonClass对象的内存空间,因此一样能够进入到它的方法列表进行查找,最后找到print方法进行调用,到此问题①解释完毕。

②打印结果为何是<ViewController: 0x7fce43e08aa0>

这个问题有点小复杂,不过不要紧,咱们一步一步来

print方法找到后的调用过程 咱们知道任何OC方法的底层都是一个C函数,而且函数头两个参数是默认参数id selfSEL _cmd,那么self是谁呢?以上面代码为例

CLPerson *person = [[CLPerson alloc] init];
[person print];

**********
-(void)print {
    NSLog(@"My name's %@", self.name);
}
复制代码

print方法对应的C函数里面,self就是person,而print的内容是打印self.name,也就是必然要经过self,找到成员变量_name,如何找呢,这就须要咱们来了解一下实例对象的内存布局,根据咱们上面有关CLPerson类的定义,实例变量person的内存布局以下图

self.name至关于self->_name,由于_nameisa后面紧接着的成员变量,而_name是一个指针,占8个字节大小,所以self->_name实际上获得的就是从self所指向的内存地址往高地址偏移8个字节(跨过isa的大小)后的内存地址,指向一段8字节大小的内存空间,从而得到person对象的成员变量_name

若是你还不太了解OC对象内存布局相关知识的,能够参考

OC对象的本质(上) —— OC对象的底层实现原理

OC对象的本质(下)—— 详解isa&superclass指针

我在其中进行了详细阐述。 若是对于上面的内容没有疑问,那么下面接着看面试题中设置的场景,在分析print方法为什么能被调用的过程当中,咱们能够看到实际上

  • obj指针至关于person指针(也就是print方法里面的self
  • cls指针至关于person指针所指向的实例对象里面的isa指针

因此对于面试题的场景,其实是这样的

两张图本质是同样的,只不过在面试题的场景里,print方法被调用的时候,其内部的self = obj,所以self.name做用就是从obj所指向的内存空间,往高地址偏移8个字节,而obj指向了cls的内存地址,cls也是是一个指针,因此占8个字节,所以self.name取到的实际上刚好是指针变量cls以后接下来的一段8字节内存空间,因此最终print打印出的就是这段内存里面存储的内容。而结果咱们已经看到了,打印的是<ViewController: 0x7fce43e08aa0>,接下来咱们就要分析一下为啥cls下面存着的是ViewController对象。

由于objcls都是viewDidLoad方法(函数)里面的局部变量,咱们知道函数的局部变量都是放在栈空间里面的。那么你了解函数的栈空间吗?咱们来简单科普一下。

函数的栈空间简介

栈空间的做用,是用来存放被调用函数其内部所定义的局部变量的。对于arm64架构来讲,这么理解就够了,若是你刚好了解过8086汇编,那么可能知道,栈空间里面还会存放函数的参数,可是对于arm64来讲,函数的参数一般会放到寄存器里面,因此咱们就先简单的认为,函数的栈空间里面放的就是函数的局部变量。并且局部变量的存放顺序,是根据定义的前后顺序,从函数栈底开始,一个一个排列,最早定义的局部变量位于栈底(高地址),经过下图来描绘一下

那么咱们就来看一下viewDidLoad里面总共有哪些局部变量,再贴一下代码

- (void)viewDidLoad {
    [super viewDidLoad];
    id cls = [CLPerson class];
    void *obj = &cls;
    [(__bridge id)obj print];  
}
复制代码

咱们看到,viewDidLoad内部只有两个局部变量,分别是id clsvoid *obj,其他的都是方法调用。那么栈里面的状况应该就是

能够看出若是按图中的分析,print方法将会最终打印栈底以外8个字节里面的内容,可是咱们知道一个函数内部是不能访问其余函数的栈空间的,上图中的这8个字节明显超出了当前函数的栈空间,因此没法解释咱们上面看到的打印结果。

其实,这个面试题里面设计了一个很隐藏的猫腻。问题的出口实际上是在[super viewDidLoad];这句代码上,关于super问题,能够参考我在Runtime笔记(五)—— super的本质一文中的解析。这里就直接基于文章中的知识来解决咱们当前的问题了。

[super viewDidLoad];展开成底层函数就是

objc_msgSendSuper((__rw_objc_super){
            (id)self,   
            (id)class_getSuperclass(objc_getClass("ViewController"))
           },   
            @selector(viewDidLoad));
复制代码

注意这个函数的第一个参数是一个结构体__rw_objc_super,那么这个结构体参数其实是在当前viewDidLoad函数的做用域里面被定义赋值,而后再传入objc_msgSendSuper做为参数的。说白了viewDidLoad还含有一个隐藏局部变量,其内部实际上等同于这么写

// [super viewDidLoad];
    struct __rw_objc_super arg = {
        (id)self,
        (id)class_getSuperclass(objc_getClass("ViewController"))
    };
    
    objc_msgSendSuper(arg, @selector(viewDidLoad));
    
    id cls = [CLPerson class];
    void *obj = &cls;
    [(__bridge id)obj print];
复制代码

因此,viewDidLoad内部第一个局部变量其实是一个结构体类型struct __rw_objc_super的变量,该结构体内部有两个id类型(也就是指针变量)的成员变量,而且注意,第一个成员变量是 self,而这个self正式当前方法的消息接受者,也就是ViewController实例对象。**须要说明的是,这个self跟咱们上面讨论print方法里面用到的那个self是不一样的两个对象哦,请用心体会。**好了,说多了太绕,直接上图

综上所述,print里面经过self.name所拿到的变量,就是图中cls下面的那8个字节,也就是当前方法的消息接受者selfViewController实例对象),所以打印的结果是<ViewController: 0x7fce43e08aa0>,好了,全部的问题就都获得解释了。

接下来,咱们经过汇编手段来验证一下上面推断,咱们先将程序运行至下图所示的断点处

此时, viewDidLoad函数栈上全部的局部变量已经赋值完毕,汇编状况以下

从上面的分析能够看出,viewDidLoad函数栈空间大小为48个字节,存放了6个局部变量,每一个局部变量8个字节,栈空间的地址范围是[rbp-0x30] ~ [rbp],所以想要查看当前栈空间里面内容,能够利用以下LLDB指令: 先读出当前栈底位置,也就寄存器rbp的值

(lldb) register read rbp
     rbp = 0x00007ffeeaddd130
复制代码

rbp - 0x30 = 0x7FFEEADDD100 这样就获得了栈顶的的位置,而后打印出栈顶位置 以后的48字节内容(也就是当前的函数栈空间)

(lldb) x/6xg 0x7FFEEADDD100
0x7ffeeaddd100: 0x00007ffeeaddd108 0x0000000104e245c8
0x7ffeeaddd110: 0x00007f9d01508f50 0x0000000104e24500
0x7ffeeaddd120: 0x00007fff527257c0 0x00007f9d01508f50
复制代码

也就是下图所示

咱们能够挨个打印一下每个局部变量

(lldb) po 0x00007ffeeaddd108
<CLPerson: 0x7ffeeaddd108>

(lldb) po 0x0000000104e245c8
CLPerson

(lldb) po 0x00007f9d01508f50   -->❗️❗️❗️ 实际上 [(__bridge id)obj print]; 的本质就等同于这一句❗️❗️❗️
<ViewController: 0x7f9d01508f50>

(lldb) po 0x0000000104e24500
ViewController

(lldb) po 0x00007fff527257c0
140734576613312

(lldb) po 0x00007f9d01508f50
<ViewController: 0x7f9d01508f50>
复制代码

你或许会好奇为何_cmd所指向的内容打出来的为何是140734576613312(=0x00007fff527257c0,也就是它本身),根据_cmd的地址0x00007fff527257c0,说明它也是栈空间的地址,由于_cmd实际上是viewDidLoad上层函数传过来的参数,所以这个栈空间应该是外层函数的局部变量,也就是说_cmd本质上说是一个指针。那咱们看一下所指向的这段内存里面放了什么内容,由于不知道具体的大小,因此咱们经过Xcode的内存查看器来看看 原来就是函数viewDidLoad所对应的函数名字符串而已,这样因此的疑问就扫清了。。。☕️☕️☕️

这道面试题确实有点扯,项目中也毫不会这么写代码,但从面试的角度,这里面涉及了对于函数栈空间的理解对于super本质的理解对于消息机制的理解对于OC对象本质的理解,在高考里面,属于最后一道大题的难度级别,本文以前,你可能祈祷千万别碰到这种变态的面试题,可是本文事后,若是你能彻底掌握里面的精髓,我相信你们确定会祈祷面试碰到这道题,由于光是把里面涉及到的四个对于...的理解都展开讲一遍,那通常的面试官估计就要被您给反虐了:)

好了,关于面试的话题,到此结束,但愿对你们有帮助,文中若有解释的不透彻或者不正确的地方,欢迎交流指正,程序员的世界没有容易二字,加油,与诸君共勉💪💪💪。


🦋🦋🦋传送门🦋🦋🦋

Runtime原理探究(一)—— isa的深刻体会(苹果对isa的优化)

Runtime原理探究(二)—— Class结构的深刻分析

Runtime原理探究(三)—— OC Class的方法缓存cache_t

Runtime原理探究(四)—— 刨根问底消息机制

Runtime原理探究(五)—— super的本质

Runtime原理探究(六)—— 面试题中的Runtime

相关文章
相关标签/搜索