《Effective Objective-C 2.0》- 5:用列舉來表示狀態、選項、狀態碼

weixin_34249678發表於2018-05-08

由於 Objective-C 基於 C 語言,所以 C 語言有的功能它都有。其中之一就是列舉型別:enum。系統框架中頻繁使用此型別,然而開發者容易忽視它。在以一系列常量來表示錯誤狀態碼或可組合的選項時,極易使用列舉為其命名。

列舉只是一種常量命名方式。某個物件所經歷的各種狀態就可以定義一個簡單的列舉集(enumeration set)。比如說,可以用下列列舉表示“套接字連線”(socket connection)的狀態:

enum EOCConnectionState {
    EOCConnectionStateDisconnected,
    EOCConnectionStateConnecting,
    EOCConnectionStateConnected,
};

由於每種狀態都用一個便於理解的值來表示,所以這樣寫出來的程式碼更易讀懂。編譯器會為列舉分配一個獨有的編號,從 0 開始,每個列舉值遞增 1 。實現列舉所用的資料型別取決於編譯器,不過其二進位制位(bit)的個數必須能完全表示下列舉編號才行。在前例中,由於最大編號是 2,所以使用 1 個位元組的 char 型別即可。

然而定義列舉變數的方式卻不太簡潔,要依如下語法編寫:

enum EOCConnectionState state = EOCConnectionStateDisconnected;

若是每次不用敲入 enum 而只需要寫 EOCConnectionState 就好了。要想這樣,則需要使用 typedef 關鍵字重新定義列舉型別:

enum EOCConnectionState {
    EOCConnectionStateDisconnected,
    EOCConnectionStateConnecting,
    EOCConnectionStateConnected,
};
typedef enum EOCConnectionState EOCConnectionState;

現在可以用簡寫的 EOCConnectionState 來代替完整的 enum EOCConnectionState 了:

EOCConnectionState state = EOCConnectionStateDisconnected;

C++11 標準修訂了列舉的某些特性。其中一項改動是:可以指明用何種“底層資料型別”(underlying type)來儲存列舉型別的變數。這樣的好處是,可以向前宣告列舉變數了。若不指定底層資料型別,則無法向前宣告列舉型別,因為編譯器不清楚底層資料型別的大小,所以在用到此列舉型別時,也就不知道究竟該給變數分配多少空間。

指定底層資料型別所用的語法是:

enum EOCConnectionStateConnectionState : NSInteger { /* ... */ };

上面這行程式碼確保列舉的底層資料型別是 NSInteger。也可以在向前宣告時指定底層資料型別:

enum EOCConnectionStateConnectionState: NSInteger;

還可以不使用編譯器所分配的序號,而是手工指定某個列舉成員所對應的值。語法如下:

enum EOCConnectionState {
    EOCConnectionStateDisconnected = 1,
    EOCConnectionStateConnecting,
    EOCConnectionStateConnected,
};

上述程式碼把 EOCConnectionStateDisconnected 的值設為 1 ,而不使用編譯器所分配的 0 。如前所述,接下來幾個列舉的值都會在上一個的基礎上遞增 1 。比如說,EOCConnectionStateConnected 的值就是 3。

還有一種情況應該使用列舉型別,那就是定義選項的時候。若這些選項可以彼此組合,則更應如此。只要列舉定義得對,各選項之間就可以通過“按位或操作符”(bitwise OR operator)來組合。例如,iOS UI 框架中有如下列舉型別,用來表示某個檢視應該如何在水平或垂直方向上調整大小:

typedef NS_OPTIONS(NSUInteger, UIViewAutoresizing) {
    UIViewAutoresizingNone                 = 0,
    UIViewAutoresizingFlexibleLeftMargin   = 1 << 0,
    UIViewAutoresizingFlexibleWidth        = 1 << 1,
    UIViewAutoresizingFlexibleRightMargin  = 1 << 2,
    UIViewAutoresizingFlexibleTopMargin    = 1 << 3,
    UIViewAutoresizingFlexibleHeight       = 1 << 4,
    UIViewAutoresizingFlexibleBottomMargin = 1 << 5
};

