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

相關文章
相關標籤/搜索