2009/9/22

[入門][Enum] Enum 詳論

Enum 通稱為「列舉型別」, 在 .Net 中應該可以算是一門顯學, 意思就是說絕大部份 .Net 開發者都知道有這個東西, 也一定會用。不過對於許多初學者來說, 他們知道列舉型別, 也會用, 卻不一定知道列舉型別用在什麼地方、有什麼好用之處。所以在這篇文章裡, 我除了向各位介紹 Enum 如何使用之外, 也會告訴你它的好用之處

Enum 的宣告

在 .Net 中 Enum 的使用是很容易、很直覺的, 看以下的例子就應該可以了解:

VB -


Enum eBugStatus As Integer 
   Open 
   ReOpen 
   ClosedFixed 
   ClosedPending 
   ClosedUseAsIs 
   ClosedNotABug 
End Enum

C# -


enum eBugStatus : int 
{ 
   Open, 
   ReOpen, 
   ClosedFixed, 
   ClosedPending, 
   ClosedUseAsIs, 
   ClosedNotABug 
};

宣告這個 Enum 之後, 你在程式裡馬上就可以使用 Intellisense 功能進行選取。

Enum 型別和 .Net 其它型別一樣可以加上各種 Modifier (Public, Protected, Private 等等)。此外, 你可以不需要為 Enum 宣告型別; 寫成下列方式是可以的:

VB -


Enum eBugStatus 
   Open 
   ReOpen 
   ClosedFixed 
   ClosedPending 
   ClosedUseAsIs 
   ClosedNotABug 
End Enum

C# -


enum eBugStatus 
{ 
   Open, 
   ReOpen, 
   ClosedFixed, 
   ClosedPending, 
   ClosedUseAsIs, 
   ClosedNotABug 
};

如果你一定要宣告型別, 那麼你不一定要宣告為 Integer/int, 在 .Net 中你可以宣告你自訂的 Enum 使用以下各種型別:

byte
sbyte
short
ushort
int
uint
long
ulong

Enum 型別的本質

你可能想要知道, 為什麼 .Net 會需要替 Enum 宣告型別? Enum 本身不就是一種型別了嗎?

其實 Enum 是一種蠻特殊的型別; 當你宣告一個 Enum 型別的時候, 它實際上和建立一群列舉的數字常數沒什麼兩樣。你可以試著在程式中逐項檢視個別的值:


eBugStatus bug = eBugStatus.Open; 
Response.Write(((int)bug).ToString() + "<br />"); 
bug = eBugStatus.ReOpen; 
Response.Write(((int)bug).ToString() + "<br />"); 
bug = eBugStatus.ClosedFixed; 
Response.Write(((int)bug).ToString() + "<br />"); 
...

如果照以上程式逐項列出 eBugStatus的值, 你會發現它事實上是以 0, 1, 2, ... 方式儲存在記憶體的。換句話說, 在 Enum 中宣告的各個項目, 就是預設以 0, 1, 2, 3, 4... 等整數數列的方式存放的, 當你使用 (int)bug 取出值的時候就可以發現。

但是, 我們也可以直接去指定 Enum 數列中個別的數值, 方法如下:


public enum eBugStatus: int 
{ 
    Open = 0, 
    ReOpen = 10, 
    ClosedFixed = 20, 
    ClosedPending = 30, 
    ClosedUseAsIs = 40, 
    ClosedNotABug = 50 
}

當你採用這個方式指定其值的時候, 數值中的值就不再依 0, 1, 2, ... 的順序自動指定了, 而會使用你所指定的值。若使用上面的程式, 你將發現列出來的值將變成 0, 10, 20, 30, ...。

常見的使用時機

或許你會問, 我們應該在什麼時候使用 Enum, 又為什麼要直接指定數列裡的值呢?

首先, 當你使用 Enum 的時候, 你就用到了物件導向中的資訊封裝 (Encapsulatioin) 功能。在本例中, 你可以在任何地方以 eBugStatus.Open 來代表一個 Bug 的 Status 為 Open, 而無需硬性指定 "0" 這個數字來代表。萬一哪天你必須更改這個值 (例如把 0 改成 1), 你只要更改 Enum 的宣告就行了, 不需要改動整個程式。

前面講過, Enum 列舉值實際上就是整數, 因此你可以很方便在兩者之間切換:


