1. 程式人生 > >一道值得思考的iOS面試題

一道值得思考的iOS面試題

前言

最近在群裡看到有人發的一道面試題,題目如下:

@interface Spark : NSObject 

@property(nonatomic,copy) NSString *name; 

@end

@implementation Spark

- (void)speak {
    NSLog(@"My name is:%@",self.name); 
}

@end

@implementation ViewController

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

複製程式碼

問題:上述程式碼執行起來會:Complie error?|Runtime crash?|NSLog ?

最終問題就是這段程式碼的執行結果。

過程

第一眼看這個問題,我直接就想說,這個東西啊,肯定是編譯報錯了、要不就是崩潰啊

所以我就跟著寫了些程式碼,結果發現:

WTF? 怎麼能執行,而且結果竟然還是

執行結果

相信當你看到這個結果的時候會和我一樣吃驚,不和邏輯啊,怎麼竟然能執行成功並且還打印出來當前controller了,不符合常理啊。

解析

對於計算機而言,不存在什麼魔法,如果一段程式碼能執行必然存在它的原理。

我們需要做的就是分析為什麼能成功。

  1. 為什麼呼叫不崩潰 我們需要了解,cls
    的意思。

cls在C語言裡,就是一個指標,這個指標的內容指向Spark類

當我們通過void *obj = &cls;這個語句執行後,獲取的就是一個指向這個指標cls的指標

事實上在這一步操作實現後,obj 這個指標就已經具有Object-c物件的功能了,為什麼呢?接下來我們可以看看runtime實現原理了,這裡我只說一點

//物件
struct objc_object {
    Class isa  OBJC_ISA_AVAILABILITY;
};
//類
struct objc_class {
    Class isa  OBJC_ISA_AVAILABILITY;
#if !__OBJC2__
    Class super_class                                        OBJC2_UNAVAILABLE;
    const char *name                                         OBJC2_UNAVAILABLE;
    long version                                             OBJC2_UNAVAILABLE;
    long info                                                OBJC2_UNAVAILABLE;
    long instance_size                                       OBJC2_UNAVAILABLE;
    struct objc_ivar_list *ivars                             OBJC2_UNAVAILABLE;
    struct objc_method_list **methodLists                    OBJC2_UNAVAILABLE;
    struct objc_cache *cache                                 OBJC2_UNAVAILABLE;
    struct objc_protocol_list *protocols                     OBJC2_UNAVAILABLE;
#endif
} OBJC2_UNAVAILABLE;
//方法列表
struct objc_method_list {
    struct objc_method_list *obsolete                        OBJC2_UNAVAILABLE;
    int method_count                                         OBJC2_UNAVAILABLE;
#ifdef __LP64__
    int space                                                OBJC2_UNAVAILABLE;
#endif
    /* variable length structure */
    struct objc_method method_list[1]                        OBJC2_UNAVAILABLE;
}                                                            OBJC2_UNAVAILABLE;
//方法
struct objc_method {
    SEL method_name                                          OBJC2_UNAVAILABLE;
    char *method_types                                       OBJC2_UNAVAILABLE;
    IMP method_imp                                           OBJC2_UNAVAILABLE;
}
複製程式碼

引自: iOS Runtime詳解-簡書

可以看到objc_object這個物件的首欄位是isa 指向一個Class

也就是說,我們如果有一個指向Class的地址的指標,相當於這個物件就已經可以使用了,只是像他的成員變數等等的一系列值都還沒有被初始化。

所以接下來用(__bridge id)obj,呼叫是不會產生問題的

  1. 為什麼能打印出ViewController物件?

這個問題就是由兩個小部分組成的

1.  name 這個屬性是什麼時候賦的值?
2.  ViewController 這個物件是什麼時候被傳入的?
複製程式碼

首先我們需要先了解一下,一個類物件的資料是如何儲存的。

這裡我就按照上文一樣引用很多的論證了,我們自己來探究

該上程式碼了:

@interface Cls : NSObject 

@property(nonatomic,strong) NSString *test; 

@property(nonatomic,strong) NSString *test1;

@end

@implementation Cls

- (void)printPrinter {
    NSLog(@"self:%p",self);
    NSLog(@"self.test:%p",&_test);
    NSLog(@"self.test1:%p",&_test1);
}

@end
複製程式碼

接下來呼叫printPrinter,列印一下物件指標地址:

地址列印

可以發現,指標偏移量成員變數和指標首地址差8個位元組,每個成員變數與上一個成員變數偏移量也是8個位元組。

完成到這一步,我們仍然沒有發現上述兩個問題是應該怎麼解釋。但是我們知道了,一個Object-C 物件的指標,和它的成員變數的指標肯定是連續的。這就為接下來我們的分析提供了一些思路。

