Objective-C的陷阱

原文地址:Friday Q&A 2012-12-14: Objective-C Pitfalls

原文是Friday Q&A系列中的一篇文章,每週五更新一個文章,很不錯的系列,一般是Objective-C/Mac/iOS相關的內容。

前段時間想着說隨便翻譯一個,就挑了一個,感覺看明白容易,要翻譯的好,還真是麻煩。
 
 
Objective-C 陷阱
 
Objective-C是一個強大而且非常有用的語言,但是他同樣也是有一點危險的。今天的主題是受到Cay S. Horstmann的article on C++ pitfalls啓發,我的同事Chris Denter推薦我來聊聊Objective-C和Cocoa中的陷阱。
 
簡介
我將和Horstmann使用同樣的定義:陷阱是能夠編譯、鏈接、運行,但卻不會按你所預期地去執行的代碼。他提供了一個例子,這段代碼在Objective-C中和在C++中同樣都是有問題的:
    if (-0.5 <= x <= 0.5) return 0;
膚淺地閱讀這段代碼可能會認爲,它用來檢查x是不是在[-0.5,0.5]區間內。但並不是這樣的。相反,這個比較會像這樣計算:
    if ((-0.5 <= x) <= 0.5)
C語言中,一個比較表達式的值是一個整型,要麼是0,要麼是 1。這是從C沒有內建的布爾類型的時候遺留下來的。所以當x和0.5相比時,結果是0或者1,而不是x的值。實際上,第二個比較執行起來就像一個相當古怪的否定操作符,也就是說這個if語句的內容只有當x比-0.5小的時候纔會執行。
Nil的比較
Objective-C相當的與衆不同,因爲對nil發送消息不會發生任何事情,而是簡單的返回0。基本上,在你可能遇到的每種語言中,同樣的事情要麼被類型系統禁止,要麼就是產生一個運行時錯誤。這既是優點也是缺點。鑑於這個文章的主題,我們來關注下缺點。
首先,我們看一個等同性的測試:
    [nil isEqual: @"string"]
nil發送消息總是返回0,在這兒就相當於NO。這次恰好是正確的答案,所以看起來我們有個不錯的開頭!但是,看看這個:
    [nil isEqual: nil]
這個也是返回NO。即使參數是完全相同的值也無關緊要。參數的值到底是什麼根本不重要,因爲給nil發送消息不管怎樣總是返回0。所以用isEqual:來判斷,nil永遠不會等同於任何東西,包括它自身。大多情況下這是正確的,但不總是。
最後,再考慮和nil比較的另一種順序:
    [@"string" isEqual: nil]
這個會怎樣呢?好吧,我們無法確定。它有可能返回NO,也有可能會拋出異常,還有可能乾脆崩潰。給一個沒有明確告知可以接受nil爲參數的方法傳遞nil是一個壞注意。並且,isEqual:並沒有表明它可以接受nil。
很多Cocoa類都包含一個compare:方法。該方法接受相同類的另一個對象作爲參數,並返回NSOrderedAscending、 NSOrderedSame、NSOrderedDescending中的一個,用於表示小於、相等或者大於。
如果我們把nil傳給compare會發生什麼事情呢?
    [nil compare: nil]
這會返回0,剛好和NSOrderedSame相同。與isEqual:不同,compare:認爲nil和nil是相同的。真好!但是:
    [nil compare: @"string"]
這一樣會返回NSOrderedSame,明顯是錯誤的答案。compare:會認爲nil和任何東西都相等。
最終,和isEqual:一樣,將nil作爲參數傳遞給它也是個壞注意:
    [@"string" compare: nil]
簡而言之,對nil進行比較的時候要注意點。它並不會真的正常工作。如果你的代碼中有可能遇到nil,那麼在你進行isEqual:和compare:之前,你最好先進行檢查並對之進行單獨處理。
散列法
你寫了個很小的類用於保存一些數據,並且有很多的這個類的相等的實例,所以你實現了isEqual:方法,這樣這些實例就可以被視爲相等的。然後你開始將這些對象加入到一個NSSet當中,事情就開始變得奇怪了。這個集合(set)在你僅僅加入一個對象的情況下聲稱持有多個實例。它找不到你剛剛加入的對象。它甚至可能崩潰或者發生內存錯誤。

這可能在你只實現了isEqual:但是沒有實現hash的情況下發生。大量的Cocoa代碼中要求,如果兩個對象比較的結果是相等的,那麼他們應該擁有相同的哈希值。如果你只重寫了isEqual:,你違背了這個要求。任何時候你重寫了isEqual:,永遠同時重寫hash。要了解更多的信息,可以看我的文章實現等同性和散列法(Implementing Equality and Hashing)

