標籤:
目錄
- Non Fragile ivars
- 為什麼Non Fragile ivars很關鍵
- 如何定址類成員變數
- 真正的“如何定址類成員變數”
- Non Fragile ivars布局調整
- 為什麼Objective-C類不能動態新增成員變數
- 總結
看下面的代碼,考慮Objective-C裡最常見的操作之一——類成員變數訪問。
- (void)doSomething:(SomeClass *)obj{ obj->ivar1 = 42; // 訪問obj對象的public成員變數 int n = self->ivar2; // 訪問當前類執行個體的成員變數 ivar2 = n + 1; // 訪問當前類的成員變數}
可能大多數人都沒有意識到的是:
- Objective-C的
->
操作符不是C語言指標操作!
- Objective-C對象不能簡單對應於一個C struct,訪問成員變數不等於訪問C struct成員!
我一直到昨天中午之前也不知道這些。當明白真相後,發現還沒有文章真正講清楚過Objective-C的類成員變數(ivar,instance variables,類執行個體變數),於是有必要做個深度剖析。
Non Fragile ivars
我們常說Objective-C是“C語言的超集”,直覺上認為C語言的文法和特性在Objective-C裡都有,Objective-C只是在C的基礎上增加了物件導向、動態特性、block等等。我也一直不假思索地以為,Objective-C的成員變數跟C++相同。在C++中,成員變數的訪問會被編譯器轉成一條指令,用“對象地址”加“成員變數位移值”即可訪問到成員變數的值。
昨天一個朋友問我runtime的問題,我看著“non-fragile instance variables”的概念,突然意識到,這不能用C++的對象記憶體模型來解決。
The most notable new feature is that instance variables in the modern runtime are “non-fragile”:
In the legacy runtime, if you change the layout of instance variables in a class, you must recompile classes that inherit from it.
In the modern runtime, if you change the layout of instance variables in a class, you do not have to recompile classes that inherit from it.
這是蘋果官方文檔Objective-C Runtime Programming Guide上的一段話,意思是在“modern runtime”裡,如果你修改了基類的成員變數布局(比如增加成員變數),子類不需要重新編譯。這是一個巨大的改動,在文檔中當做“modern runtime”最重要的修改點被提出來。
Cocoa Samurai的文章Understanding the Objective-C Runtime用幾張圖清晰地解釋了Non Fragile ivars。以下藉助他的圖舉例說明。
1) 用舊版OSX SDK編譯的MyObject類成員變數布局是這樣的,MyObject的成員變數依次排列在基類NSObject的成員後面。
2) 當蘋果發布新版本OSX SDK後,NSObject增加了兩個成員變數。如果沒有Non Fragile ivars特性,我們的代碼將無法正常運行,因為MyObject類成員變數布局在編譯時間已經確定,有兩個成員變數和基類的記憶體地區重疊了。此時,我們只能重新編譯MyObject代碼,程式才能在新版本系統上運行。如果更悲催一點,MyObject類是來自第三方提供的靜態庫,我們就只能眼巴巴等著庫作者更新版本了。
3) Non Fragile ivars特性出場了。在程式啟動後,runtime載入MyObject類的時候,通過計算基類的大小,runtime動態調整了MyObject類成員變數布局,把MyObject成員變數的位置向後移動8個位元組。於是我們的程式無需編譯,就能在新版本系統上運行。
為什麼Non Fragile ivars很關鍵
這個特性的重大意義在於,Objective-C的庫從此具有了“二進位相容性”。舉例來說,你在項目裡用了第三方提供的靜態庫SDK,包含一些.h
和一個.a
檔案。當iOS SDK的版本從6升到了7,又從7升到了8時,你都不需要更新這個SDK。雖然iOS SDK版本升級時,蘋果在UIView等基類中加入了更多的成員變數,但是以前發布的靜態庫SDK不需要重新編譯還能正常使用。
幸好我們已經不在那個黑暗時代了,iOS從一開始就是用的modern runtime。可以想象以前的Mac開發人員是如何忍受這個問題的:每次MacOS發布新版本,都要重新編譯自己的程式,跟著發布新版本。
Non Fragile ivars的基本原理就是這樣。聽起來並沒多麼先進,很多程式設計語言都能做到,比如Java、C#,都有二進位相容性。可是Objective-C畢竟不是“那麼”動態語言,Objective-C代碼編譯後是真正的native二進位,不是byte code。Objective-C程式也不是運行在VM上,底下只有個很小的runtime。這兩點,Java、C#做不到。
那Non Fragile ivars是如何?的呢?最關鍵的點是,當成員變數布局調整後,靜態編譯的native程式怎麼能找到變數的新位移位置呢?
如何定址類成員變數
我們藉助兩個工具來探索答案:Objective-C runtime源碼和LLVM。
首先去http://opensource.apple.com/下載runtime源碼,在“OSX”分類裡,當前最新版本是objc4-646.tar.gz。解壓後開啟Xcode工程,尋找struct objc_object
定義。
我們已經知道,每個Objective-C對象對應於struct objc_object
,後者的isa
指向類定義,即struct objc_class
。
struct objc_object {private: isa_t isa; //...};struct objc_class : objc_object { // Class ISA; Class superclass; cache_t cache; // formerly cache pointer and vtable class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags class_rw_t *data() { return bits.data(); } //...};
沿著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
,這裡一定是記錄著這個成員變數在對象中的位移位置嘍。也就是說,runtime在發現基類大小變化時,通過修改offset
,來更新子類成員變數的位移值。那Objective-C中擷取對象的第N個成員變數位移位置就需要這樣一長串代碼:
*((&obj->isa.cls->data()->ro->ivars->first)[N]->offset)
這麼多次定址,看起來很可怕吧。每個成員變數都這樣訪問的話,效能一定無法接受。看看編譯器到底是如何?的吧,我們祭出LLVM。
真正的“如何定址類成員變數”
LLVM在編譯時間,首先產生一種中繼語言(IR,intermediate representation);後續的一些最佳化、分析步驟都在IR上進行;最後再把IR轉化成native可執行檔。由於IR比彙編可讀性要好,我們利用IR來分析編譯後的Objective-C程式是怎麼執行的。
建立測試代碼test.m
。
#import <Foundation/Foundation.h>// 特意選個大一點的基類,方便看@interface MyClass : NSError {@public int myInt;}@end@implementation MyClass@endint main(){ MyClass *obj = [[MyClass alloc] init]; obj->myInt = 42;}
在命令列執行
clang -cc1 -S -emit-llvm -fblocks test.m
編譯結果test.ll
就是LLVM IR代碼。推薦用Sublime Text安裝LLVM外掛程式,有文法高亮。可以看到IR格式比較繁瑣,比彙編簡單,比C複雜。這裡就不寫出IL的分析過程了,直接說結論。
編譯後的obj->myInt = 42
調用對應於如下的簡單C語言代碼。
int32_t g_ivar_MyClass_myInt = 40; // 全域變數*(int32_t *)((uint8_t *)obj + g_ivar_MyClass_myInt) = 42;
兩條CPU指令搞定。第一條取g_ivar_MyClass_myInt
的值,第二條定址並賦值。根本不需要一長串的指標調用。LLVM為每個類的每個成員變數都分配了一個全域變數,用於儲存該成員變數的位移值。
這也就是為什麼ivar_t.offset
用int指標來儲存位移值,而不是直接放一個int的原因。在這個設計中,真正存放位移值的地址是固定不變的,在編譯時間就確定了下來。因此才能用區區2條指令搞定動態布局的成員變數。
這就是Objective-C類成員變數的定址方式。編譯器通過這種方式,達到了靈活性和執行效率的完美平衡!
Non Fragile ivars布局調整
有了這種靈活而高效的定址方式,那runtime是在什麼時候調整成員變數位移值的呢?從IR中可以看到,在編譯時間,LLVM計算出基類NSError對象的大小為40位元組,然後記錄在MyClass的類定義中,如下是對應的C代碼。在編譯後的可執行程式中,寫死了“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對象的時候,能分配出正確大小的記憶體塊。
為什麼Objective-C類不能動態新增成員變數
這個問題的答案與Non Fragile ivars無關,但既然此文是關於類成員變數的,因此一併討論。很多人在學到Category時都會有疑問,既然允許用Category給類增加方法和屬性,那為什麼不允許增加成員變數?
在Objective-C提供的runtime函數中,確實有一個class_addIvar()
函數用於給類新增成員變數,但是文檔中特別說明:
This function may only be called after objc_allocateClassPair and before objc_registerClassPair. Adding an instance variable to an existing class is not supported.
意思是說,這個函數只能在“構建一個類的過程中”調用。一旦完成類定義,就不能再新增成員變數了。經過編譯的類在程式啟動後就被runtime載入,沒有機會調用addIvar。程式在運行時動態構建的類需要在調用objc_registerClassPair
之後才可以被使用,同樣沒有機會再新增成員變數。
我們設想一下如果Objective-C允許動態增加成員變數,會發生什麼事情。假設如下代碼可以執行。
MyObject *obj = [[MyObject alloc] init];// 基類增加一個4位元組的成員變數someVarclass_addIvar([NSObject class], "someVar", 4, ...);// 基類增加方法someMethod,用到了someVarclass_addMethod([NSObject class], @selector(someMethod), ...);// 調用someMethod,修改了someVar[obj someMethod];// 訪問子類成員變數,會發生什嗎?[obj->students length];
顯然,這樣做會帶來嚴重問題,為基類動態增加成員變數會導致所有已建立出的子類執行個體都無法使用。那為什麼runtime允許動態添加方法和屬性,而不會引發問題呢?
因為方法和屬性並不“屬於”類執行個體,而成員變數“屬於”類執行個體。我們所說的“類執行個體”概念,指的是一塊記憶體地區,包含了isa指標和所有的成員變數。所以假如允許動態修改類成員變數布局,已經建立出的類執行個體就不符合類定義了,變成了無效對象。但方法定義是在objc_class
中管理的,不管如何增刪類方法,都不影響類執行個體的記憶體布局,已經建立出的類執行個體仍然可正常使用。
總結
Objective-C的“Non Fragile ivars”特性,以極低的運行時開銷換取了程式的二進位相容性。並且可執行檔仍然是目標平台上的native程式,不需要運行在VM上。實在是設計權衡取捨的典範。
Objective-C類成員變數深度剖析