iOS super 關鍵字幫我們做了什麼?

從來吃不胖發表於2017-06-08

本篇文章講的是super的實際運作原理,如有同學對super與self的區分還有疑惑的,請參考ChenYilong大神的《招聘一個靠譜的iOS》面試題參考答案(上)

super究竟在幹什麼?

官方提到的super關鍵字?

開啟蘋果API文件,搜尋objc_msgSendSuper(對該函式陌生的先去補補rumtime)。

iOS super 關鍵字幫我們做了什麼?
super官方解釋

裡面明確提到了使用super關鍵字傳送訊息會被編譯器轉化為呼叫objc_msgSendSuper以及相關函式(由返回值決定)。

再讓我們看看該函式的定義(這是文件中的定義):

id objc_msgSendSuper(struct objc_super *super, SEL op, ...);複製程式碼

這裡的super已經不再是我們呼叫時寫的[super init]super了,這裡指代的是struct objc_super結構體指標。文件中明確指出,該結構體需要包含接收訊息的例項以及一開始尋找方法實現的父類

struct objc_super {
    /// Specifies an instance of a class.
    __unsafe_unretained id receiver;

    /// Specifies the particular superclass of the instance to message. 
    __unsafe_unretained Class super_class;
    /* super_class is the first class to search */
};複製程式碼

iOS super 關鍵字幫我們做了什麼?
objc_super結構體

既然知道了super是如何呼叫的,那麼我們來嘗試自己實現一個super

手動實現super關鍵字

讓我們先定義兩個類:

這是父類:Father類

// Father.h
@interface Father : NSObject

- (void)eat;

@end

// Father.m
@implementation Father

- (void)eat {
    NSLog(@"Father eat");
}

@end複製程式碼

這是子類:Son類

// Son.h
@interface Son : Father

- (void)eat;

@end

// Son.m
@implementation Son

- (void)eat {
    [super eat];
}

@end複製程式碼

在這裡,我們的Son類重寫了父類的eat方法,裡面只做一件事,就是呼叫父類的eat方法。

讓我們在main中開始進行測試:

int main(int argc, char * argv[]) {
    Son *son = [Son new];
    [son eat];
}

// 輸出:
2017-05-14 22:44:00.208931+0800 TestSuper[7407:3788932] Father eat複製程式碼

到這裡沒毛病,一個Son物件呼叫了eat方法(內部呼叫父類的eat),輸出了結果。

1. 下面,我們來自己實現super的效果:

改寫Son.m:

// Son.m

- (void)eat {
//    [super eat];

    struct objc_super superReceiver = {
        self,
        [self superclass]
    };
    objc_msgSendSuper(&superReceiver, _cmd);    
}複製程式碼

執行我們的main函式:

//輸出
2017-05-14 22:47:00.109379+0800 TestSuper[7417:3790621] Father eat複製程式碼

沒毛病,我們可是根據官方文件來實現super的效果。

難道super真的就是如此?

讓我們持懷疑的態度看看下面這個例子:

在這裡,我們又有個Son的子類出現了:Grandson類

// Grandson.h
@interface Grandson : Son

@end

// Grandson.m
@implementation Grandson

@end複製程式碼

該類啥什麼都沒實現,純粹繼承自Son。

然後讓我們改寫main函式:

int main(int argc, char * argv[]) {
    Grandson *grandson = [Grandson new];
    [grandson eat];
}複製程式碼

執行起來,過一會就crash了,如圖:

iOS super 關鍵字幫我們做了什麼?
崩潰提示

再看看相關執行緒中的方法呼叫:

iOS super 關鍵字幫我們做了什麼?
crash方法呼叫

