如果把類的實例看成一個C語言的結構體(struct)
首先包含的是一個 isa 指針
類的其它成員變量依次排列在結構體中
對象在內存中的排布可以看成一個結構體,該結構體的大小并不能動態變化。所以無法在運行時動態給對象增加成員變量。
需要特別說明一下,通過 objc_setAssociatedObject 和 objc_getAssociatedObject方法可以變相地給對象增加成員變量,但由于實現機制不一樣,所以并不是真正改變了對象的內存結構。
這些成員變量是基本類型和指針類型,指針類型的個數決定了結構體的大小,也就是實例變量決定著對象的內存結構,而運行時改變指針指向的結構體是不受影響的(如添加方法)
Non Fragile ivars(強壯的成員變量)
在C++中,成員變量的訪問會被編譯器轉成一條指令,用“對象地址”加“成員變量偏移值”即可訪問到成員變量的值,或許Objective-C2.0之前也是這樣的。
上面說了類實例的結構,父類的成員變量在前,子類的在后。我們編譯后上傳到AppStore,用戶下載到手機。當蘋果發布新版本OSX SDK后。
例如,NSObject增加了兩個成員變量。如果沒有Non Fragile ivars特性,我們的代碼將無法正常運行。我們的類繼承自NSObject,編譯時,類成員變量的已經確定。當根類增添成員變量,我們的類的成員變量和基類的內存區域重疊了。此時,我們只能重新編譯我們的代碼,程序才能在新版本系統上運行。
如果更悲催一點,如果我們使用了第三方提供的靜態庫,我們就只能眼巴巴等著庫作者更新版本了。
Non Fragile ivars特性出場了。在程序啟動后,runtime加載MyObject類的時候,通過計算基類的大小,runtime動態調整了我們自定義類成員變量布局,把自定義類成員變量的位置向后移動若干字節。于是我們的程序無需編譯,就能在新版本系統上運行
那Non Fragile ivars是如何實現的呢?最關鍵的點是,當成員變量布局調整后,怎么能找到變量的新偏移位置呢?
沿著 objc_class的data()->ro->ivars 找下去,struct ivar_list_t 是類所有成員變量的定義列表。
struct ivar_list_t {
? ? ?uint32_t entsize;
? ? ? uint32_t count;
? ? ? ivar_t first;
};
通過first字段,可以取得類里任意一個類成員變量的定義。
struct ivar_t {
? ? ?int32_t *offset;
? ? ?const char *name;
? ? ?const char *type;
//...
};
offset,如果offset直接記錄著這個成員變量在對象中的偏移位置,那么,runtime在發現基類大小變化時,通過修改offset值,來更新子類成員變量的偏移值。那Objective-C中獲取對象的第N個成員變量偏移位置就需要這樣一長串代碼:
*((&obj->isa.cls->data()->ro->ivars->first)[N]->offset)
這么多次尋址,看起來很可怕吧。每個成員變量都這樣訪問的話,性能一定無法接受。看看編譯器到底是如何實現的吧,我們祭出LLVM
@interface MyClass : NSError {
@public
? ? ? int myInt;
}
@end
@implementation MyClass
@end
int main()
{
? ? ?MyClass *obj = [[MyClass alloc] init];
? ? ? obj->myInt = 42;
}
obj->myInt = 42;
通過clang,我們看到這句代碼被轉為:
int32_t g_ivar_MyClass_myInt = 40;??// 全局變量
*(int32_t *)((uint8_t *)obj + g_ivar_MyClass_myInt) = 42;
兩條CPU指令搞定,根本不需要一長串的指針調用。LLVM為每個類的每個成員變量都分配了一個全局變量,用于存儲該成員變量的偏移值。
這也就是為什么結構體中 ivar_t.offset 用int指針來存儲偏移值,而不是直接放一個int的原因。在這個設計中,真正存放偏移值的地址是固定不變的,在編譯時就確定了下來。因此才能用區區2條指令搞定動態布局的成員變量。
有了這種靈活而高效的尋址方式,那runtime是在什么時候調整成員變量偏移值的呢?在編譯時,LLVM計算出基類NSError對象的大小為40字節,然后記錄在MyClass的類定義中。在編譯后的可執行程序中,寫死了“40”這個魔術數字,記錄了在此次編譯時MyClass基類的大小。
class_ro_t class_ro_MyClass = {
? ? ? .instanceStart = 40,?
? ? ? .instanceSize = 48,
//...
}
現在假如蘋果發布了OSX 11 SDK,NSError類大小增加到48字節。當我們的程序啟動后,runtime加載MyClass類定義的時候,發現基類的真實大小和MyClass的instanceStart不相符,得知基類的大小發生了改變。
于是runtime遍歷MyClass的所有成員變量定義,將offset指向的值增加8。具體的實現代碼在runtime/objc-runtime-new.mm的moveIvars()函數中。
并且,MyClass類定義的instanceSize也要增加8。這樣runtime在創建MyClass對象的時候,能分配出正確大小的內存塊
在博客的結尾,又提到了,為什么無法在運行時為類添加成員變量?
上面說過實例變量影響著對象的內存結構體,其實不但影響著當前類的實例內存,還影響著子類實例的內存。為基類動態增加成員變量會導致所有已創建出的子類實例都無法使用。
那為什么runtime允許動態添加方法和屬性,而不會引發問題呢?
因為方法和屬性并不“屬于”類實例,而成員變量“屬于”類實例。我們所說的“類實例”概念,指的是一塊內存區域,包含了isa指針和所有的成員變量。所以假如允許動態修改類成員變量布局,已經創建出的類實例就不符合類定義了,變成了無效對象。但方法定義是在objc_class中管理的,不管如何增刪類方法,都不影響類實例的內存布局,已經創建出的類實例仍然可正常使用。
上面說的類實例就是,我們說的對象在內存中的結構。另外上面提到了 obj->isa.cls->data()->ro 前面說過 ro 存儲了當前類在編譯期就已經確定的屬性、方法以及遵循的協議。在運行期間就不能改變了(只讀)
總結
1 在Objective-C,通過 -> 操作符,操作成員變量時,不是C語言指針操作,通過clang 可以發現
2 程序啟動后,runtime加載MyClass類定義的時候,比較