筆記目錄

Skip to content

淺談 C# Property (屬性) 語法糖與 NRT 機制演進

會寫這篇起因是最近無意間發現 C# 14 的 field 關鍵字,它讓自動屬性也能加入自訂邏輯,不用再為了一行 Trim() 就退回手寫 Backing Field。就順便把印象中有幾個可以改善 NRT 體驗的語法也找出來進行梳理。

C# Property (屬性) 的語法演進史

C# 的屬性(Property)是將早期的「Getter/Setter 設計模式」直接內建到程式語言,用方法(Method)來包裝欄位(Field)。雖然從呼叫端來看,使用起來就像欄位一樣,但定義端就還是很繁雜,所以後來又出了各種語法糖來簡化定義。

C# 版本通常隨 .NET SDK 一併發佈,以下標註僅註明該語法首次出現的對應版本。

1. 早期古典寫法:Backing Field (C# 1.0 / .NET Framework 1.0)

csharp
public class User {
    private string name;
    
    public string Name {
        get { return name; }
        set { name = value; }
    }
}

2. 自動實作屬性:Auto-Implemented Properties (C# 3.0 / .NET Framework 3.5)

實務上大部分的屬性只是單純的資料容器,沒有任何邏輯。每次都要手寫 private string name; 非常冗長,因此引入自動屬性,由編譯器在底層代為產生欄位。

自動屬性推出後,也導致 Web Forms 時期,很多剛入門的人分不出來屬性和欄位的差異(不過我當年剛學 C# 主要是卡在屬性和 Get 方法的語意和使用時機就是)。

csharp
public class User {
    public string Name { get; set; }
}

3. 屬性初始值設定項:Property Initializers (C# 6.0 / .NET Framework 4.6)

在使用 C# 3.0 的自動屬性時,如果需要給定初始值,就必須要放棄自動屬性,退回 C# 1.0 的 Backing Field 寫法。為了解決這個困擾,C# 6.0 允許直接對自動屬性給定初始值。

csharp
public class User {
    public string Name { get; set; } = "Default Name";
}

4. Expression-bodied 屬性:Lambda 語法糖 (C# 6.0、C# 7.0 / .NET Framework 4.6、4.7)

為了解決大括號 { } 嵌套層級過深的問題,C# 6.0 支援唯讀屬性的 => 寫法,C# 7.0 擴充到 getset 皆可使用,讓程式碼更扁平。

csharp
public class User {
    private string name;

    public string Role => "User";

    public string Name {
        get => name;
        set => name = value.Trim();
    }
}

WARNING

Property 初始值設定項和唯讀寫法很像,不熟悉的人很容易不小心寫錯,但在記憶體與執行生命週期上完全不同,在實務上容易引發 Bug。

  • public string Name => "Default Name" (Expression-bodied):動態計算。每次被呼叫時,都會重新執行後面的程式碼
  • public string Name { get; } = "Default Name" (Property Initializer):靜態快取。只有在物件實體化(new)的當下會執行一次,之後值即固定不變。

災難情境舉例(以 Guid 為例):

csharp
public class Order {
    // ❌ 錯誤情境:每次讀取 OrderId 都會產生全新的 Guid,這會導致序列化或 Log 追蹤出問題
    public Guid OrderId => Guid.NewGuid(); 

    // ✅ 正確做法:只在 new() 時產生一次,之後狀態保持不變
    public Guid CorrectOrderId { get; } = Guid.NewGuid();
}

// 呼叫端
Order order = new();

5. 半自動屬性與 field 關鍵字 (C# 13 Preview、C# 14 / .NET 9、.NET 10)

雖然有了上述語法糖,但只要想在 set 裡面加一行微小的邏輯(例如 Trim()NotifyPropertyChanged),自動屬性就會立刻破功,尤其是 DDD 常常會 set 加上一些邏輯檢核,或是資料修正,又必須退回 C# 1.0 手動宣告那個私有欄位

為了徹底消滅無意義的欄位宣告,微軟終於引入了新的上下文關鍵字 field,讓你可以直接存取編譯器背後產生的那個欄位。

csharp
public class User {
    public string Name { 
        get;
        set => field = value.Trim();
    }
}

TIP

建議盡量在 set 中進行邏輯處理,因為 get 使用比較頻繁,會有一些隱形的額外開銷,也能避免類似 Entity Framework Core 直接繞過 get,直接存取 Backing Field 的潛在問題。

INFO

C# 12 引入了 Primary Constructor 用於減少欄位宣告,但我個人對其持保留態度,認為它是個充滿爭議的設計。

除了剛出有嘗試過,後面除非我想偷懶,否則我不太會用它。對我而言,它更像是一個為了簡化而犧牲語意明確性的產物,不僅容易誘導開發者為了偷懶而忽略必要的參數檢查(Guard Clauses),而且它模糊了參數、欄位與屬性之間的定位與邊界,若類別邏輯稍重又沒寫好,這種隱含的捕獲機制會讓物件內部狀態變得混雜且難以辨識。

NRT (Nullable Reference Types) 與檢查機制的補全

NRT 於 C# 8.0 引入,目標是消滅 NullReferenceException。畢竟大家常常在說程式設計最大的失敗就是 null,話雖這樣說,但 C# 的 struct 有引入 Nullable<T>,讓原本是 Value Type 的 struct 也能表示 null 狀態;因此,問題的核心應該在於我們能否「明確辨識」它存在的可能性。

NRT 詳情可以參考 可為 Null 的參考型別,其主要用意是讓開發者在參考型別透過 ? 標註,主動宣告「這裡可能為空」,從而建立一份清晰的合約。

  • ASP.NET Core 的字串綁定檢核,如果沒加 [Required] 仍會擋掉 null 傳值,但不會擋空字串。所以如果串接別人 API 聽到說沒傳值要傳空字串,基本上是因為這個
  • Entity Framework Core 5.0 起, Code First 的屬性定義,若未標示 ?,EF Core 會預期此資料表欄位是 NOT NULL

如果要讓違反約束的強制無法編譯,要加上 <WarningsAsErrors>nullable</WarningsAsErrors>,這個設定是指定哪些警告變成無法編譯的錯誤,並且指定 nullable,如果有哪些你討厭的寫法,也可以把錯誤代碼(例如非同步相關的 CS4014;CS1998) 加上去,多個用分號隔開,讓它強制不能編譯 XD

不過之前我不是很喜歡開 NRT 這個設定,一來是這個設定最大的意義在於介面合約,團隊上如果不是大家都願意遵守,這就只是一個會誤導人的雜訊,而這個維護成本又很大,所以我也不會想要要求其他人一定遵守,那這樣還不如關閉,也省得我看到編譯器跳出一堆警告 礙眼;二來當時這套機制對 DTO (Data Transfer Object) 來說體驗極差。

為什麼以前會想關掉?

在 C# 8 到 C# 10 時期,NRT 要求非 Null 屬性必須要初始化。但 DTO 通常是由反序列化工具或 ORM 賦值的。為了避免編譯器出現警告,必須要寫出無意義的預設值或是使用 !(null-forgiving)說這邊一定有值,不要跳出警告:

csharp
public class UserDto {
    // 為了騙過編譯器而寫的無意義代碼
    public string UserName { get; set; } = "";

    public string NickName { get; set; } = null!;

    public string Email { get; set; } = default!; 
}

為什麼我會反感這東西?設定 = "" 一般是用在預設是空字串的情況,但在 NRT 下卻變成了一種消弭警告的手段,而 null!,告訴編譯器說這邊有值,不要跳出警告,但如果實際上 set 沒觸發到,那就真的不會有值,造成誤判,這使得類別定義被迫承擔了它無法保證的承諾,反而有更難追蹤的可能。

機制的補全

後來看到以下幾個機制的引入,我覺得 NRT 這個機制才算完整,絕對不是因為可以叫 AI 幫忙處理

  • init (C# 9.0):允許屬性在物件初始化期間(new() { ... })被賦值,之後轉為唯讀,保護了資料的不可變性。
  • required (C# 11.0):它強制呼叫端在 new() 的當下「必須」給予該屬性值。有了這個保證,編譯器就不再逼你在類別內部寫 null! 了。
csharp
public class UserDto {
    public required string UserName { get; init; }
}

// 呼叫端必須給值,否則編譯失敗
UserDto dto = new() { UserName = "Alice" };

[SetsRequiredMembers]:解決與建構子的衝突

required 必須要和 [SetsRequiredMembers] 搭配使用,因為它強制呼叫端使用大括號 { } 給值。如果你提供了一個傳統的參數化建構子,編譯器依然會出現警告,因為它只認 { }

這時就需要加上 [SetsRequiredMembers] 屬性,告訴編譯器已經在這個建構子裡面把所有 required 的屬性都設值了。

csharp
using System.Diagnostics.CodeAnalysis;

public class User {
    public required string UserName { get; init; }
    public required string Email { get; init; }

    // 掛上此 Attribute,編譯器就不會再要求外部呼叫端使用 { } 初始化
    [SetsRequiredMembers]
    public User(string userName, string email) {
        UserName = userName; 
        Email = email;
    }
}

// 呼叫端
User user = new("Alice", "[email protected]");

不過實際上要處理 NRT 還要搭配 [AllowNull][NotNull],這邊就不提了(因為我很多我也忘了,暫時不想在這篇整理),詳情參考 null-state 靜態分析的屬性

required 在 Web API 的應用

required 除了改善 NRT 體驗外,過往開 API,常常會遇到 struct 明明是必填,但為了區分前端是傳預設值(如 0 或 false),還是漏傳這個屬性,而必須使用 Nullable<T> (這邊講的是 [FromBody],Submit 路線的 [FromForm] 之前有 [BindRequired] 可以用,但現在應該大部分情況也可以考慮用 required 處理)

當然這部分對我而言是還好,但網路上確實有人反應認為這是一個破壞語意的邊界妥協,但現在有了 required 後,如果屬性有加上 required,但被前端漏傳了,則會拋出 JsonException,詳情參考 必需屬性

後記

屬性語法糖演變到這種程度,除了 Lazy<T> 以外,好像一時也想不到什麼被迫要加欄位處理的,這也是我看到這東西,會想要寫筆記的原因,期待微軟哪天把 Lazy<T> 的語法糖也補上去。

NRT 機制補完的現在,個人專案我是還滿願意使用的,就是目前有了 AI Agent 後,人比較懶,常常直接叫它幫我處理,但有時會遇到一個情境,我覺得 AI Agent 比我懂得怎麼處理比較好,但有時不知道是因為缺乏上下文,還是因為偷懶,要讓它處理好幾次才會比較滿意,感覺是不是哪天要找時間生出一個 Skill 來處理。

異動歷程

  • 2026-03-30 初版文件建立。