這是一個死迴圈,所以系統讓該段程式碼強制停止了。可為什麼這裡會構成死迴圈呢?讓我們好好分析分析:

  1. Grandson中沒有實現eat方法,所以main函式中Grandson的例項執行eat方法是這樣的:根據類繼承關係自下而上尋找,在Grandson的父類Son類中找到了eat方法,進行呼叫。
  2. 在Son的eat方法的實現中,我們構建了一個superReceiver結構體,內部包含了self以及[self superclass]。在呼叫過程中,self指代的應是Grandson例項,也就是grandson這個變數,那麼[self superclass]方法返回值也就是Son這個類。
  3. 根據第2點的分析,以及我們在文章開頭的文件中,蘋果指出superReceiver中的父類就是開始尋找方法實現的那個父類,我們可以得出,此時的objc_msgSendSuper(&superReceiver, _cmd)函式呼叫的方法實現即是Son類中的eat方法的實現。即,構成了遞迴。

既然這裡不能使用superclass方法,那麼我們要如何自己實現super的作用呢?

我們是這段程式碼的作者,所以,我們可以這樣:

// 我們修改了Son.m

- (void)eat {
//    [super eat];

    struct objc_super superReceiver = {
        self,
        objc_getClass("Father")
    };
    objc_msgSendSuper(&superReceiver, _cmd);
}

// 輸出
2017-05-14 23:16:49.232375+0800 TestSuper[7440:3798009] Father eat複製程式碼

我們直接指明superReceiver中要尋找方法實現的父類:Father。這裡必定有人會問:這樣子豈不是每個呼叫[super xxxx]的地方都需要直接指明父類

“直接指明”的意思是,程式碼中直接寫出這個類,比如直接寫:[Father class]或者objc_getClass("Father"),這裡面的Father與"Father"就是我們在程式碼裡寫死的。

先不談這個疑問,我們來分析這段程式碼:

  1. Grandson中沒有實現eat方法,所以main函式中Grandson的例項執行eat方法是這樣的:根據類繼承關係自下而上尋找,在Grandson的父類Son類中找到了eat方法,進行呼叫。
  2. 在Son的eat方法的實現中,我們構建了一個superReceiver結構體,內部包含了self以及Father這個類。
  3. objc_msgSendSuper函式直接去Father類中尋找eat方法的實現,並執行(輸出)。

現在這段程式碼是以正常邏輯執行的。

2. [super xxxx]真的要直接指明父類?

我們使用clang的rewrite指令重寫Son.m:

clang -rewrite-objc Son.m複製程式碼

生成的Son.cpp檔案:

static void _I_Son_eat(Son * self, SEL _cmd) {
    ((void (*)(__rw_objc_super *, SEL))(void *)objc_msgSendSuper)((__rw_objc_super){(id)self, (id)class_getSuperclass(objc_getClass("Son"))}, sel_registerName("eat"));
}複製程式碼

這一行到底的程式碼可讀性太差,讓我們稍稍分解下(由於語法問題我們作了少量語法修改以通過編譯,實際作用與原cpp中一致):

static void _I_Son_eat(Son * self, SEL _cmd) {
    __rw_objc_super superReceiver = (__rw_objc_super){
        (__bridge struct objc_object *)(id)self,
        (__bridge struct objc_object *)(id)class_getSuperclass(objc_getClass("Son"))};

    typedef void *Func(__rw_objc_super *, SEL);
    Func *func = (void *)objc_msgSendSuper;

    func(&superReceiver, sel_registerName("eat"));
}複製程式碼

先修改Son.m執行起來:

// Son.m

- (void)eat {
//    [super eat];

  //_I_Son_eat即為重寫的函式
    _I_Son_eat(self, _cmd);
}

// 輸出
2017-05-15 00:08:37.782519+0800 TestSuper[7460:3810248] Father eat複製程式碼

沒有毛病。

重寫的程式碼裡構建了一個__rw_objc_super的結構體,定義如下:

struct __rw_objc_super { 
    struct objc_object *object; 
    struct objc_object *superClass; 
    // cpp裡的語法,忽略即可
    __rw_objc_super(struct objc_object *o, struct objc_object *s) : object(o), superClass(s) {} 
};複製程式碼