假設你在寫一些單元測試。有一個方法理應返回一個數組,其中包含一個對象。於是你寫了一個測試來驗證它:

    STAssertEqualObjects([obj method], @[ @"expected" ], @"Didn't get the expected array");

這兒用了新的文本型語法來讓它保持簡短。很不錯,對吧?
現在我們有另一個方法返回的數組中包含兩個對象,於是我們爲之寫了這樣一個測試:

    STAssertEqualObjects([obj methodTwo], @[ @"expected1", @"expected2" ], @"Didn't get the expected array");

突然,代碼無法通過編譯,並且產生一堆十分奇怪的錯誤。這是怎麼回事?
問題在於STAssertEqualObjects是個宏。宏是由預處理器展開的,並且預處理器是個古老的、相當愚蠢的程序,它不知道任何的現代Objective-C語法,或者現代C語法。預處理器按照逗號將宏參數分割開。它足夠聰明,知道括號是可以遞歸的,所以這個宏被它視作三個參數:
    Macro(a, (b, c), d)
這裏第一個參數是a,第二個是(b,c),然後第三個是d。但是,預處理器不知道它需要對[]和{}做相同的處理。之前的那個宏,預處理器看到的是四個參數:

    1   [obj methodTwo]

    2   @[ @"expected1"

    3   @"expected2 ]

    4   @"Didn't get the expected array"

這個結果完全是代碼碎片,不僅不能編譯,而且還迷惑了編譯器,使之無法提供可理解的診斷信息。一旦知道了問題在哪裏,解決方法很簡單了。只要將那些文本用括號括起來,這樣預處理器就會把它當作一個參數了:

    STAssertEqualObjects([obj methodTwo], (@[ @"expected1", @"expected2" ]), @"Didn't get the expected array");

單元測試是我最經常遇到的,但是它隨時都有可能突然冒出來一個宏。Objective-C文本會成爲受害者,C的複合文本(C compound literals)也是。如果你在block中使用逗號,儘管很少遇到,但是是合法的,那麼也可能出問題。你會發現Apple在Block_copy和Block_release宏中已經考慮到了這個問題,這兩個宏在/usr/include/Block.h中:

    #define Block_copy(...) ((__typeof(__VA_ARGS__))_Block_copy((const void *)(__VA_ARGS__)))

    #define Block_release(...) _Block_release((const void *)(__VA_ARGS__))

這些宏理論上只接受單一的參數,但它們被聲明成接受可變參數以避免這個問題。通過接受…作爲參數,並使用__VA_ARGS__來指代參數,帶逗號的“多參數”被複制到了宏的輸出。你可以用相同的方法讓自己的宏避免這個問題,儘管它只對多參數宏的最後一個參數有效。
屬性合成(Property Synthesis)

看下面這個類:

    @interface MyClass : NSObject {
        NSString *_myIvar;
    }
 
    @property (copy) NSString *myIvar;
 
    @end
 
    @implementation MyClass
 
    @synthesize myIvar;
 
    @end
沒什麼問題,是嗎?ivar的聲明和@synthesize在現在有點多餘,但是沒有關係。
很不幸,這段代碼會默默的忽略掉_myIvar並且合成一個新的不帶前綴下劃線的變量myIvar。如果你的代碼中直接使用了ivar,它的值會和代碼中直接使用屬性的值不一樣。很混亂!
@synthesize合成的變量名字的規則有點怪異。如果你通過 @synthesize myIvar = _myIvar;來指定變量名字,那麼當然它用的是你所指定的任何名字。如果你沒有指定變量名,那麼它會合成一個與屬性名相同名字的變量。如果你乾脆連@synthesize也一起省略了,那麼它會合成一個名字和屬性名相同,但是帶一個前綴下劃線的變量。
除非你需要支持32位的Mac,你現在最好的選擇就是避免顯示地爲屬性聲明對應的變量。讓@synthesize創建該變量,並且如果你搞錯了名字,你會得到一個好的編譯警告,而不是難以理解的行爲。
被中斷的系統調用

Cocoa代碼一般堅持使用高級結構,但有時需要降低一些來處理POSIX時也很實用。例如,這些代碼會向一個文件描述符中寫入一些數據:

    int fd;
    NSData *data = ...;
 
    const char *cursor = [data bytes];
    NSUInteger remaining = [data length];
 
    while(remaining > 0) {

        ssize_t result = write(fd, cursor, remaining);

        if(result < 0)
        {

            NSLog(@"Failed to write data: %s (%d)", strerror(errno), errno);

            return;
        }
        remaining -= result;
        cursor += result;
    }
但是,這可能會失敗,它失敗的方式會很奇怪,並且是間歇性的。像這樣的POSIX調用是可以被信號打斷的。即使是應用當中在其他地方處理的無害的信號,例如SIGCHLD、SIGINFO,都會導致這種情況發生。如果你使用了NSTask或者進行多線程的工作,SIGCHLD就會產生。當write被信號打斷的時候,它會返回-1,並且將errno設置爲EINTR來表示這個調用被中斷。上述代碼將所有錯誤都當作是致命的,並往外跳,儘管它僅僅是需要被重新調用。正確的代碼應該單獨檢查這種情況,並重試該調用:
    while(remaining > 0) {

        ssize_t result = write(fd, cursor, remaining);

        if(result < 0 && errno == EINTR)

        {
            continue;
        }
        else if(result < 0)
        {

            NSLog(@"Failed to write data: %s (%d)", strerror(errno), errno);

            return;
        }
        remaining -= result;
        cursor += result;
    }
 
字符串長度
相同的字符串,用不同的方式表示,會有不同的長度。這個是相當常見的但是確實有錯的樣例:

    write(fd, [string UTF8String], [string length]);

這個問題在於當write需要一個字節數的時候,NSString是以utf-16編碼爲單位計算長度的。僅當字符串中只包含ASCII字符的時候,這兩個數纔會相等(這就是爲什麼人們如此經常寫這種錯誤代碼卻能僥倖無事)。當字符串中一旦包含非ASCII字符,例如重音字符,它們就不再相等。請一直使用相同的表示法來計算你正在操作的字符串長度:
    const char *cStr = [string UTF8String];
    write(fd, cStr, strlen(cStr));
強制轉換成BOOL類型

看下這段用於檢查一個對象指針是否是空的代碼:

    - (BOOL)hasObject
    {
        return (BOOL)_object;
    }
一般來說,它能正常工作。但,大概6%的概率,它會在_object不爲nil的情況下返回NO。出什麼事了?
BOOL,很不幸,它不是布爾類型。這是它的定義:
    typedef signed char BOOL;
這是另一個很不幸的從C沒有布爾類型的時候遺留下來的問題。Cocoa早在C99的_Bool出現前,將它自己的“布爾“類型定義爲signed char,也就是一個8位的整數。當你將一個指針轉轉爲整型時,你將得到指針本身的數值。當你將指針轉換成小整型的時候,那麼你將得到指針的低位部分的數值。當指針看起來像這樣:
    ....110011001110000
轉成BOOL就會得到:
               01110000
這個值非0,也就是說它是被正確計算的。那麼問題是什麼?問題在於如果指針看起來像這樣:
    ....110011000000000
那麼轉成BOOL就會得到:
               00000000
這個值是0,也就是NO,即使指針本身不是nil。啊哦!
這個發生的頻率有多高?BOOL類型有256個可能的值,而NO只佔其中一個。所以我們可以簡單的假設它發生的概率是1/256。但Objective-C的對象在分配內存的時候是對齊的,一般來說是16位對齊。也就是說指針的最低4位一直都是0(有些地方會利用它們來對指針進行標記),故轉換成BOOL後,只有4位的值是會變化的。那麼所有位都爲0的可能性就變成了1/16,也就是大概6%。
安全的實現這個方法,需要和nil進行一個顯示的對比:
    - (BOOL)hasObject
    {
        return _object != nil;
    }
如果你想耍點小聰明,並使代碼變得難以閱讀,可以連續使用兩次!操作符。!!結構有時被稱爲C語言的布爾轉換操作符,雖然這只是它的一部分功能。
    - (BOOL)hasObject
    {
        return !!_object;
    }
倒數第一個!根據_object是否爲nil產生一個1或者0的值。第二個!再將它轉爲正確的值,如果_object爲nil,則產生1,否則產生0。
你應該堅持使用!= nil的版本。
 
丟失的方法參數

假設你正在實現一個表格視圖的數據源。你將這個加入到你的類的方法中:

    - (id)tableView:(NSTableView *) objectValueForTableColumn:(NSTableColumn *)aTableColumn row:(NSInteger)rowIndex

    {

        return [dataArray objectAtIndex: rowIndex];

    }
於是開始運行應用,然後NSTableView開始抱怨說你沒有實現這個方法。但是它明明就在那兒!
像往常一樣,計算機是正確的。計算機是你的朋友。
認真點看,第一個參數丟失了。爲什麼這樣也能編譯呢?

原因在於Objective-C允許空的選擇符部分。上面聲明的並不是一個丟失了一個參數的名叫 tableView:objectValueForTableColumn:row: 的方法。而是聲明瞭名叫 tableView::row:的方法,並且它的第一個參數名叫objectValueForTableColumn. 這是一個相當不愉快的方法來鍵入一個方法的名字,並且如果你在一個編譯器無法提示你方法丟失的情況下犯了這個錯,你可能就要花上相當長的時間用於調試這個問題。

結論

Objective-C和Cocoa給大意的程序員準備了相當多的陷阱。上面的只是個示例罷了。但是它的確是一個好的問題清單,列出了那些需要被注意的問題。

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