enum 常被拿來表示一組有限、可讀性高的常數;[Flags] enum 則進一步把這些常數當成可組合的位元旗標。兩者看起來語法很輕,但在 CLR 裡其實有很明確的底層模型。把這件事看清楚,很多看似零碎的規則就會自然許多。
enum 本質上是具名常數集合
最典型的列舉型別長這樣:
public enum TimePeriod
{
Morning = 0,
Afternoon = 1,
Night = 2
}
它的價值不是功能很多,而是把「一組有限狀態」明確地型別化。比起直接在程式裡散落 0、1、2 這些 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.GetUnderlyingTypeEnum.GetValuesEnum.ParseEnum.TryParseEnum.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
}
這種設計表示:
Read、Write、Execute、Delete各自占不同 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