該結構體與struct objc_super一致。之後我們將objc_msgSendSuper函式轉換為指定引數的函式func進行呼叫。這裡請注意__rw_objc_super superReceiver中的第二個值class_getSuperclass(objc_getClass("Son"))

該程式碼直接指明的類是本類:Son類。但是__rw_objc_super結構體中的superClass並不是本類,而是通過runtime查詢出的父類。這與我們自己實現的 “直接指明Father為objc_super結構體的super_class值” 最後達到的效果是一樣的。

所以,[super xxxx]肯定要通過指明一個類,可以是父類,也可以是本類,來達到正確呼叫父類方法的目的!只不過“直接指明”這件事,編譯器會幫我們搞定,我們只管寫super即可。

clang rewrite不可靠

為何clang不可靠

clang的rewrite功能所提供的重寫後的程式碼並非編譯器(LLVM)轉換後的程式碼,如今的編譯器在Xcode開啟bitcode功能後會生成一種中間程式碼:LLVM Intermediate Representation(LLVM IR)。該程式碼向上可統一大部分高階語言,向下可支援多種不同架構的CPU,具體可檢視LLVM文件。所以我們的目標是從IR程式碼求證super究竟在做什麼事!

檢視IR程式碼

終端裡cd到Son.m檔案所在目錄,執行:

clang -emit-llvm Son.m -S -o son.ll複製程式碼

生成的IR程式碼比較多,我們挑重點進行檢視:

%0 = type opaque

// Son的eat方法
define internal void @"\01-[Son eat]"(%0*, i8*) #0 {
  %3 = alloca %0*, align 8    // 分配一個指標的記憶體,8位元組對齊(宣告一個指標變數)
  %4 = alloca i8*, align 8    // 分配一個char *的記憶體(宣告一個char *指標變數)
  %5 = alloca %struct._objc_super, align 8    // 給_objc_super分配記憶體(宣告一個struct._objc_super變數)
  store %0* %0, %0** %3, align 8    // 將第一個引數,id self 寫入%3分配的記憶體中去
  store i8* %1, i8** %4, align 8    // 將_cmd寫入%4分配的記憶體中區
  %6 = load %0*, %0** %3, align 8   // 讀出%3記憶體中的資料到%6這個臨時變數(%3中存的是self)
  %7 = bitcast %0* %6 to i8*        // 將%6變數的型別轉換為char *指標型別,指向的還是self
  %8 = getelementptr inbounds %struct._objc_super, %struct._objc_super* %5, i32 0, i32 0    // 取struct._objc_super變數(%5)中的第0個元素,宣告為%8
  store i8* %7, i8** %8, align 8    // 將%7存入%8這個變數中,即把i8* 型別的 self存入了結構體第0個元素中
  %9 = load %struct._class_t*, %struct._class_t** @"OBJC_CLASSLIST_SUP_REFS_$_", align 8    // 宣告%9臨時變數為struct._class_t*型別,內容為@"OBJC_CLASSLIST_SUP_REFS_$_"
  %10 = bitcast %struct._class_t* %9 to i8*   // 將%9的變數強轉為char *型別
  %11 = getelementptr inbounds %struct._objc_super, %struct._objc_super* %5, i32 0, i32 1   // 取struct._objc_super變數(%5)中的第1個元素,宣告為%11
  store i8* %10, i8** %11, align 8    // 將%9的變數,即@"OBJC_CLASSLIST_SUP_REFS_$_"存入結構體第1個元素中
  %12 = load i8*, i8** @OBJC_SELECTOR_REFERENCES_, align 8, !invariant.load !7    // 將@selector(eat)的引用放入char *型別的%12變數中

  // 函式呼叫,傳入引數為上述生成的struct._objc_super結構體和 @selector(eat),呼叫函式objc_msgSendSuper2
  call void bitcast (i8* (%struct._objc_super*, i8*, ...)* @objc_msgSendSuper2 to void (%struct._objc_super*, i8*)*)(%struct._objc_super* %5, i8* %12)
  ret void
}


