C# Record + 預設參數:一個實務上很好用的設計模式
在維護一套「規則設定」型系統時(例如批次匯入驗證、表單規則、流程狀態設定等),常常會遇到一個共同的問題:
設定項目很多,但大部分情境只需要覆寫其中一兩個欄位,其餘都用同一套預設值。
這篇文章用一個簡化過的範例,說明 C# 的 positional record + 預設參數(optional parameter) 怎麼解決這個問題,以及背後的設計考量。
範例情境
假設我們要設計一套「訂單匯入規則」,用來描述每一種訂單來源(例如:門市、電商、批發商)各自的欄位驗證方式。
csharp/// <summary>
/// 單一資料來源的匯入驗證規則。
/// SourceIndex:後備用的固定順序索引(當 SourceAliases 比對不到時使用)。
/// SourceAliases:這個來源可能出現的名稱別名(例如中英文皆可)。
/// HeaderRow:表頭在第幾行(1-based)。0 = 不靠表頭,純用欄位順序(向後相容舊格式)。
/// DataStartRow:資料從第幾行開始(1-based)。
/// KeyColumnIndex:判斷整列是否為空列的關鍵欄(0-based)。-1 = 用第一個解析到的欄。
/// RequiredColumnCount:僅供診斷參考,不再做硬性等號比對。
/// </summary>
public sealed record ImportRule(
int SourceIndex,
string DisplayName,
int RequiredColumnCount,
int DataStartRow,
IReadOnlyList<FieldRule> Fields,
int KeyColumnIndex = -1,
IReadOnlyList<string>? SourceAliases = null,
int HeaderRow = 0,
int MaxRowCount = 0, // 0 = 不限;某些來源可設 1(例如整批只能有一筆設定)
IReadOnlyList<string>? UniqueFieldNames = null, // 需唯一的欄位(用欄位名稱表示)
string? RegionFieldName = "Region"); // 區域一致性檢核欄位;設 null 可關閉該來源的檢核
呼叫端使用時大概長這樣:
csharpprivate static IReadOnlyList<ImportRule> BuildImportRules() => new List<ImportRule>
{
// 來源 A:只允許一筆設定資料,不需要區域一致性檢核
new(
SourceIndex: 0,
DisplayName: "全域參數設定",
RequiredColumnCount: 6,
DataStartRow: 5,
HeaderRow: 0,
MaxRowCount: 1,
SourceAliases: new[] { "全域參數設定", "Global Settings" },
Fields: new FieldRule[] { /* ... */ }),
// 來源 B:關閉區域檢核(RegionFieldName 傳 null)
new(
SourceIndex: 1,
DisplayName: "門市訂單",
RequiredColumnCount: 4,
DataStartRow: 3,
HeaderRow: 0,
RegionFieldName: null,
Fields: new FieldRule[] { /* ... */ }),
// 來源 C:用組合鍵取代單一欄位的區域檢核
new(
SourceIndex: 2,
DisplayName: "電商訂單",
RequiredColumnCount: 5,
DataStartRow: 3,
HeaderRow: 0,
RegionFieldName: "Region,Warehouse",
Fields: new FieldRule[] { /* ... */ }),
};
這是什麼寫法?
csharppublic sealed record ImportRule(
int SourceIndex,
...
int KeyColumnIndex = -1, // ← 預設值
...
int MaxRowCount = 0, // ← 預設值
IReadOnlyList<string>? UniqueFieldNames = null, // ← 預設值
string? RegionFieldName = "Region"); // ← 預設值
這是 C# 9 之後的 positional record(位置式紀錄型別)。它的參數列表其實就是 primary constructor(主建構子),而 = 值 就是一般 C# 方法/建構子早就支援的 optional parameter(選擇性參數),只是這裡套用在 record 上。
如何運作?
呼叫端在 new(...) 時,只需要用具名參數(named argument)指定要覆寫的值,沒指定的就自動套用預設值:
「來源 A」沒有寫 RegionFieldName,所以吃預設值 "Region";但因為它本身也沒有進到區域一致性檢核(是用 MaxRowCount: 1 控管),這個預設值對它並無實質影響。
「來源 B」明確傳 RegionFieldName: null,代表「這個來源不需要區域一致性檢核」——這是刻意關閉,不是漏寫。
「來源 C」明確傳 RegionFieldName: "Region,Warehouse",代表用「地區+倉別」兩欄組合當作一致性檢核與重複檢查的依據。
為什麼要這樣設計?好處是什麼?
1. 減少樣板重複
大部分來源都用同一套「區域欄位一致性檢查」邏輯(預設值 "Region"),不需要每個來源定義都重複打一次 RegionFieldName: "Region"。
2. 新增欄位不破壞既有呼叫
這是 record 搭配預設值最大的好處之一——未來如果要在 ImportRule 尾巴再加一個新參數(例如 MinRowCount),只要給預設值,舊有的所有 new(...) 呼叫都不需要修改,也不會編譯失敗。這對「向後相容」很重要,尤其當設定檔裡有多個來源定義時,每次改動最怕的就是牽一髮動全身。
3. 用具名參數表達語意,本身就是文件
呼叫端不用背參數順序,MaxRowCount: 1、RegionFieldName: null 這種寫法一看就懂這個來源的特殊規則是什麼,不需要額外註解說明。
這是「類別」嗎?
嚴格來說,record 不是傳統意義上的 class,但底層仍然是 reference type(參考型別),語法與用法都跟 class 很像。差異在於:
特性classrecord相等比較(== / Equals)預設比對「參考位址」是否相同預設比對「所有屬性值」是否相同(value equality)ToString()預設只印型別名稱自動印出所有屬性名稱與值,方便除錯修改內容需自己寫 clone / copy 邏輯內建 with 運算式,可「複製並只改某幾個欄位」產生新物件適用情境有行為/方法邏輯的物件純粹用來表達「一份不可變資料」(data holder)
ImportRule 本質上就是「一組設定值」,沒有複雜行為,很適合用 record 來表達。例如之後如果需要「以來源 C 的規則為基礎,只是把 MaxRowCount 改成 5」,可以直接這樣寫,而不用整包重新宣告:
csharpvar sourceCVariant = existingSourceCRule with { MaxRowCount = 5 };
而 sealed 則是額外限制:這個 record 不能再被繼承,避免日後有人不小心衍生出子類別,把這組「純設定資料」搞得複雜。
小結
當設計「一份規則清單、每項規則都是資料而非行為」的情境時,positional record + 預設參數是一個成本很低、但長期維護效益很高的組合:
呼叫端寫起來簡潔(只覆寫需要改的欄位)
擴充新設定欄位不影響既有程式碼
具名參數本身即文件,降低後續維護者理解成本
record 的 value equality 與 with 運算式,讓「複製規則、微調欄位」變得非常直覺
如果你的專案裡也有類似「一堆設定物件、彼此差異只在少數欄位」的情境,這個模式值得參考看看。