enum 常被拿來表示一組有限、可讀性高的常數;[Flags] enum 則進一步把這些常數當成可組合的位元旗標。兩者看起來語法很輕,但在 CLR 裡其實有很明確的底層模型。把這件事看清楚,很多看似零碎的規則就會自然許多。

enum 本質上是具名常數集合

最典型的列舉型別長這樣:

public enum TimePeriod
{
    Morning = 0,
    Afternoon = 1,
    Night = 2
}

它的價值不是功能很多,而是把「一組有限狀態」明確地型別化。比起直接在程式裡散落 012 這些 magic number,TimePeriod.Morning 這種寫法的意圖清楚得多,也比較不容易傳錯值。

enum 是值型別,而且最終都來自 System.Enum

每個列舉型別都繼承自 System.Enum,而 System.Enum 又繼承自 System.ValueType。因此:

  • enum 是值型別
  • 每個列舉實例內部都持有一個底層整數值
  • 預設底層型別是 int

你也可以明確指定底層型別:

public enum Difficulty : ushort
{
    Easy,
    Normal,
    Hard
}

這通常只有在你很在意儲存格式、interop 或序列化契約時才需要特別指定。一般業務程式碼直接使用預設的 int 通常就夠了。

編譯器其實把每個成員都當成常數欄位

列舉型別本身不能自訂欄位、方法、屬性或事件。它的主要工作就是定義一組名稱和值的對應關係。

概念上,你可以把前面的 TimePeriod 理解成類似下面這種結構:

public struct TimePeriod
{
    public const TimePeriod Morning = (TimePeriod)0;
    public const TimePeriod Afternoon = (TimePeriod)1;
    public const TimePeriod Night = (TimePeriod)2;

    private int value__;
}

這不是實際可寫的 C# 原始碼,而是幫助理解用的近似模型。重點在於:

  • 每個列舉成員本質上是常數
  • 變數裡真正存放的是底層數值
  • 型別名稱只是讓這些數值變得可讀、可約束

enum 的型別安全比裸整數好,但不是絕對封死

enum 比直接用 int 更安全,因為 API 可以明確要求某個特定列舉型別:

void SetTimePeriod(TimePeriod period)
{
    // ...
}

呼叫端不能直接把 Difficulty.Hard 傳進來,因為那是不同的列舉型別。

但列舉並不是完全封閉世界。只要經過轉型,你還是可以造出未定義值:

TimePeriod period = (TimePeriod)123;

這在語法上是合法的,所以如果你的 API 真的只接受已定義成員,就要自行驗證,而不是假設所有列舉值都天然有效。

System.Enum 提供的是反射型 API

System.Enum 提供不少常用工具:

  • Enum.GetUnderlyingType
  • Enum.GetValues
  • Enum.Parse
  • Enum.TryParse
  • Enum.IsDefined

例如:

var values = Enum.GetValues<TimePeriod>();
bool ok = Enum.TryParse("Morning", out TimePeriod period);
bool defined = Enum.IsDefined(typeof(TimePeriod), period);

這些 API 很方便,但要知道它們偏向「型別資訊操作」,不是零成本操作。特別是 Enum.IsDefined,若放在高頻熱路徑上通常不值得,因為它不是為極致效能設計的。

列舉本身不能有方法,但可以配合擴充方法

雖然你不能在 enum 裡直接寫方法,但可以用 extension method 補語意:

public static class TimePeriodExtensions
{
    public static bool IsMorning(this TimePeriod value)
    {
        return value == TimePeriod.Morning;
    }
}

這種做法適合補充少量、明確的語意判斷。但若某個列舉開始需要大量商業規則,通常表示你要處理的可能已經不是單純常數集合,而是更完整的領域模型。

[Flags] enum 不是另一種列舉,而是另一種使用語意

有些列舉不是拿來表示「四選一」,而是表示「多個選項可同時成立」。這時就適合用 [Flags]

[Flags]
public enum FilePermission
{
    None = 0,
    Read = 1,
    Write = 2,
    Execute = 4,
    Delete = 8
}

這種設計表示:

  • ReadWriteExecuteDelete 各自占不同 bit
  • 多個值可以用按位或組合
FilePermission permission = FilePermission.Read | FilePermission.Write;

這和一般列舉的思考模型不同。普通 enum 通常表示單一狀態;[Flags] enum 表示一組可疊加的開關。

定義位旗標時,每個值通常都要是 2 的次方

若要讓每個旗標彼此不衝突,最基本的規則就是每個成員都佔獨立 bit:

  • 1 (0001)
  • 2 (0010)
  • 4 (0100)
  • 8 (1000)

這也是為什麼旗標列舉幾乎都長這樣:

[Flags]
public enum FilePermission
{
    None = 0,
    Read = 1,
    Write = 2,
    Execute = 4,
    Delete = 8
}

若你把值定成 1, 2, 3, 4,那 3 就不再是獨立旗標,而會和 1 | 2 混在一起,整個模型會變得模糊。

None = 0 幾乎是必要慣例

旗標列舉最好明確定義一個 None = 0

  • 語意清楚,代表沒有任何旗標被打開
  • 預設值安全,因為值型別預設本來就是 0
  • 搭配判斷與序列化時比較穩

如果你不提供 None = 0,很多預設狀態最後還是會落到 0,只是那個 0 會變成「存在但沒有名字」的值,這通常不是好設計。

[Flags] 主要改善的是可讀性與格式化語意

[Flags] 不會改變底層儲存方式,底層仍然只是整數值。它真正帶來的差異是:

  • 表明這個列舉應被視為位旗標集合
  • ToString() 在可組合的情況下輸出更合理的名稱
  • 讓 parse / display 等行為更貼近「組合值」而不是「單一值」

例如:

FilePermission permission = FilePermission.Read | FilePermission.Write;
Console.WriteLine(permission); // Read, Write

沒有 [Flags] 的話,這個值通常只會顯示數字或不夠直觀的結果。

實務上如何判斷某個旗標是否存在

最直接的做法是用按位與:

bool canWrite = (permission & FilePermission.Write) == FilePermission.Write;

如果你更偏好語意化 API,也可以用 HasFlag

bool canWrite = permission.HasFlag(FilePermission.Write);

HasFlag 的可讀性和效能取捨要自己衡量。平常業務碼用它沒問題;高頻熱路徑若真的在意成本,通常會直接寫按位運算。

什麼時候該用 enum,什麼時候不該

適合用 enum 的場景:

  • 一組有限、穩定、彼此明確區分的狀態
  • 希望 API 介面比裸整數更可讀、更有約束
  • 某些欄位就是單純狀態碼,而不是行為物件

不適合硬塞進 enum 的場景:

  • 成員需要大量附加資料
  • 每個值背後有複雜行為差異
  • 狀態會頻繁擴展到像資料表配置一樣動態

這種時候通常該升級成 class、record 或更完整的 domain model,而不是繼續把所有語意塞在列舉上。

實務上該記住什麼

  • enum 是值型別,本質是具名常數集合。
  • 預設底層型別是 int,但可以依需求改成其他整數型別。
  • 列舉提供型別安全,但轉型後仍可能出現未定義值。
  • [Flags] 適合表示可疊加的位元開關,不適合拿來表示互斥狀態。
  • 旗標列舉應盡量使用 2 的次方分配值,並提供 None = 0
  • HasFlag 好讀,但高頻場景通常直接寫按位運算更直接。

參考資料

  • 《CLR via C#》 Jeffrey Richter