Objective-C Runtime 執行時之一:類與物件

南峰子的技術部落格發表於2014-11-11

Objective-C語言是一門動態語言,它將很多靜態語言在編譯和連結時期做的事放到了執行時來處理。這種動態語言的優勢在於:我們寫程式碼時更具靈活性,如我們可以把訊息轉發給我們想要的物件,或者隨意交換一個方法的實現等。

這種特性意味著Objective-C不僅需要一個編譯器,還需要一個執行時系統來執行編譯的程式碼。對於Objective-C來說,這個執行時系統就像一個作業系統一樣:它讓所有的工作可以正常的執行。這個執行時系統即Objc Runtime。Objc Runtime其實是一個Runtime庫,它基本上是用C和彙編寫的,這個庫使得C語言有了物件導向的能力。

Runtime庫主要做下面幾件事:

  1. 封裝:在這個庫中,物件可以用C語言中的結構體表示,而方法可以用C函式來實現,另外再加上了一些額外的特性。這些結構體和函式被runtime函式封裝後,我們就可以在程式執行時建立,檢查,修改類、物件和它們的方法了。
  2. 找出方法的最終執行程式碼:當程式執行[object doSomething]時,會向訊息接收者(object)傳送一條訊息(doSomething),runtime會根據訊息接收者是否能響應該訊息而做出不同的反應。這將在後面詳細介紹。

Objective-C runtime目前有兩個版本:Modern runtime和Legacy runtime。Modern Runtime 覆蓋了64位的Mac OS X Apps,還有 iOS Apps,Legacy Runtime 是早期用來給32位 Mac OS X Apps 用的,也就是可以不用管就是了。

在這一系列文章中,我們將介紹runtime的基本工作原理,以及如何利用它讓我們的程式變得更加靈活。在本文中,我們先來介紹一下類與物件,這是物件導向的基礎,我們看看在Runtime中,類是如何實現的。

類與物件基礎資料結構

Class

Objective-C類是由Class型別來表示的,它實際上是一個指向objc_class結構體的指標。它的定義如下:

檢視objc/runtime.h中objc_class結構體的定義如下:

在這個定義中,下面幾個欄位是我們感興趣的

  1. isa:需要注意的是在Objective-C中,所有的類自身也是一個物件,這個物件的Class裡面也有一個isa指標,它指向metaClass(元類),我們會在後面介紹它。
  2. super_class:指向該類的父類,如果該類已經是最頂層的根類(如NSObject或NSProxy),則super_class為NULL。
  3. cache:用於快取最近使用的方法。一個接收者物件接收到一個訊息時,它會根據isa指標去查詢能夠響應這個訊息的物件。在實際使用中,這個物件只有一部分方法是常用的,很多方法其實很少用或者根本用不上。這種情況下,如果每次訊息來時,我們都是methodLists中遍歷一遍,效能勢必很差。這時,cache就派上用場了。在我們每次呼叫過一個方法後,這個方法就會被快取到cache列表中,下次呼叫的時候runtime就會優先去cache中查詢,如果cache沒有,才去methodLists中查詢方法。這樣,對於那些經常用到的方法的呼叫,但提高了呼叫的效率。
  4. version:我們可以使用這個欄位來提供類的版本資訊。這對於物件的序列化非常有用,它可是讓我們識別出不同類定義版本中例項變數佈局的改變。

針對cache,我們用下面例子來說明其執行過程:

其流程是:

  1. [NSArray alloc]先被執行。因為NSArray沒有+alloc方法,於是去父類NSObject去查詢。
  2. 檢測NSObject是否響應+alloc方法,發現響應,於是檢測NSArray類,並根據其所需的記憶體空間大小開始分配記憶體空間,然後把isa指標指向NSArray類。同時,+alloc也被加進cache列表裡面。
  3. 接著,執行-init方法,如果NSArray響應該方法,則直接將其加入cache;如果不響應,則去父類查詢。
  4. 在後期的操作中,如果再以[[NSArray alloc] init]這種方式來建立陣列,則會直接從cache中取出相應的方法,直接呼叫。

objc_object與id

objc_object是表示一個類的例項的結構體,它的定義如下(objc/objc.h):

可以看到,這個結構體只有一個字型,即指向其類的isa指標。這樣,當我們向一個Objective-C物件傳送訊息時,執行時庫會根據例項物件的isa指標找到這個例項物件所屬的類。Runtime庫會在類的方法列表及父類的方法列表中去尋找與訊息對應的selector指向的方法。找到後即執行這個方法。

當建立一個特定類的例項物件時,分配的記憶體包含一個objc_object資料結構,然後是類的例項變數的資料。NSObject類的alloc和allocWithZone:方法使用函式class_createInstance來建立objc_object資料結構。

