id 與 instancetype

這星期我們再來看個有點冷門但我覺得還滿有趣的小東西:instancetype。如果我們去翻一下 NSObjectallocinit 的定義:

// 檔案:NSObject.h
@interface NSObject <NSObject> {
    Class isa  OBJC_ISA_AVAILABILITY;
}

- (id)init;
+ (id)new;
+ (id)alloc;

會發現 allocinit 的回傳型態都是 id。而在上一篇提到,在 Objective-C 裡 id 是一個可以指向任何物件的指針,所以如果這樣寫的話:

NSArray* myArray = [[NSArray alloc] init];

看起好像沒什麼問題,執行起來也正常,但這裡就有個小小的疑惑了.. 既然 allocinit 都是回傳 id 型別,Objective-C 是個動態語言,很多資訊是在執行階段(runtime)才會取得,那編譯器(compiler)又是怎麼知道它應該要是個 NSArray?

根據 Clang 的文件說明,原來當我們寫 [NSArray alloc] 的時候,訊息接受者(receiver,也就是 NSArray)收到訊息(message,也就是 alloc),它並不是真的就乖乖的就只傳回 id 型別,而是回傳 receiver 的型別(a.k.a related result type),在這個例子就是 NSArray。同理 init 也是一樣,所以 [[NSArray alloc] init] 也會偷偷的回傳 NSArray 型別。在 Objective-C 裡,allocnewinit 等方法都享有這個特別的服務。

但如果不是這些享有特別服務的方法呢? 例如:

@interface Animal : NSObject
+ (id) createAnimal;
@end

@implementation Animal
+ (id) createAnimal
{
    return [[self alloc] init];
}
@end

@interface Fox : Animal
- (void) say;
@end

@implementation Fox
- (void) say
{
    NSLog(@"what does the fox say!?");
}
@end

這裡我們建立了兩個類別,一個 Animal 一個 FoxFox 繼承自 Animal,並且在 Animal 定義了一個類別方法 createAnimal。看起來很正常,但如果接下來不小心這樣寫的話:

[[Animal createAnimal] say];

上面這行在編譯階段沒問題,但執行之後就會 crash 了。我們一般不會這樣寫,因為我們光用肉眼就能發現問題在哪裡,父類別 Animal 根本沒有定義或實作 say 方法所以理所當然的會 crash。但為什麼這麼明顯的錯誤在 Xcode 裡沒被挑出來? 因為 +createAnimal 這個方法回傳的是 id,編譯器沒辦法在編譯階段從 id 推敲出它真正的型別,所以只好先放它過關,然後在執行階段就 crash 了。(關於為什麼是在執行階段才知道型別,可參考這一篇的介紹)

你當然也可以在執行階段再用 respondsToSelector: 之類的方法來檢查,但事實上這個工作可以交給編譯器來做,只要把 id 換成 instancetype,像這樣:

@interface Animal : NSObject
+ (instancetype) createAnimal;
@end

@implementation Animal
+ (instancetype) createAnimal
{
    return [[self alloc] init];
}
@end

這樣編譯器就會在原本會 crash 的那行跳出一個紅色的警告:

Error

寫著:

No visible @interface for 'Animal' declares the selector 'say'

在編譯過程就會幫你把這個問題抓出來了。使用 instancetype 的另一個好處,就是在子類別也可以正確的知道子類別的型別,例如你不小心這樣寫:

[[Fox createAnimal] addObject:@"hello, fox!"];

如果這邊回傳的是 id 的話,上面這行在編譯階段也不會有錯,但執行就 crash 了(除非你剛好有幫 Fox 類別實作了 addObject: 方法)。如果改用 instancetype 的話,編譯器就會把問題在編譯階段就抓出來了。

什麼是 instancetype?

引用 Clang 文件的一段話:

"instancetype is a contextual keyword that is only permitted in the result type of an Objective-C method"
- Clang Language Extensions

其實 instancetype 就只是個關鍵字(keyword),它告訴編譯器回傳型態,讓編譯器可以在編譯階段就有足夠的資訊可以來判斷你寫的程式碼是不是有問題。

用 instancetype 取代 id?

WWDC 2013 的影片(404 - Advances in Objective-C)提到在新版的 SDK 加入了 instancetype 這個型別。其實 instancetype 並不是很新的東西,不過 Apple 在最近推出的 SDK 開始把 id 改換成 instancetype,例如我們隨便打開一個內建的類別的 header,例如 NSArray.h 來看看:

// 檔案:NSArry.h
+ (instancetype)array;
+ (instancetype)arrayWithObject:(id)anObject;
+ (instancetype)arrayWithObjects:(const id [])objects count:(NSUInteger)cnt;
+ (instancetype)arrayWithObjects:(id)firstObj, ... NS_REQUIRES_NIL_TERMINATION;
+ (instancetype)arrayWithArray:(NSArray *)array;

- (instancetype)init;/* designated initializer */
- (instancetype)initWithObjects:(const id [])objects count:(NSUInteger)cnt;/* designated initializeralizer */

- (instancetype)initWithObjects:(id)firstObj, ... NS_REQUIRES_NIL_TERMINATION;
- (instancetype)initWithArray:(NSArray *)array;
- (instancetype)initWithArray:(NSArray *)array copyItems:(BOOL)flag;

NSArray 的 initializer 以及一些 class method 的回傳型態都也都是改用 instancetype 了。

所以意思是要用 instancetype 來取得 id 的意思嗎? 其實不是的。

Clang 的文件提到 instancetype 是 "only permitted in the result type of an Objective-C method", 也就是說,instancetype 只能作為回傳值,不能作為參數,像這樣:

- (void) clickAction:(id) sender;            // 這樣寫沒問題
- (void) clickAction:(instancetype) sender;  // 但這樣寫是不行的

簡單的說,instancetype 主要的目的是為了幫助編譯器更了解你的程式碼,提早在編譯階段就發現問題。

至於之前已經寫好的程式碼需要整個用 instancetype 再重新改寫嗎? 其實也沒必要,不改也不會怎麼樣,因為編譯器本來就會幫 allocnewinit 之類的方法傳回適當的型別,不過如果是新的專案,倒是建議可以試著在適當的地方開始使用 instancetype

話說,研究這種有點冷門的東西對 iOS app 的開發雖然不會有直接明顯的幫助,但對整個 Objective-C / Cocoa Framework 可以有更進一步的認識,可以更知道我寫的程式碼到底實際上是怎麼運作的,我個人覺得這樣挺有趣的。