每個選項均可啟用或禁用,使用上述方式來定義列舉值即可保證這一點,因為在每個列舉值(UIViewAutoresizingNone 除外,它點值是 0,對應的二進位制值是 0,其中沒有值為 1 的二進位制位)所對應的二進位制表示中,只有一個二進位制位的值是 1。用“按位或操作符”可組合多個選項,例如: UIViewAutoResizingFlexibleWidth | UIViewAutoresizingFlexibleHeight。圖列出了每個列舉成員的二進位制值,並演示了剛才那兩個列舉組合之後的值。用“按位與操作符”(bitwise AND operator)即可判斷出是否已啟用某個選項:

enum UIViewAutoresizing resizing = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
    if (resizing & UIViewAutoresizingFlexibleWidth) {
        // UIViewAutoresizingFlexibleWidth is set
    }
UIViewAutoresizingFlexibleLeftMargin 000001
    UIViewAutoresizingFlexibleWidth 000010
    UIViewAutoresizingFlexibleRightMargin 000100
    UIViewAutoresizingFlexibleTopMargin 001000
    UIViewAutoresizingFlexibleHeight 010000
    UIViewAutoresizingFlexibleBottomMargin 100000    

    UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight 010010

每個列舉值的二進位制表示,以及對其中兩個列舉值執行按位或操作之後對二進位制值。

系統庫中頻繁使用這個方法。iOS UI 框架中的 UIKit 裡面還有個例子,用列舉值告訴系統檢視所支援的裝置顯示方向。這個列舉型別叫做 UIInterfaceOrientationMask,開發者需要實現一個名為 supportedInterfaceOrientations 的方法,將檢視所支援的顯示方向高速系統:

- (UIInterfaceOrientationMask)supportedInterfaceOrientations {
    return  UIInterfaceOrientationMaskPortrait | UIInterfaceOrientationMaskLandscapeLeft;
}

Foundation 框架中定義了一些輔助的巨集,用這些巨集來定義列舉型別時,也可以指定用於儲存列舉值的底層資料型別。這些巨集具備向後相容(backward compatibility)能力,如果目標平臺的編譯器支援新標準,那就使用新式語法,否則改用舊式語法。這些巨集是用 #define 預處理指令來定義的,其中一個用於定義像 EOCConnectionState 這種普通的列舉型別,另一個用於定義像 UIViewAutoresizing 這種包含一系列選項的列舉型別,其用法如下:

typedef NS_ENUM(NSUInteger, EOCConnectionState) {
    EOCConnectionStateDisconnected,
    EOCConnectionStateConnecting,
    EOCConnectionStateConnected,
};
 
typedef NS_OPTIONS(NSUInteger, EOCPermittedDirection) {
    EOCPermittedDirectionUP = 1 << 0,
    EOCPermittedDirectionDown = 1 << 1,
    EOCPermittedDirectionLeft = 1 << 2,
    EOCPermittedDirectionRight = 1 << 3,
};

這些巨集的定義如下:

#define NS_ENUM(...) CF_ENUM(__VA_ARGS__)
#define NS_OPTIONS(_type, _name) CF_OPTIONS(_type, _name)

由於需要分別處理不同的情況,所以上述程式碼用很多種方式來定義這兩個巨集。第一個 #if 用於判斷編譯器是否支援新式列舉。其中所用的布林邏輯看上去相當複雜,不過其意思就是想判斷編譯器是否支援新的列舉特性。如果不支援,那麼就用老式語法來定義列舉。

如果支援新特性,那麼用 NS_ENUM 巨集所定義的列舉型別展開之後就是:

typedef enum EOCConnectionState : NSUInteger EOCConnectionState;
enum EOCConnectionState : NSUInteger {
    EOCConnectionStateDisconnected,
    EOCConnectionStateConnecting,
    EOCConnectionStateConnected,
};