另外還有我們常見的id,它是一個objc_object結構型別的指標。它的存在可以讓我們實現類似於C++中泛型的一些操作。該型別的物件可以轉換為任何一種物件,有點類似於C語言中void *指標型別的作用。

objc_cache

上面提到了objc_class結構體中的cache欄位,它用於快取呼叫過的方法。這個欄位是一個指向objc_cache結構體的指標,其定義如下:

該結構體的欄位描述如下:

  1. mask:一個整數,指定分配的快取bucket的總數。在方法查詢過程中,Objective-C runtime使用這個欄位來確定開始線性查詢陣列的索引位置。指向方法selector的指標與該欄位做一個AND位操作(index = (mask & selector))。這可以作為一個簡單的hash雜湊演算法。
  2. occupied:一個整數,指定實際佔用的快取bucket的總數。
  3. buckets:指向Method資料結構指標的陣列。這個陣列可能包含不超過mask+1個元素。需要注意的是,指標可能是NULL,表示這個快取bucket沒有被佔用,另外被佔用的bucket可能是不連續的。這個陣列可能會隨著時間而增長。

元類(Meta Class)

在上面我們提到,所有的類自身也是一個物件,我們可以向這個物件傳送訊息(即呼叫類方法)。如:

這個例子中,+array訊息傳送給了NSArray類,而這個NSArray也是一個物件。既然是物件,那麼它也是一個objc_object指標,它包含一個指向其類的一個isa指標。那麼這些就有一個問題了,這個isa指標指向什麼呢?為了呼叫+array方法,這個類的isa指標必須指向一個包含這些類方法的一個objc_class結構體。這就引出了meta-class的概念

當我們向一個物件傳送訊息時,runtime會在這個物件所屬的這個類的方法列表中查詢方法;而向一個類傳送訊息時,會在這個類的meta-class的方法列表中查詢。

meta-class之所以重要,是因為它儲存著一個類的所有類方法。每個類都會有一個單獨的meta-class,因為每個類的類方法基本不可能完全相同。

再深入一下,meta-class也是一個類,也可以向它傳送一個訊息,那麼它的isa又是指向什麼呢?為了不讓這種結構無限延伸下去,Objective-C的設計者讓所有的meta-class的isa指向基類的meta-class,以此作為它們的所屬類。即,任何NSObject繼承體系下的meta-class都使用NSObject的meta-class作為自己的所屬類,而基類的meta-class的isa指標是指向它自己。這樣就形成了一個完美的閉環。

通過上面的描述,再加上對objc_class結構體中super_class指標的分析,我們就可以描繪出類及相應meta-class類的一個繼承體系了,如下圖所示:

1413628797629491

 

對於NSObject繼承體系來說,其例項方法對體系中的所有例項、類和meta-class都是有效的;而類方法對於體系內的所有類和meta-class都是有效的。

講了這麼多,我們還是來寫個例子吧:

這個例子是在執行時建立了一個NSError的子類TestClass,然後為這個子類新增一個方法testMetaClass,這個方法的實現是TestMetaClass函式。

執行後,列印結果是

我們在for迴圈中,我們通過objc_getClass來獲取物件的isa,並將其列印出來,依此一直回溯到NSObject的meta-class。分析列印結果,可以看到最後指標指向的地址是0x0,即NSObject的meta-class的類地址。

這裡需要注意的是:我們在一個類物件呼叫class方法是無法獲取meta-class,它只是返回類而已。

類與物件操作函式

runtime提供了大量的函式來操作類與物件。類的操作方法大部分是以class為字首的,而物件的操作方法大部分是以objc或object_為字首。下面我們將根據這些方法的用途來分類討論這些方法的使用。

類相關操作函式

我們可以回過頭去看看objc_class的定義,runtime提供的操作類的方法主要就是針對這個結構體中的各個欄位的。下面我們分別介紹這一些的函式。並在最後以例項來演示這些函式的具體用法。

類名(name)

類名操作的函式主要有:

  • 對於class_getName函式,如果傳入的cls為Nil,則返回一個字字串。

父類(super_class)和元類(meta-class)

父類和元類操作的函式主要有:

  • class_getSuperclass函式,當cls為Nil或者cls為根類時,返回Nil。不過通常我們可以使用NSObject類的superclass方法來達到同樣的目的。
  • class_isMetaClass函式,如果是cls是元類,則返回YES;如果否或者傳入的cls為Nil,則返回NO。

例項變數大小(instance_size)

例項變數大小操作的函式有:

成員變數(ivars)及屬性