@"OBJC_CLASS_$_Son" = global %struct._class_t { 
                                                %struct._class_t* @"OBJC_METACLASS_$_Son",
                                                %struct._class_t* @"OBJC_CLASS_$_Father", 
                                                %struct._objc_cache* @_objc_empty_cache, 
                                                i8* (i8*, i8*)** null,
                                                %struct._class_ro_t* @"\01l_OBJC_CLASS_RO_$_Son" 
                                              }, section "__DATA, __objc_data", align 8

// 直接存放進入struct._objc_super的變數, 內容為@"OBJC_CLASS_$_Son"
@"OBJC_CLASSLIST_SUP_REFS_$_" = private global %struct._class_t* @"OBJC_CLASS_$_Son", section "__DATA, __objc_superrefs, regular, no_dead_strip", align 8複製程式碼

IR的語法其實不難記,還是比較好懂的。這裡我們只要對照著看即可:

  • %1,%2,@xxx之類的都是指代變數,理解為變數名就可以了
  • i8指8位的int型別,即1個位元組的char型別。i8就是指char 指標
  • alloca指分配記憶體,理解為宣告一個變數即可,如alloca i8即為一個char 的變數
  • %0在開頭的程式碼裡說明了是一個不透明的型別,所以%0*就指代一個萬能指標,理解為id即可
  • store為寫入記憶體
  • load為從記憶體中讀取出來
  • bitcast為型別轉換
  • getelementptr inbounds取指定記憶體偏移

程式碼中既有彙編的趕腳,又有高階語言的味道。基本上註釋都補全了,程式碼中的邏輯和上文中我們自己實現的/clang重寫的程式碼基本相似。但是這裡注意@"OBJC_CLASSLIST_SUP_REFS_$_"這個變數。

@"OBJC_CLASSLIST_SUP_REFS_$_"其實就是對應到struct objc_super結構中的第二個元素:super_class。在IR程式碼的%11以及後面那一行就是體現。

@"OBJC_CLASSLIST_SUP_REFS_$_"的定義就是@"OBJC_CLASS_$_Son"這個全域性變數。@"OBJC_CLASS_$_Son"全域性變數就是Son這個類物件,裡面包含了元類:@"OBJC_METACLASS_$_Son",以及父類:@"OBJC_CLASS_$_Father",以及其他的一些資料。然而,看到這裡,我們發現這和我們自己實現的super,以及clang重寫的super都不一樣:這裡是直接將[Son class]作為struct objc_supersuper_class,但是並沒有任何呼叫class_getSuperclass的地方...

檢視彙編原始碼

但是,這裡唯一的一個函式@objc_msgSendSuper2貌似與眾不同,與我們之前看到的objc_msgSendSuper相比多了個2,難道是這個函式在作鬼?那就讓我們到官方的objc4-709原始碼裡查詢下這個函式(位於objc-msg-arm64.s檔案中):

ENTRY _objc_msgSendSuper2
UNWIND _objc_msgSendSuper2, NoFrame
MESSENGER_START