eBugStatus bug; 
int status = (int)bug;

在上例中, 使用 (int)bug 就可以取得列舉項目的隱含整數值。

相反的, 你可以從整數轉換為列舉項目:


eBugStatus bug = (eBugStatus )10;

依照這種做法, 你可以很方便的透過 Enum 物件把資料寫進資料庫或 Cookies, 或者讀取出來。

此外, 我們也可以透過 Enum.GetNames 和 Enum.GetValues 方法將一個 Enum 型別逐條列舉。例如:


List<ebugstatus> list = new List<ebugstatus>();
foreach (eBugStatus e in (eBugStatus[])Enum.GetValues(typeof(eBugStatus)))
    list.Add(e);

如果我們想要列舉的既不是該 Enum 的 Value, 也不是 Name, 怎麼辦? 很簡單, 我們可以依下列程式的做法, 列舉出所有項目的 IEnumerable 集合:

/// <summary>
/// Enumerate the full list of the underlying Enum members
/// </summary>
public static IEnumerable<T> GetUnderlyingEnums<T>()
{
    if (typeof(T).IsEnum)
        return Enum.GetValues(typeof(T)).Cast<T>();
    else return null;
}

如上, 我建立了一個接受通用型別的公用程式。我們可以使用 GetUnderlyingEnums<eBugStatus>() 取出 IEnumerable<eBugStatus> 物件。

應用於資料繫結控制項

Enum 宣告之後, 除了可以在程式中方便的使用之外, 在某些場合中也很適合讓資料繫結控制項使用。在下例中, 我把 eBugStatus 列舉型別指定為 DropDownList1 的繫結資料來源:


DropDownList1.DataSource = Enum.GetNames(typeof(eBugStatus)); 
DropDownList1.DataBind();

如此, 這個 DropDownList 就可以列出 Open, ReOpen, ClosedFixed... 等項目供使用者挑選。

那麼, 當使用者選中某個項目時, 又該如何取回列舉值呢? 在這裡我們必須使用 Enum.Parse 方法:


string selection = DropDownList1.SelectedValue; 
eBugStatus status = (eBugStatus)Enum.Parse(typeof(eBugStatus), selection);

如果在 DropDownList 中你希望它的 Value 為 Enum 的數值而非文字, 你可以藉由採用一個 SortedList 或 ArrayList 物件, 如下例:


public string selectedValue 
    { 
        get 
        { 
            if (ddl.Items.Count == 0) 
                bindDdl(); 
            return ddl.SelectedValue; 
        } 
        set 
        { 
            ddl.SelectedValue = value; 
        } 
    }

protected void ddl_Load(object sender, EventArgs e) 
    { 
        if (ddl.Items.Count == 0) 
            bindDdl(); 
    }

private void bindDdl() 
    { 
        SortedList<int, string> sl = new SortedList<int, string> { }; 
        foreach (string e in Enum.GetNames(typeof(clsWebBase.eOfficialFilterType))) 
            sl.Add((int)Enum.Parse(typeof(clsWebBase.eOfficialFilterType), e), e); 
        ddl.DataSource = sl; 
        ddl.DataTextField = "Value"; 
        ddl.DataValueField = "Key"; 
        ddl.DataBind(); 
    }

使用 ObjectdataSource 作為資料繫結來源

與上述同樣的道理, 我們也可以透過使用 ObjectDataSource 把 Enum 物件當作繫結的資料來源:


public class Johnny 
{ 
    public enum eBugStatus: int 
    { 
        Open = 0, 
        ReOpen = 10, 
        ClosedFixed = 20, 
        ClosedPending = 30, 
        ClosedUseAsIs = 40, 
        ClosedNotABug = 50 
    } 

    public SortedList<int, string> getBugStatusList() 
    { 
        SortedList<int, string> sl = new SortedList<int, string> { }; 
        foreach (string e in Enum.GetNames(typeof(eBugStatus))) 
            sl.Add((short)Enum.Parse(typeof(eBugStatus), e), e); 
        return sl; 
    } 
}

接著, 在 .aspx 程式中使用一個 DropDownList 來展示資料, 並使用一個 ObjectDataSource 作為資料來源:

<asp:DropDownList ID="DropDownList1" runat="server" AutoPostBack="True" 
        DataSourceID="odsBugStatus" DataTextField="Value" DataValueField="Key" /> 