在objc_class中,所有的成員變數、屬性的資訊是放在連結串列ivars中的。ivars是一個陣列,陣列中每個元素是指向Ivar(變數資訊)的指標。runtime提供了豐富的函式來操作這一欄位。大體上可以分為以下幾類:

1.成員變數操作函式,主要包含以下函式:

  • class_getInstanceVariable函式,它返回一個指向包含name指定的成員變數資訊的objc_ivar結構體的指標(Ivar)。
  • class_getClassVariable函式,目前沒有找到關於Objective-C中類變數的資訊,一般認為Objective-C不支援類變數。注意,返回的列表不包含父類的成員變數和屬性。
  • Objective-C不支援往已存在的類中新增例項變數,因此不管是系統庫提供的提供的類,還是我們自定義的類,都無法動態新增成員變數。但如果我們通過執行時來建立一個類的話,又應該如何給它新增成員變數呢?這時我們就可以使用class_addIvar函式了。不過需要注意的是,這個方法只能在objc_allocateClassPair函式與objc_registerClassPair之間呼叫。另外,這個類也不能是元類。成員變數的按位元組最小對齊量是1<<alignment。這取決於ivar的型別和機器的架構。如果變數的型別是指標型別,則傳遞log2(sizeof(pointer_type))。
  • class_copyIvarList函式,它返回一個指向成員變數資訊的陣列,陣列中每個元素是指向該成員變數資訊的objc_ivar結構體的指標。這個陣列不包含在父類中宣告的變數。outCount指標返回陣列的大小。需要注意的是,我們必須使用free()來釋放這個陣列。

2.屬性操作函式,主要包含以下函式:

這一種方法也是針對ivars來操作,不過只操作那些是屬性的值。我們在後面介紹屬性時會再遇到這些函式。

3.在MAC OS X系統中,我們可以使用垃圾回收器。runtime提供了幾個函式來確定一個物件的記憶體區域是否可以被垃圾回收器掃描,以處理strong/weak引用。這幾個函式定義如下:

但通常情況下,我們不需要去主動呼叫這些方法;在呼叫objc_registerClassPair時,會生成合理的佈局。在此不詳細介紹這些函式。

方法(methodLists)

方法操作主要有以下函式:

  • class_addMethod的實現會覆蓋父類的方法實現,但不會取代本類中已存在的實現,如果本類中包含一個同名的實現,則函式會返回NO。如果要修改已存在實現,可以使用method_setImplementation。一個Objective-C方法是一個簡單的C函式,它至少包含兩個引數—self和_cmd。所以,我們的實現函式(IMP引數指向的函式)至少需要兩個引數,如下所示:

與成員變數不同的是,我們可以為類動態新增方法,不管這個類是否已存在。

另外,引數types是一個描述傳遞給方法的引數型別的字元陣列,這就涉及到型別編碼,我們將在後面介紹。

  • class_getInstanceMethod、class_getClassMethod函式,與class_copyMethodList不同的是,這兩個函式都會去搜尋父類的實現。
  • class_copyMethodList函式,返回包含所有例項方法的陣列,如果需要獲取類方法,則可以使用class_copyMethodList(object_getClass(cls), &count)(一個類的例項方法是定義在元類裡面)。該列表不包含父類實現的方法。outCount引數返回方法的個數。在獲取到列表後,我們需要使用free()方法來釋放它。
  • class_replaceMethod函式,該函式的行為可以分為兩種:如果類中不存在name指定的方法,則類似於class_addMethod函式一樣會新增方法;如果類中已存在name指定的方法,則類似於method_setImplementation一樣替代原方法的實現。
  • class_getMethodImplementation函式,該函式在向類例項傳送訊息時會被呼叫,並返回一個指向方法實現函式的指標。這個函式會比method_getImplementation(class_getInstanceMethod(cls, name))更快。返回的函式指標可能是一個指向runtime內部的函式,而不一定是方法的實際實現。例如,如果類例項無法響應selector,則返回的函式指標將是執行時訊息轉發機制的一部分。
  • class_respondsToSelector函式,我們通常使用NSObject類的respondsToSelector:或instancesRespondToSelector:方法來達到相同目的。

協議(objc_protocol_list)

協議相關的操作包含以下函式:

  • class_conformsToProtocol函式可以使用NSObject類的conformsToProtocol:方法來替代。
  • class_copyProtocolList函式返回的是一個陣列,在使用後我們需要使用free()手動釋放。

版本(version)

版本相關的操作包含以下函式:

其它

runtime還提供了兩個函式來供CoreFoundation的tool-free bridging使用,即:

通常我們不直接使用這兩個函式。

例項(Example)

上面列舉了大量類操作的函式,下面我們寫個例項,來看看這些函式的例項效果:

