Objective-C類成員變量深度剖析

原文鏈接:http://t.cn/R24lioj?u=1708947107&m=3845622346543604&cu=1839484950&ru=1796149773&rm=3845088835182767

參考鏈接:http://www.sealiesoftware.com/blog/archive/2009/01/27/objc_explain_Non-fragile_ivars.html

目錄

  • 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的成員後面。

舊版本SDK的成員變量佈局

2) 當蘋果發佈新版本OSX SDK後,NSObject增加了兩個成員變量。如果沒有Non Fragile ivars特性,我們的代碼將無法正常運行,因爲MyObject類成員變量佈局在編譯時已經確定,有兩個成員變量和基類的內存區域重疊了。此時,我們只能重新編譯MyObject代碼,程序才能在新版本系統上運行。如果更悲催一點,MyObject類是來自第三方提供的靜態庫,我們就只能眼巴巴等着庫作者更新版本了。

新版本SDK的成員變量佈局

3) Non Fragile ivars特性出場了。在程序啓動後,runtime加載MyObject類的時候,通過計算基類的大小,runtime動態調整了MyObject類成員變量佈局,把MyObject成員變量的位置向後移動8個字節。於是我們的程序無需編譯,就能在新版本系統上運行。

Runtime調整後的佈局

爲什麼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_classdata()->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
@end

int 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.offsetint指針來存儲偏移值,而不是直接放一個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.mmmoveIvars()函數中。

並且,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字節的成員變量someVar
class_addIvar([NSObject class], "someVar", 4, ...);
// 基類增加方法someMethod,用到了someVar
class_addMethod([NSObject class], @selector(someMethod), ...);

// 調用someMethod,修改了someVar
[obj someMethod];

// 訪問子類成員變量,會發生什麼?
[obj->students length];

顯然,這樣做會帶來嚴重問題,爲基類動態增加成員變量會導致所有已創建出的子類實例都無法使用。那爲什麼runtime允許動態添加方法和屬性,而不會引發問題呢?

因爲方法和屬性並不“屬於”類實例,而成員變量“屬於”類實例。我們所說的“類實例”概念,指的是一塊內存區域,包含了isa指針和所有的成員變量。所以假如允許動態修改類成員變量佈局,已經創建出的類實例就不符合類定義了,變成了無效對象。但方法定義是在objc_class中管理的,不管如何增刪類方法,都不影響類實例的內存佈局,已經創建出的類實例仍然可正常使用。

總結

Objective-C的“Non Fragile ivars”特性,以極低的運行時開銷換取了程序的二進制兼容性。並且可執行文件仍然是目標平臺上的native程序,不需要運行在VM上。實在是設計權衡取捨的典範。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章