<asp:ObjectDataSource ID="odsBugStatus" runat="server" SelectMethod="getBugStatusList" 
        TypeName="Johnny" />

使用 IEnumerable<T> 作為資料繫結來源

我們也可以使用上面提過的 GetUnderlyingEnums<T>() 方法, 取出 IEnumerable<T> 物件以做為繫結來源。以下以 MVC 的 Razor 語法做示範:

選擇問題狀況:
<select id="selectedButStatus">
    <option value="-1">--請選擇--</option>
    @foreach (eButStatus li in Util.GetUnderlyingEnums<eButStatus>())
    {
        <option value="@((int)li)">@li.GetChineseName()</option>
    }
</select>

如上, 我們建立了一個 DropDownList 控制項。其中 GetChineseName() 方法乃用來取出 eBugStatus 的自訂 Attribute, 其做法源自「[C#] 屬性中的屬性: 自訂 Attributes」一文。我在本文最後面會有更詳細的介紹。

Enum 的型別轉換

從剛才的範例中, 我們已經看到 Enum 可以輕易的和整數(或者 byte、sbyte、short、ushort... 等等)型別互相轉換(端看你一開始宣告為什麼型別), 簡單的使用 CType 或 (int) 方法就行了:

VB -


Dim bugId As Integer = CType(bug, Integer)

C# -


int bugId = (int) bug;

其次, 針對 Enum 的列舉名稱 (Name), 則可以透過 Enum.Parse() 方法進行轉換, 在上面我們已經看過例子了。這個方法可能並不是所有人都熟悉, 或者不曉得可以用在什麼地方。但在上面範例中, 我們直接把 eBugStatus 列舉名稱當作 DropDownList 的資料繫結來源, 當其中某個項目被選取時, 你就可以拿被選取項目的 Value 作為 Enum.Parse 的參數並解析出對應的 Enum 項目:

VB -


Dim status As eBugStatus = CType(Enum.Parse(typeof(eBugStatus), DropDownList1.SelectedValue), eBugStatus)

C# -


eBugStatus status = (eBugStatus)Enum.Parse(typeof(eBugStatus), DropDownList1.SelectedValue);

還有, Enum 物件的列舉名稱可以簡單的使用 ToString() 取得:

VB -


Dim name As String = eBugStatus.Open.ToString

C# -


string name = eBugStatus.Open.ToString();

Enum 的好處和進階使用技巧

我在一開始的地方提過, Enum 宣告列舉值的方式等同於常數 (Constant) 的宣告; 像這種宣告方式, 就是俗稱的 Hard-code 宣告 (如果用通俗的話來講, 意思就是「寫死的」)。其實 Hard-coded 的數值完全的缺乏彈性, 當程式 Compile 完畢之後就已確實, 也無法在 Runtime 時期變更, 所以運用的時機十分有限。然而, 畢竟是有一些東西確實是不會變的, 例如公司名稱、作者大名、程式版本等等, 這些資訊都是在 Compile 時已經確定, 大概不會有人會希望在 Runtime 時期更動它們。

在上面的範例中, 當我們要自行開發一個 Bug Tracking 系統時, Bug Status 有哪幾種, 我們必須在程式發行之前就已經討論完畢並且確定 (除非你是要設計一個 Commercial 版本的 Bug Tracking 系統, 你或許會允許使用者自行決定 Bug Status; 在這種情況下, 你就不適合把它宣告為 Enum)。

那麼, 既然要以 Hard-code 方式設計常數結構, 像 Bug Status 這種不會改變的東西 (說實在的, 即使在不同公司、不同專案, Bug Status 幾乎都是那幾個), 你就應該設計為 Enum 結構。一來, 它可以讓程式設計師馬上享有 Intellisense 的便利性, 二來, 它也讓你可以避免寫出像 int status = 10; 這種恐怕會導致連錯誤發生在什麼地方都找不到的程式出來。

當你在設計 Enum 結構時, 你可以使用一個很多人都沒想到的一個小技巧, 也就是刻意的設計不同的 Enum, 但是賦予部份項目相同的值:


public enum eBugStatus: int
{
    Open = 0,
    ReOpen = 10,
    ClosedFixed = 20,
    ClosedCanNotReproduce = 30,
    ClosedPending = 40,
    ClosedUseAsIs = 50,
    ClosedNotABug = 60
}

public enum eDeveloperStatus: int
{
    Unread = 0,
    Inspecting = 110,
    Fixed = 120,
    CanNotReproduced = 130,
    Pending = 140,
    UseAsIs = 150,
    NotABug = 160
}

在上面程式中, 你可以注意到 eBugStatus.Open 和 eDeveloperStatus.Unread 兩個項目的數值都是 0, 而其它的數值都不一樣。運用這種刻意宣告數值的方式, 你可以賦允同一個常數在不同情況下擁有不同的解釋。

這個小技巧其實可以應用在許多情況之下。例如, Space 鍵的鍵盤掃描碼是固定的常數, 但是 Space 鍵可能在不同情況下代表不同的意義, 例如平常它會輸出一個空白字元, 在使用倉頡輸入法打字時, Space 鍵卻是代表「送出」的意思。這時候, 你或許可以運用 Enum 來設計資料結構, 剛好就可以用上這個小技巧。

Enum 旗標

自從 .Net 4.0 之後, Enum 型別多了 HasFlag() 方法, 這樣可以讓我們更容易將 Enum 當作旗標來使用, 尤其是用在複數旗標的情形之下。不過如果要將 Enum 當作可以複數選取的旗標, 我們就不能像上面那樣任意賦予個別 Enum 項目的數值了, 而是必須較為謹慎的選用 2 的幕次方, 例如 1, 2, 4, 8, 16 等等。現在, 我們拿 MSDN 上的範例來看看:


[Flags] public enum DinnerItems {
   None = 0,
   Entree = 1,
   Appetizer = 2,
   Side = 4,
   Dessert = 8,
   Beverage = 16, 
   BarBeverage = 32
}

public class Example
{
   public static void Main()
   {
      DinnerItems myOrder = DinnerItems.Appetizer | DinnerItems.Entree |
                            DinnerItems.Beverage | DinnerItems.Dessert;
      DinnerItems flagValue = DinnerItems.Entree | DinnerItems.Beverage;
      Console.WriteLine("{0} includes {1}: {2}", 
                        myOrder, flagValue, myOrder.HasFlag(flagValue));
   }
}

從程式中, 各位可以看到幾個與普通 Enum 宣告略有不同的地方。首先, 在宣告 Enum 之前, 必須加上 Flags 這個 Attribute; 其次, 我們可以注意到它們的值都是 2 的幕次方(請特別留意, 要被拿來當作旗標的項目不可設定其值為 0); 第三, 這個 Enum 的 instance 可以使用 & (AND) 或 | (OR) 來做邏輯運算; 第四, 你可以使用 HasFlag() 方法來判斷某個旗標是否被設定; 第五, 當你把程式拿來執行的時候, 你會發現含有複合旗標的 Enum 值被轉成 string 之後, 會是個別旗標被以逗號區隔的一段文字。

喔, 還有, 你的程式必須在屬性頁中把目標 Framework 設定為 4.0 以上, 否則 HasFlag() 方法是不能用的。此外, 由於數值的限制, 設定為 Flags 的 Enum 型別的項目勢必會受到其基礎型別的限制; 例如基礎型別為 int 的 Enum 變數, 最多可以設定 31 或 32 個(複合旗標不算, 下面會提到); Enum 能採用的基礎數值型別中最大的是 long 或 ulong (64 bit), 但那也不過 64 個而已。所以如果你的旗標數目大於 64 個, 你恐怕不能使用 Enum 來做。

如果你的好奇心強烈的話, 你可能會想要試試看能不能不以 2 的幕次方來設定其個別值。其實是可以的, 但是你必須確實知道你在做什麼。我把原來的範例稍為修改如下:


[Flags] public enum DinnerItems {
   None = 0,
   Entree = 1,
   Appetizer = 2,
   Side = 4,
   Dessert = 8,
   Beverage = 16, 
   BarBeverage = 32,
   Beverages = 48
}

public class Example
{
   public static void Main()
   {
      DinnerItems myOrder = DinnerItems.Beverages;
      DinnerItems flagValue = DinnerItems.Beverage;
      Console.WriteLine("{0} includes {1}: {2}", 
                        myOrder, flagValue, myOrder.HasFlag(flagValue));
   }
}

經我修改後的程式中, Beverages 的值(48)並不是 2 的幕次方, 但卻是 Beverage 和 BarBeverage 的加總。所以這個程式執行之後的結果仍然是True。

各位應該不難看出來, .Net 4.0 很簡單的增強了 Enum 型別的功能, 讓它可以當作旗標來用, 也讓我們可以不再需要使用二進位數字來達到原來的目的。這雖然並不是什麼偉大的改變, 但確實可以為我們帶來小小的方便。

擴充 Enum

由於 Enum 並不是類別, 我們無法很方便地為 Enum 型別撰寫方法。但是自從 .Net 3.5 之後, 我們可以透過擴充方法, 為一個 Enum 型別撰寫特定的方法。例如, 假設你希望為 eBugStatus 這個 Enum 型別增加一個特別的 ID 欄位, 其形式如 "ST 0"、"ST 20" 等等。但是由於 Enum 無法附加屬性, 那麼, 我們仍然可以透過擴充方法辦到這一點:


public static partial class exts
{
    public static string ConvertToId(this eBugStatus sp)
    {
        int value = (short)sp;
        return value string.Format("ST {0}", value);
    }
}

如此, 你就可以很簡單地使用 eBugStatus.Open.ConvertToId() 這種方式將它轉換為你想到的型式了。

套用自訂 Attribute

我在「[C#] 屬性中的屬性: 自訂 Attributes」一文中介紹了如何在類別中自訂 Attribute 的方法。我在該文中說過其原理適用於類別中的 properties; 但是, 它也適用於 Enum 項目嗎?

答案是肯定的。

首先, 我們必須先決定要建立哪一種 attribute。在這裡, 假設我們建立了一個稱為 ChineseName 的 attribute:

[AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = false)]
public class ChineseName : Attribute
{
    public string Name { get; set; }

    public ChineseName(string name)
    {
        Name = name;
    }
}

我個人習慣把 Enum 中的項目以英文取名。但是, 如果我們在應用程式中想要取出其中文對應名稱, 該怎麼辦? 最笨的做法, 就是另外寫一個對應的方法來一一翻譯, 這樣一點也不 graceful, 也違反封裝的精神。上面範例中的 ConvertToId() 方法, 就有一點這樣的壞味道。以下要介紹的是更好的做法。

對於類別裡的 property 成員, 我們還有 DisplayName 這個 .Net 內建的 attribute 可以用。但是很可惜的, 它並不適用於 Enum 裡的 field。所以我們必須自訂 attribute 來解決這個問題。

當我們建立 ChineseName 這個 attribute 之後, 我們就可以把原來的 eBugStatus 改寫如下:


public enum eBugStatus: int 
{ 
    [ChineseName("未結案")]
    Open = 0, 
    [ChineseName("重啟")]
    ReOpen = 10, 
    [ChineseName("結案-他改好了")]
    ClosedFixed = 20, 
    [ChineseName("結案-他現沒空改")]
    ClosedPending = 30, 
    [ChineseName("結案-他說不想改怎樣")]
    ClosedUseAsIs = 40, 
    [ChineseName("結案-他說沒問題就是沒問題")]
    ClosedNotABug = 50 
}

如上, 我們就可以使用這種自訂的 attribute, 寫入一些輔助性的資訊。

接下來, 我要寫一個擴充方法把這個自訂的 attribute 取出來:

public static class eBugStatusExtensions
{
    public static string GetChineseName(this eBugStatus st)
    {
        var typ = typeof(eBugStatus);
        var property = typ.GetField(st.ToString());
        var attr = (ChineseName[])property.GetCustomAttributes(typeof(ChineseName), false);
        if (0 < attr.Length)
            return attr[0].Name;
        else // 防呆
            throw new NullReferenceException("eBugStatusExtensions.GetChineseName() error: Attempt to retrieve the non-existent \"ChineseName\" attribute " +
                "out of property \"" + st + "\" in class eBugStatus! You must define it before using it.");
    }
}

如此, 我們就可以透過 GetChineseName() 這個擴充方法把該列舉項目的中文名稱取出來了。

為協助大家了解, 我在 .Net Fiddle 上面寫了一個範例程式, 有興趣的朋友可以試著親自操作和修改。

沒有留言:

張貼留言