C# enum 看起來是一組名稱,跨版本傳遞時卻可能是一組已經固定意義的整數。真正需要保護的不是排列順序,而是「同一數字始終代表同一件事」。本文以虛構的文件處理階段與畫面選項示範,不連接資料庫,也不引用任何實際業務資料。

核心結論: 對外交換或需要長期保留的列舉,應明確指定數值、定義零值政策,並把解析成功、成員存在與情境允許拆開驗證。單選列舉與旗標組合的有效性規則不同,不能共用一條沒有邊界的檢查式。

為什麼新增成員會改變既有數字?

未指定數值的列舉會依宣告順序自動遞增,因此插入成員可能改變後續成員的數字。只要整數已被交換或保存,就應將它視為穩定契約,以明確賦值與回歸斷言保護既有對應。

Microsoft 的 C# 列舉參考文件說明,預設底層型別是 int,第一個未指定值的成員從零開始,後續成員依規則遞增。這是語言行為,不是編輯器的顯示習慣。假設舊版 Ready 對應數字一,後來在前面插入 Paused,新版 Ready 可能變成二。舊資料中的一仍然是一,但新版程式對它的解釋已經不同。

把新成員放在尾端可以避免這一次的位移,卻不是完整的長期政策。後續有人整理分類、刪掉舊名稱或插入另一項,風險仍會再出現。較可靠的方法是先以已確認的舊契約建立對照表,再替每個成員寫出固定值。不能在沒有歷史證據時,重新從零編號並宣稱已修好。

本範例以 Ready = 10 與 Done = 20 展示明確值。數字間保留空隙只是編輯選擇,並非相容性保證;真正保證來自不改變既有數值含義。程式新增 Paused = 5 後,Ready 仍應等於十。若不同系統已使用不同映射,單純補上等號不足以修正矛盾,必須另定版本轉換規格;本文不進行資料變更。

零值應該是未指定,還是可執行狀態?

列舉的預設值永遠是底層零值,即使型別根本沒有定義零值成員也一樣。設計者應明確決定零代表未指定或其他合法意義,並在操作邊界額外判斷,不可把成員存在當成已滿足所有條件。

default(NoZero) 不會挑第一個宣告的成員,也不會自動挑數字最小的合法值。若 NoZero 只有十與二十,預設結果仍是零,Enum.IsDefined 會回傳假。這種狀態不一定在建立物件時拋出例外,因此只觀察「有沒有錯誤」無法發現問題。

對新設計而言,Unspecified = 0 能讓預設狀態可命名,但名稱不是免檢通行證。若呼叫端必須主動選擇處理階段,零即使已定義,也應在該入口被拒絕。本範例的 Accept 同時檢查解析、成員存在與不是 Unspecified,刻意展示這三個條件各自負責什麼。

需要區分「沒有提供」與「有提供零」時,可以另外設計可空欄位或明確的存在標記。這是資料契約決策,不應由顯示層猜測。本文不主張把所有舊列舉直接加上零值,因為既有消費端可能已賦予零不同意義;應先列出呼叫端,再以測試確認可接受的輸入集合。

TryParse 成功為何仍然需要驗證?

解析成功只表示輸入能轉成列舉的底層表示,不保證該數字對應已宣告的成員。單選值應再檢查成員集合與情境規則,而只接受名稱的入口還需要額外限制輸入形式,避免數字字串繞過設計意圖。

Enum.TryParse("99", out var value) 在本範例可以成功,但 Stage 沒有九十九這個成員。這不是 API 壞掉,而是把「能解析」誤當成「能接受」。同樣地,明確轉型成列舉也不是驗證動作,型別標記不會自動縮小整數的可表示範圍。

本範例允許名稱 Ready,也允許數字字串 10;拒絕未定義數字 99、未指定的 0 與不存在的名稱 missing。這些是公開寫出的測試政策,不能外推為所有入口都應接受數字。若你的交換規格只准名稱,應先限制名稱白名單,再呼叫解析;若只准數字,則應先用對應整數解析器建立明確的範圍規則。