根據是否要將程式碼按 C++ 模式編譯,NS_OPTIONS 巨集的定義方式有所不同。如果不按 C++ 編譯,那麼其展開方式就和 NS_ENUM 相同。若按 C++ 編譯,則展開後的程式碼略有不同。原因在於,用按位或運算來操作兩個列舉值時,C++ 編譯模式的處理辦法與非 C++ 模式不一樣。而上面已經提到了,作為選項的列舉值經常需要用按位或運算來組合。在用或運算操作兩個列舉值時,C++ 認為運算結果的資料型別應該是列舉的底層資料型別,也就是NSUInteger。而且 C++ 不允許將這個底層型別“隱式轉換”(implicit cast)為列舉型別本身。我們用 EOCPermittedDirection 來演示一下,假設按 NS_ENUM 方式將其展開:

typedef enum EOCPermittedDirection : int EOCPermittedDirection;

enum EOCPermittedDirection : int {
    EOCPermittedDirectionUP = 1 << 0,
    EOCPermittedDirectionDown = 1 << 1,
    EOCPermittedDirectionLeft = 1 << 2,
    EOCPermittedDirectionRight = 1 << 3,
};

然後考慮下列程式碼:

EOCPermittedDirection permittedDirections = EOCPermittedDirectionLeft | EOCPermittedDirectionUP;

若編譯器按 C++ 模式編譯(也可能是按 Objective-C 模式編譯),則會給出下列錯誤資訊:

error: cannot initialize a variable of type
'EOCPermittedDirection' with an rvalue of type 'int'

如果想編譯這行程式碼,就要將按位或操作的結果顯示轉換(explicit cast)為 EOCPermittedDirection。所以,在C++ 模式下應該用另一種方式定義 NS_OPTIONS 巨集,以便省去型別轉換操作。鑑於此,凡是需要以按位或操作來組合的列舉都應該使用 NS_OPTIONS 定義。若是列舉不需要互相組合,則應使用 NS_ENUM 來定義。

能夠用到列舉的情況還有很多。前面已經提到,列舉可以表示選項與狀態,然而還有許多東西也能用列舉表示。比如狀態碼就是個好例子。可以把邏輯含義相似的一組狀態碼放入同一個列舉集裡,而不要用 #define 預處理指令或常量來定義。以列舉來表示樣式(style)也很合宜。假設建立某個 UI 元素時可以使用不同的樣式,那麼在這種情況下就最應該把樣式宣告為列舉型別了。

最後再講一種列舉的用法,就是在 switch 語句裡,有時可以這樣定義:

typedef NS_ENUM(NSUInteger, EOCConnectionState) {
    EOCConnectionStateDisconnected,
    EOCConnectionStateConnecting,
    EOCConnectionStateConnected,
};
  
    switch (_currentState) {
        case EOCConnectionStateDisconnected:
        {
             // Handle disconnected state
        }
            break;
            case EOCConnectionStateConnecting:
        {
            // handle connecting state
        }
            break;
            case EOCConnectionStateConnected:
        {
            // handle connected state
        }
            break;
    }

我們總是習慣在 switch 語句中加上 default 分支。然而,若是用列舉來定義狀態機(state machine),則最好不要有 default 分支。這樣的話,如果稍後又加了一種狀態,那麼編譯器就會發出警告資訊,提示新加入的狀態並未在 switch 分支中處理。假如寫上了 default 分支,那麼它就會處理這個新狀態,從而導致編譯器不發出警告資訊。用 NS_ENUM 定義其他列舉型別時也要注意此問題。例如,在定義代表 UI 元素的列舉時,通常要確保 switch 語句能正確處理所有樣式。

總結:

  1. 應該用列舉來表示狀態機的狀態、傳遞給方法的選項以及狀態碼等值,給這些值起個易懂的名字。
  2. 如果把傳遞給某個方法的選項表示為列舉型別,而多個選項又可同時使用,那麼就將各選項定義為 2 的冪,以便通過按位或操作將其組合起來。
  3. 用 NS_ENUM 與 NS_OPTIONS 巨集來定義列舉型別,並指明其底層資料型別。這樣做可以確保列舉是用開發者所選的底層資料型別實現出來的,而不會採用編譯器所選的型別。
  4. 在處理列舉型別的 switch 語句中不要實現 default 分支。這樣的話,加入新列舉之後,編譯器就會提示開發者:switch 語句並未處理所有列舉。

相關文章