這段程式的輸出如下:

動態建立類和物件

runtime的強大之處在於它能在執行時建立類和物件。

動態建立類

動態建立類涉及到以下幾個函式:

  • objc_allocateClassPair函式:如果我們要建立一個根類,則superclass指定為Nil。extraBytes通常指定為0,該引數是分配給類和元類物件尾部的索引ivars的位元組數。

為了建立一個新類,我們需要呼叫objc_allocateClassPair。然後使用諸如class_addMethod,class_addIvar等函式來為新建立的類新增方法、例項變數和屬性等。完成這些後,我們需要呼叫objc_registerClassPair函式來註冊類,之後這個新類就可以在程式中使用了。

例項方法和例項變數應該新增到類自身上,而類方法應該新增到類的元類上。

  • objc_disposeClassPair函式用於銷燬一個類,不過需要注意的是,如果程式執行中還存在類或其子類的例項,則不能呼叫針對類呼叫該方法。

在前面介紹元類時,我們已經有接觸到這幾個函式了,在此我們再舉個例項來看看這幾個函式的使用。

程式的輸出如下:

動態建立物件

動態建立物件的函式如下:

  • class_createInstance函式:建立例項時,會在預設的記憶體區域為類分配記憶體。extraBytes參數列示分配的額外位元組數。這些額外的位元組可用於儲存在類定義中所定義的例項變數之外的例項變數。該函式在ARC環境下無法使用。

呼叫class_createInstance的效果與+alloc方法類似。不過在使用class_createInstance時,我們需要確切的知道我們要用它來做什麼。在下面的例子中,我們用NSString來測試一下該函式的實際效果:

輸出結果是:

可以看到,使用class_createInstance函式獲取的是NSString例項,而不是類簇中的預設佔位符類__NSCFConstantString。

  • objc_constructInstance函式:在指定的位置(bytes)建立類例項。
  • objc_destructInstance函式:銷燬一個類的例項,但不會釋放並移除任何與其相關的引用。

例項操作函式

例項操作函式主要是針對我們建立的例項物件的一系列操作函式,我們可以使用這組函式來從例項物件中獲取我們想要的一些資訊,如例項物件中變數的值。這組函式可以分為三小類:

1.針對整個物件進行操作的函式,這類函式包含

有這樣一種場景,假設我們有類A和類B,且類B是類A的子類。類B通過新增一些額外的屬性來擴充套件類A。現在我們建立了一個A類的例項物件,並希望在執行時將這個物件轉換為B類的例項物件,這樣可以新增資料到B類的屬性中。這種情況下,我們沒有辦法直接轉換,因為B類的例項會比A類的例項更大,沒有足夠的空間來放置物件。此時,我們就要以使用以上幾個函式來處理這種情況,如下程式碼所示:

2.針對物件例項變數進行操作的函式,這類函式包含:

如果例項變數的Ivar已經知道,那麼呼叫object_getIvar會比object_getInstanceVariable函式快,相同情況下,object_setIvar也比object_setInstanceVariable快。

3.針對物件的類進行操作的函式,這類函式包含:

獲取類定義

Objective-C動態執行庫會自動註冊我們程式碼中定義的所有的類。我們也可以在執行時建立類定義並使用objc_addClass函式來註冊它們。runtime提供了一系列函式來獲取類定義相關的資訊,這些函式主要包括:

  •  objc_getClassList函式:獲取已註冊的類定義的列表。我們不能假設從該函式中獲取的類物件是繼承自NSObject體系的,所以在這些類上呼叫方法是,都應該先檢測一下這個方法是否在這個類中實現。

下面程式碼演示了該函式的用法:

輸出結果如下:

獲取類定義的方法有三個:objc_lookUpClass, objc_getClass和objc_getRequiredClass。如果類在執行時未註冊,則objc_lookUpClass會返回nil,而objc_getClass會呼叫類處理回撥,並再次確認類是否註冊,如果確認未註冊,再返回nil。而objc_getRequiredClass函式的操作與objc_getClass相同,只不過如果沒有找到類,則會殺死程式。

  • objc_getMetaClass函式:如果指定的類沒有註冊,則該函式會呼叫類處理回撥,並再次確認類是否註冊,如果確認未註冊,再返回nil。不過,每個類定義都必須有一個有效的元類定義,所以這個函式總是會返回一個元類定義,不管它是否有效。

小結

在這一章中我們介紹了Runtime執行時中與類和物件相關的資料結構,通過這些資料函式,我們可以管窺Objective-C底層物件導向實現的一些資訊。另外,通過豐富的操作函式,可以靈活地對這些資料進行操作。

相關文章