另一個需要獨立決策的是大小寫。本文使用未指定忽略大小寫的泛型多載,不承諾任何大小寫都能通過。正式的介面說明應記錄是否接受不同大小寫、空白與別名,並為每一種允許形式加上測試。輸入的寬鬆程度是相容性的一部分,不能在重構時順手改動。

Flags 為何不能只靠 IsDefined?

旗標列舉允許多個位元組合,因此合法組合未必有獨立的成員名稱。單純呼叫成員存在檢查可能拒絕合理組合,應先定義允許位元遮罩,再檢查是否出現未知位元,最後另行處理零值與互斥規則。

範例的 Grid = 1 與 Labels = 2 可以組成三。因為沒有宣告值為三的成員,Enum.IsDefined 回傳假;但若畫面本來允許同時顯示格線與標籤,這個組合仍符合需求。Microsoft 的方法文件也以旗標組合說明這個差異,因此不能把這個假值一概解釋成無效輸入。

程式使用 (combined & ~allowed) == 0 檢查未知位元。當允許遮罩是一與二的聯集時,三不包含未知位元,而八包含未知位元。這個公式只回答位元是否超出範圍,不回答組合是否具有實際意義。例如某兩個選項若互斥,仍需另外寫條件;沒有未知位元的零值,也未必符合必選要求。

本測試限於使用 int 的簡單非負旗標,不提供能套用任意底層型別的通用轉換器。負數、保留位元與不同寬度整數需要額外邊界測試。先界定資料模型是單選還是組合,再選驗證方法,順序不能顛倒。

本機驗證:固定 SDK 重現六項契約

驗證應在獨立測試資料夾完成,使用固定版本工具鏈與完整程式檔,讓每個結論對應可觀察的斷言。本例只建立本機建置產物,不連接任何外部應用服務,並以例外及結束碼區分通過與失敗。

前置條件為已安裝 .NET SDK 10.0.400,且 dotnet 可從終端執行。global.json 只固定 SDK 選擇,不代表鎖定所有執行階段修補版本;本文的重現範圍是這個 SDK 與 net10.0 測試專案,不宣稱完成跨版本矩陣驗證。

  1. 在自己的可寫入位置建立全新空資料夾 enum-contract-lab 並進入;將下方三份完整內容分別存成 global.json、EnumLab.csproj、Program.cs,不要使用既有應用專案。
  2. 在該資料夾執行 dotnet --version,必須得到 10.0.400;若版本不符或工具缺少,停止,不要移除版本鎖定讓測試偷偷換版本。
  3. 執行 dotnet run --project EnumLab.csproj --configuration Release --no-launch-profile。確認六行 PASS:、最後的 ALL_CHECKS_PASSED 與結束碼零。任何 FAIL: 例外、建置錯誤或非零結束碼都不算通過。

global.json:

{"sdk":{"version":"10.0.400","rollForward":"disable"}}

EnumLab.csproj:

Exenet10.0enableenabletrue

Program.cs:

using System;

static class Program
{
    static void Check(bool condition, string label)
    {
        if (!condition) throw new InvalidOperationException("FAIL:" + label);
        Console.WriteLine("PASS:" + label);
    }
    static bool Accept(string text, out Stage stage) =>
        Enum.TryParse(text, out stage) && Enum.IsDefined(stage) && stage != Stage.Unspecified;
    static void Main()
    {
        Check((int)OldImplicit.Ready == 1 && (int)InsertedImplicit.Ready == 2,
            "implicit-insertion-drifts");
        Check((int)Stage.Ready == 10 && (int)ExtendedStage.Ready == 10,
            "explicit-values-stable");
        Check((int)default(NoZero) == 0 && !Enum.IsDefined(default(NoZero)),
            "default-zero-undefined");
        Check(Enum.TryParse("99", out var unknown) && !Enum.IsDefined(unknown),
            "parse-does-not-validate-membership");
        Check(Accept("Ready", out _) && Accept("10", out _) &&
              !Accept("99", out _) && !Accept("0", out _) && !Accept("missing", out _),
            "single-choice-boundary");
        var combined = ViewOptions.Grid | ViewOptions.Labels;
        var allowed = ViewOptions.Grid | ViewOptions.Labels;
        Check(!Enum.IsDefined(combined) && (combined & ~allowed) == 0 &&
              (((ViewOptions)8) & ~allowed) != 0, "flags-mask-not-membership");
        Console.WriteLine("ALL_CHECKS_PASSED");
    }
}
enum OldImplicit { Unspecified, Ready, Done }
enum InsertedImplicit { Unspecified, Paused, Ready, Done }
enum NoZero { Ready = 10, Done = 20 }
enum Stage { Unspecified = 0, Ready = 10, Done = 20 }
enum ExtendedStage { Unspecified = 0, Paused = 5, Ready = 10, Done = 20 }
[Flags] enum ViewOptions { None = 0, Grid = 1, Labels = 2 }