ldp    x0, x16, [x0]        // x0 = real receiver, x16 = class
ldr    x16, [x16, #SUPERCLASS]    // x16 = class->superclass
CacheLookup NORMAL

END_ENTRY _objc_msgSendSuper2複製程式碼

這是一段彙編程式碼,沒錯,蘋果為了提高執行效率,傳送訊息相關的函式是直接用匯編實現的。

這裡我們來簡單分析下這個函式:

  1. ldp x0, x16, [x0]:從x0出讀取兩個字資料到x0與x16中,根據註釋,讀取的資料應該是對應的self[Son class]
  2. ldr x16, [x16, #SUPERCLASS]:將x16的數值+SUPERCLASS值的偏移作為地址,取出該地址的數值儲存在x16中。這裡的SUPERCLASS定義是#define SUPERCLASS 8,也就是偏移8位,那麼取到的應該就是@"OBJC_CLASS_$_Father"這個父類[Father class]到x16中。
  3. 執行CacheLookup函式,引數為NORMAL。

讓我們看看CacheLookup的定義:

/********************************************************************
 *
 * CacheLookup NORMAL|GETIMP|LOOKUP
 * 
 * Locate the implementation for a selector in a class method cache.
 *
 * Takes:
 *     x1 = selector
 *     x16 = class to be searched
 *
 * Kills:
 *      x9,x10,x11,x12, x17
 *
 * On exit: (found) calls or returns IMP
 *                  with x16 = class, x17 = IMP
 *          (not found) jumps to LCacheMiss
 *
 ********************************************************************/

#define NORMAL 0
#define GETIMP 1
#define LOOKUP 2

.macro CacheLookup
    // x1 = SEL, x16 = isa
    ldp    x10, x11, [x16, #CACHE]    // x10 = buckets, x11 = occupied|mask
    and    w12, w1, w11        // x12 = _cmd & mask
    add    x12, x10, x12, LSL #4    // x12 = buckets + ((_cmd & mask)<<4)

    ldp    x9, x17, [x12]        // {x9, x17} = *bucket
1:    cmp    x9, x1            // if (bucket->sel != _cmd)
    b.ne    2f            //     scan more
    CacheHit $0            // call or return imp

2:    // not hit: x12 = not-hit bucket
    CheckMiss $0            // miss if bucket->sel == 0
    cmp    x12, x10        // wrap if bucket == buckets
    b.eq    3f
    ldp    x9, x17, [x12, #-16]!    // {x9, x17} = *--bucket
    b    1b            // loop

3:    // wrap: x12 = first bucket, w11 = mask
    add    x12, x12, w11, UXTW #4    // x12 = buckets+(mask<<4)

    // Clone scanning loop to miss instead of hang when cache is corrupt.
    // The slow path may detect any corruption and halt later.

    ldp    x9, x17, [x12]        // {x9, x17} = *bucket
1:    cmp    x9, x1            // if (bucket->sel != _cmd)
    b.ne    2f            //     scan more
    CacheHit $0            // call or return imp

2:    // not hit: x12 = not-hit bucket
    CheckMiss $0            // miss if bucket->sel == 0
    cmp    x12, x10        // wrap if bucket == buckets
    b.eq    3f
    ldp    x9, x17, [x12, #-16]!    // {x9, x17} = *--bucket
    b    1b            // loop

3:    // double wrap
    JumpMiss $0

.endmacro複製程式碼

具體的CacheLookup我們這裡就不再展開了,我們只關心這裡是從哪裡查詢方法的。在註釋中,明確說到這是一個“去類的方法快取中尋找方法實現”的函式,參入的引數是x1中的selector,x16中的class(class to be searched 就是說從這個類中開始查詢),而這時候的x16,恰恰是我們剛才在_objc_msgSendSuper2存入的父類[Father class],因此,方法會從這個類中開始查詢

整體呼叫流程

從手動實現->檢視clang重寫->檢視IR碼->檢視彙編原始碼這幾個過程分析下來,我們總算是把這條真實的super呼叫鏈路搞搞清楚了:

  1. 編譯器指定一個struct._objc_super結構體, 結構體中self為接收物件,直接指明自身的類為結構體第二個class型別的值。
  2. 呼叫_objc_msgSendSuper2函式,傳入上述struct._objc_super結構體。
  3. _objc_msgSendSuper2函式中直接通過偏移量直接查詢父類。
  4. 呼叫CacheLookup函式去父類中查詢指定方法。

結論

所以,從真實的IR程式碼中,super關鍵字其實是直接指明本類Son,再結合_objc_msgSendSuper2函式直接獲取父類去查詢方法的,而並非像clang重寫的那樣,指明本類,再通過runtime查詢父類。

其實先指明本類,再通過runtime查詢父類,也是沒有問題的,這還可以避免一些執行時“更改父類”的情況。但是LLVM的做法應該是有他的道理的,可能是出於效能考慮?

相關文章