下一步,我在原本的題目中增加一行程式碼:

[super viewDidLoad];

NSString *str = @"11111";
    
id cls = [Spark class];
複製程式碼

為啥要增加這行程式碼呢,這步是經過深(瞎)思(J)熟(B)慮(試),主要是考慮到函式內部的引數生成必然會需要地方儲存,但這部分儲存地址,我們是不知曉的,它的實現是被系統隱藏的。而我們的程式碼又沒有明顯的設定相關程式碼,那麼必然是由這些條件實現的。所以當我們增加了這一行程式碼後,不出意外的,列印結果變了

2018-11-29 20:49:39.254021+0800 test[1961:92498] My name is:11111

變成了 我們 上述的值,這一切都和猜想的差不多

於是一個基本設想就出來了:

因為棧上的地址結構和原本類的需求地址結構高度重合了,同時所有地址都能訪問到對應的值。我們通過棧的預設行為生成了一個Spark物件!

為了驗證,我們列印一下clsstr的指標堆疊地址

NSLog(@"cls address:%p str address:%p",&cls,&str);
複製程式碼

2018-11-29 21:03:30.490989+0800 test[2129:122769] cls address:0x7ffeebf4fa00 str address:0x7ffeebf4fa08

我們可以看到他們之間相差也正好是8,而且正好和物件結構體定義的一模一樣。所以這也正好能說明我們上述的列印結果My name is:11111為什麼會發生。

注:這個存在的原因是因為函式內部變數採用的小端模式,也就是將引數地址由棧區從高地址依次向低地址分配,所以我們列印cls地址會比str要小。

由此,第一個小問題就解決了,答案是因為我們在生成堆疊引數的時候,拼湊出了Spark物件的地址資料結構格式,和真正的物件地址資料結構一樣,所以self.name就是在生成cls的那一刻起記憶體地址就已經被賦值了。

接下來到下一個問題了ViewController 是什麼時候傳入的?

在這一步裡我們只能把目光向cls物件生成前執行的操作來看,[super viewDidLoad];我們只執行了這一步操作,那必然是這個操作產生的結果。為了驗證,我們可以更改一下呼叫順序

id cls = [Cls class];
    
[super viewDidLoad];
複製程式碼

當我們進行這部操作後,會發現,執行speak方法時崩潰了,錯誤是EXC_BAC_ACCESS,說明是我們引用野指標了。

由此也可以證實,[super viewDidLoad];肯定做了一些騷操作,將ViewController的self壓入了棧區。

接下來我們就需要探究究竟做了什麼操作,我們可以用如下的命令列程式碼將ViewController.m重寫成c++程式碼,然後觀看發生了什麼。

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc ViewController.m -o ViewController.cpp
複製程式碼
static void _I_ViewController_viewDidLoad(ViewController * self, SEL _cmd) {
    ((void (*)(__rw_objc_super *, SEL))(void *)objc_msgSendSuper)((__rw_objc_super){(id)self, (id)class_getSuperclass(objc_getClass("ViewController"))}, sel_registerName("viewDidLoad"));
複製程式碼

我們可以發現原本這個方法裡面會傳入兩個引數一個是self,一個是_cmd,當我們呼叫[super viewDidLoad]時,執行的方法中傳入了引數self,由此將self做為一個值壓入了棧中,但是_cmd這個引數並未被使用,因此,沒有被壓入棧中。

至此,這個問題已經被解釋出來了。

答案

所有NSObject物件的首地址都是指向這個物件的所屬類。這個條件是充要條件。反過來說,如果一個地址指向某個類,我們就可以把這個地址當成物件去用。所以編譯是會通過的,也不會報unrecognized selector的錯誤。

列印結果會是ViewController物件的原因是因為cls在棧上的資料結構符合了它作為真實的類時候的資料結構,cls.name原本地址正好是棧上ViewController物件地址,因此NSLog能打印出<ViewController >

思索

這類問題,考察的東西很深,並且結合了很多知識點。但是當我們拿到面試題並且能進行思索的時候一定要好好的考慮,我對這道題的想法,也是在不斷的試驗中逐漸的完善,並且嘗試了很多。其實找面試題為什麼是這個答案的過程和,找程式碼找bug的流程都是類似的,都是排除變數,逐步探索,最終將探索過程和概念結合。

備註

也許答案不是很專業,希望大家如果有更專業的答案,可以告訴我。順便同步推廣自己的部落格:www.wdtechnology.club/

謝謝您的閱讀

本文同步發行於本人部落格 未經授權不得隨意使用