完整執行指令如下;先用編輯器依步驟存好檔案,再在新資料夾中執行。dotnet run 會進行必要的建置與隱含還原,可能建立 bin、obj,工具鏈也可能檢查套件來源;本範例沒有額外套件參照,應用程式本身不發出網路請求。

dotnet --version
dotnet run --project EnumLab.csproj --configuration Release --no-launch-profile

排程實際結果: 在 Windows 本機以 SDK 10.0.400 執行上述 Release 指令,結束碼為零,觀察到六項斷言全部通過及 ALL_CHECKS_PASSED。這是功能正確性驗證,不是效能測試;未測量延遲、配置量或吞吐量,也沒有把筆記中的歷史數字當成測試成果。

若要檢查測試是否真的能擋錯,只能在此獨立範例的副本中修改 ExtendedStage.Ready 對應值後重新執行,預期穩定值斷言失敗。這個額外反向步驟本次未執行,不列為已通過項目。

相容性審查與結論

列舉相容性應以固定數值、輸入政策與未知值處理共同審查,而不是只看程式是否編譯成功。測試需要同時保護既有值與拒絕條件,文件則應清楚說明尚未驗證的格式、版本及消費端,避免把局部通過誤認為全面相容。

審查時先問:這個列舉是否會離開目前程序?如果只在單一函式內短暫使用,需求可能很簡單;若用於長期保存的匯出格式或跨版本交換,就必須建立契約表。表格應包含名稱、固定整數、零值意義、停用政策,以及哪些消費端會收到新成員,不需要包含任何實際資料內容。

  • 數值穩定: 為重要既有成員加入明確整數斷言,不只斷言成員總數。
  • 未知輸入: 明確選擇拒絕、隔離或保留原始值待新版理解;不要把未知值默認成另一個已知狀態。
  • 名稱變更: 如果交換格式使用名稱,改名仍可能破壞相容性;固定數字只解決數字契約,不保證字串契約。
  • 旗標邊界: 分別測試合法組合、未知位元、零值與互斥條件,不以 IsDefined 取代整個規格。
  • 證據範圍: 本次通過的是記憶體內的列舉語意,沒有驗證序列化設定、儲存提供者或其他語言的消費端。

這篇屬於資訊型搜尋內容,協助讀者在查詢「C# enum 預設值」與「TryParse 驗證」時直接找到決策依據。與一般例外處理或資料庫結構變更不同,核心問題是數字的意義是否跨版本保持不變。下一步應先建立自己的小型契約測試,再決定是否需要調整交換格式,而不是立即修改既有資料。

來源與官方查核

本文從開發筆記的列舉相關段落提取可公開的語言問題,再以官方參考文件和獨立程式驗證核對。來源中的環境限定名稱、歷史事件及其他不相干操作均未帶入,兩個語言版本使用相同來源集合與相同查核依據。

來源追溯:軟體開發/專案開發問題集/CSharp與DotNet/CSharp 語言與建置陷阱.md。完整來源檔案已讀取,僅採用列舉數值與預設值概念;本文不將來源內其他段落視為已驗證主張。