C# 属性用字段式语法公开数据,却通过 get、set 或 init 访问器执行读取和赋值契约。
init 不是深度不可变,required 也不是运行时验证;公开集合的 getter 仍可能泄露可变状态。
简单存储使用自动属性,需要验证时实现访问器,并把状态变更、空值与对象所有权写成可测试的契约。
是什么,为什么存在
属性(property) 是类、结构体或接口的成员。调用方以 order.Total 或 order.Status = value 这样的字段式语法使用它,声明类型则通过访问器控制读取或赋值。属性本身不等于字段:它可以返回存储值、计算结果,也可以拒绝一次赋值。
属性解决的是公开数据与保留控制权之间的矛盾。公开字段一旦成为 API,声明类型就无法在同一访问点增加验证、计算或更严格的写权限;成对的 GetName()、SetName() 方法又会让简单数据访问变得笨重。属性把这些控制点放在稳定的成员语法后面。
你会在领域对象、配置类型、DTO、序列化模型和 UI 绑定对象中遇到属性。调用代码看起来相同,不代表契约相同:自动属性通常只保存值,计算属性每次读取都求值,而自定义访问器可以运行任意代码。审查属性时,要从声明而不是调用语法推断行为。
属性适合表达对象在某一时刻具有的特征。一次网络请求、数据库查询、长时间计算或明显的状态转换通常更适合方法,因为方法调用能让成本与动作更可见。
工作原理
访问器定义读写能力
属性访问器(property accessor) 是属性中的 get、set 或 init 部分。读取属性会执行 get;普通赋值会执行 set;init 只允许在对象构造阶段的合法位置赋值。set 与 init 中的隐式参数 value 就是调用方提供的新值。
访问器组合直接构成公开契约。只有 get 的属性对调用方只读;get 加 set 可以反复赋值;get 加 init 允许构造阶段赋值,之后不再允许普通赋值。C# 也允许仅写属性,但调用方无法读回状态,实际 API 中很少需要这种形状。
属性和访问器还可以使用不同的可访问性。public decimal Balance { get; private set; } 允许所有调用方读取,却只允许声明类型修改。较严格的访问修饰符只能放在其中一个访问器上,而且属性必须同时具有读取与写入访问器。
| 声明形状 | 外部读取 | 外部普通赋值 | 对象初始化器赋值 |
|---|---|---|---|
{ get; set; } | 可以 | 可以 | 可以 |
{ get; private set; } | 可以 | 不可以 | 不可以 |
{ get; init; } | 可以 | 不可以 | 可以 |
{ get; } | 可以 | 不可以 | 不可以 |
=> expression | 可以 | 不可以 | 不可以 |
存储属性与计算属性
自动属性省略访问器主体,例如 public string Name { get; set; }。编译器为它创建隐藏的 幕后字段(backing field) ,并生成读取和写入该存储的访问器。属性初始值设定项会在构造过程中为这份存储赋值。
需要控制逻辑时,可以显式声明幕后字段,再在访问器中引用它。这样适合范围检查、规范化输入或维护简单不变量。访问器应先验证,再写入字段,否则抛出异常时对象可能已经进入部分更新状态。
计算属性不需要自己的存储。public decimal Total => UnitPrice * Quantity; 每次读取都会根据当前状态求值,因此不会出现独立缓存过期的问题。计算很昂贵或需要 I/O 时,应改用名称明确的方法,或者设计带清晰失效规则的缓存。
C# 14 的 field 上下文关键字提供第三种形状。访问器可以通过 field 使用编译器合成的幕后字段,同时保留自动实现的另一个访问器。这减少了只为一小段验证逻辑而声明字段的样板代码。
init 与 required 回答不同问题
init 访问器(init accessor) 限制属性可以在哪里赋值。调用方可以在对象初始化器中设置它,类型的构造过程也可以建立初始状态;构造阶段结束后,普通代码不能再次给它赋值。这个限制针对属性赋值,不会冻结属性引用的对象。
必需成员(required member) 要求创建表达式初始化某个字段或属性,除非所调用的构造函数声明自己已经满足全部必需成员。required 不决定之后能否修改,因此既可配合 set,也可配合 init。它也不会自动验证字符串非空、数值范围或业务关系。
非可空引用类型、required 和运行时验证各自处理一层问题。非可空注解参与编译器的空状态分析,required 检查对象创建语法是否遗漏成员,访问器或构造函数则执行真正的运行时不变量。健壮的边界类型经常需要三者配合,而不是选择其中一个。
调用语法隐藏了方法调用
编译后,读取和写入属性对应特殊名称的方法,通常可在元数据中看到 get_Name 与 set_Name。反射把属性作为属性元数据公开,同时也能取得相应访问器。调用方仍写 customer.Name,编译器负责绑定到访问器调用。
这解释了为什么属性能够出现在接口中,也能声明为 virtual 或 abstract。实现或重写提供的是访问行为,不是要求所有类型具有同名字段。它也解释了为什么调试器读取一个属性可能执行用户代码。
示例
从自动属性开始
第一个例子把简单状态交给自动属性,把派生值写成计算属性,并把库存变化放进方法。private set 让调用方不能绕过 TrySell 直接替换库存。
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
var item = new InventoryItem(openingStock: 3)
{
Name = "Keyboard",
UnitPrice = 79.90m
};
Console.WriteLine($"{item.Name}: {item.Stock} units, {item.InventoryValue:F2}");
Console.WriteLine($"sold: {item.TrySell(2)}");
Console.WriteLine($"remaining: {item.Stock}");
public sealed class InventoryItem
{
public InventoryItem(int openingStock)
{
if (openingStock < 0)
throw new ArgumentOutOfRangeException(nameof(openingStock));
Stock = openingStock;
}
public required string Name { get; init; }
public decimal UnitPrice { get; init; }
public int Stock { get; private set; }
public decimal InventoryValue => UnitPrice * Stock;
public bool TrySell(int quantity)
{
if (quantity <= 0 || quantity > Stock)
return false;
Stock -= quantity;
return true;
}
}# not executed here: the .NET SDK and C# compilers are unavailableName 必须出现在创建表达式中,之后也不能通过普通赋值修改。UnitPrice 没有 required,所以遗漏它仍能编译,并得到 decimal 的默认值 0;真实领域若不接受零价,还需要构造或验证契约。
InventoryValue 始终根据当前 Stock 计算。库存只有私有 setter,但 TrySell 仍能在类型内部使用复合赋值;方法集中处理数量范围,并以返回值表达一次预期的拒绝。
用 field 验证赋值
第二个例子使用 C# 14 的字段支持属性。调用方仍然面对普通属性,init 和私有 set 则分别规范化名称与保护积分变化入口。
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
var profile = new CustomerProfile
{
DisplayName = " Ada "
};
profile.AddPoints(25);
Console.WriteLine($"{profile.DisplayName}: {profile.LoyaltyPoints}");
try
{
profile.AddPoints(-1);
}
catch (ArgumentOutOfRangeException)
{
Console.WriteLine("negative points rejected");
}
public sealed class CustomerProfile
{
public required string DisplayName
{
get;
init => field = string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Display name is required")
: value.Trim();
}
public int LoyaltyPoints
{
get;
private set => field = value >= 0
? value
: throw new ArgumentOutOfRangeException(nameof(value));
}
public void AddPoints(int points) => LoyaltyPoints += points;
}# not executed here: the .NET SDK and C# compilers are unavailableDisplayName 的 required 检查创建代码没有遗漏成员,init 主体才负责拒绝空白并保存裁剪后的值。这两个约束不能互相替代。调用方即使显式写入 null,required 条件也算满足,因此运行时验证仍然必要。
AddPoints 把结果写回 LoyaltyPoints,所以私有 setter 会检查相加后的最终值。这里的负数调用被拒绝,是因为结果小于零;若业务规则规定参数本身必须为正,还应在方法入口单独检查 points。
保护集合所有权
第三个例子把列表保留在对象内部,只通过只读包装公开。属性只读并不等于对象深度不可变,因此类型仍用方法控制集合变化。
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
var order = new PurchaseOrder { Number = "PO-42" };
order.AddLine(new OrderLine("Keyboard", 2, 79.90m));
order.AddLine(new OrderLine("Cable", 1, 12.50m));
Console.WriteLine($"{order.Number}: {order.Lines.Count} lines");
Console.WriteLine($"total: {order.Total:F2}");
public sealed class PurchaseOrder
{
private readonly List<OrderLine> _lines = [];
public PurchaseOrder()
{
Lines = _lines.AsReadOnly();
}
public required string Number { get; init; }
public ReadOnlyCollection<OrderLine> Lines { get; }
public decimal Total => _lines.Sum(line => line.Quantity * line.UnitPrice);
public void AddLine(OrderLine line)
{
ArgumentNullException.ThrowIfNull(line);
if (line.Quantity <= 0 || line.UnitPrice < 0)
throw new ArgumentOutOfRangeException(nameof(line));
_lines.Add(line);
}
}
public sealed record OrderLine(string Product, int Quantity, decimal UnitPrice);# not executed here: the .NET SDK and C# compilers are unavailableLines 没有 setter,调用方不能替换包装对象;ReadOnlyCollection<T> 也不公开添加和删除操作。内部列表改变时,包装视图会反映最新内容,这正是此 API 选择的实时视图语义。
元素使用不可变记录,避免调用方取得某一行后再偷偷修改数量。若元素本身可变,只读集合只能保护集合结构,不能保护元素状态;这种所有权决策必须在类型契约中明确。
陷阱
访问器递归调用自身
修复方法: 显式实现时写入幕后字段,或者在 C# 14 中使用 field。搜索访问器主体中的属性自身名称,并为一次读写添加最小测试;不要把这种错误当成普通验证异常来捕获。
在 getter 中隐藏 I/O 或状态变化
修复方法: 有明显成本、失败模式或副作用的操作使用方法,例如 LoadReportAsync()。计算属性保持可预测;必须缓存时,明确缓存的所有者、线程行为与失效条件。
把 init 或私有 setter 当成深度不可变
修复方法: 对外暴露不可变集合、只读包装或快照,并控制元素本身的可变性。审查属性类型形成的整个对象图,不要只看 setter 的可访问级别。
把 required 当成验证器
修复方法: 在可信边界内执行空值、格式和跨字段验证。把 required 当作调用方体验与编译时完整性工具,不要把它当作运行时数据验证或安全边界。
在验证前修改幕后状态
修复方法: 先在局部值上完成验证和规范化,再提交字段更新。涉及多个成员的原子状态转换应使用方法或工厂,把全部条件检查完后再一次性修改对象。
让可写属性破坏哈希或集合身份
修复方法: 用稳定且不可变的值定义键身份,或者在修改前移出、修改后重新加入。记录类型也要检查参与值相等性的属性是否会引用可变对象。
属性在元数据中的形状
C# 源码把属性呈现为一个成员,但公共语言运行时通过访问器方法执行它。常规实例 getter 没有参数并返回属性类型,setter 接收一个属性类型的参数并返回 void。属性元数据把这些方法关联起来,让反射、序列化器和绑定框架能够把它们视为一个逻辑属性。
自动属性的幕后字段是实现细节。它的名称、特性和是否存在都不应成为应用契约;从自动属性改为显式幕后字段后,依赖编译器字段名称的反射代码很容易失效。需要持久化或传输时,应绑定公开属性或明确声明的契约,而不是扫描编译器生成字段。
属性调用也参与普通成员规则。静态属性没有实例接收者,虚属性根据运行时类型分派,接口属性要求实现提供兼容访问器。字段没有这些多态行为,因此把属性描述成“公开字段的语法糖”会漏掉重要语义。
复合赋值仍会读后再写
account.Balance += amount 不是对某个公开存储位置的直接修改。概念上,它先调用 getter 取得旧值,计算新值,再调用 setter 写回。getter 或 setter 中任一逻辑都可能运行并抛出。
这对带副作用的访问器尤其危险。一次看似单独的复合赋值会同时触发读与写,线程之间也可能在两步之间交错;属性访问本身不提供原子性。共享计数应使用锁、Interlocked 或更高层同步契约,而不是依赖 Count++ 的简短语法。
只写属性无法参加复合赋值,因为运算需要先读取旧值。只有 getter 的属性则不能接收最终写回。编译器在调用点检查这些访问器能力。
C# 14 的字段支持属性
field 是属性访问器中的上下文关键字,表示该属性由编译器合成的幕后字段。它允许一个访问器保持自动实现,另一个访问器增加验证。例如,getter 可以只写分号,init 则在写入 field 前规范化输入。
field 不会让多个属性共享存储,每个字段支持属性都有自己的合成字段。它也不是可以传给其他 API 的公开名称,调用方仍只能通过属性访问。需要多个属性共同维护一个值或进行跨成员协调时,显式字段通常更清楚。
已有成员确实可能名为 field。在属性访问器范围内需要引用该成员时,应写成 @field,否则新关键字可能改变名称解析。升级到 C# 14 后,包含这种名称的生成代码值得单独检查。
字段支持属性减少样板代码,却没有改变访问器设计规则。验证仍应在修改前完成,getter 仍不应隐藏昂贵工作,异常和线程行为仍属于 API 契约。语法变短不能代替行为审查。
初始化契约的边界
init 的限制由编译器在赋值位置执行。它提供浅层的赋值限制:属性所引用的列表、字典或普通类仍可通过自己的 API 修改。若需要真正不可变的对象图,成员类型本身也必须不可变,或者对象必须复制传入数据并只公开不可变视图。
required 同样主要是编译时协议。创建类型实例的 C# 表达式必须设置所有可见必需成员,但显式赋默认值仍算“已设置”。非可空分析可能另外警告 null,两套诊断表达的是不同问题。
标有 SetsRequiredMembers 的构造函数告诉编译器:它已经初始化全部必需成员。编译器不会检查这个承诺是否真实,所以该特性是一个需要审计的逃生口。生成代码机械添加它时,遗漏的成员会从调用点诊断中消失。
反射和某些对象构造基础设施不一定通过普通 C# 创建表达式,因此不能把 required 当成反序列化保证。应针对实际使用的序列化器和配置运行端到端测试,并在数据进入可信模型时验证不变量。框架支持会随版本和选项变化,类型设计不能只靠一个关键词猜测绑定行为。
继承会扩大必需成员集合
派生类型会继承基类型的必需成员,并可以增加自己的必需成员。调用方创建派生实例时,必须满足最终类型的完整集合。重写一个必需属性不能移除它的必需状态。
这会影响工厂和带 new() 约束的泛型代码。具有必需成员的类型不能直接用作依赖无参 new() 约束来创建实例的类型实参,因为泛型创建点无法提供对象初始化器。工厂需要接收初始化数据或由类型提供能明确建立契约的构造路径。
API 演进时新增 required 也会给重新编译的调用方增加工作。它也许不会改变现有二进制中的访问器签名,却会改变新的源代码创建要求。公共库应把这种变化当成调用方契约变更来评估。
属性与对象不变量
单属性验证只能看到一次赋值。如果 Start 必须早于 End,分别公开两个 setter 会让赋值顺序决定中间状态,甚至让任何顺序都先失败一次。构造函数、工厂或 ChangeWindow(start, end) 方法可以先验证整组候选值,再一起提交。
规范化也属于契约。setter 若自动裁剪名称或调整大小写,读取结果可能不同于调用方写入的文本;这可能合理,但应被测试和记录。对密码、令牌或签名输入进行静默规范化通常会破坏数据,应由领域要求决定。
属性抛出异常后,对象应尽量保持原状态。先计算、验证并保存到局部变量,再更新幕后字段,可以让单属性修改具有清晰的提交点。需要更新多个字段时,同样先建立完整的新状态,再执行不会失败的赋值部分。
可观察不变量还包括相等性、哈希和排序。若这些行为依赖可写属性,修改会影响集合位置、缓存键或去重结果。标识属性通常应在构造后稳定,状态属性则不应偷偷参与标识。
可访问性与重写保持契约
访问器的可访问性决定调用方能执行什么,不只是文档提示。公开属性上的 private set 不会出现在外部赋值的候选操作中,因此调用方代码直接编译失败。它比“请勿修改”的注释更可靠,但声明类型内部仍要负责所有写入路径。
虚属性的重写必须保留基类契约允许的访问器。调用方若通过基类引用读取属性,运行时会分派到派生实现;派生 getter 因而不应突然加入破坏基类预期的 I/O、副作用或更弱不变量。多态属性需要像多态方法一样审查替换行为。
隐藏继承属性与重写不同。派生类型使用 new 声明同名属性后,通过基类静态类型访问时仍选择基类属性,通过派生静态类型访问时则选择新属性。同一个对象因引用类型不同而呈现两套状态,通常比明确的重写或改名更难维护。
接口只规定属性访问器形状,不规定幕后字段。一个实现可以存储值,另一个实现可以计算值,但两者都应满足接口对成本、失败和可变性的语义约定。若这些约定对调用方重要,应写进接口文档并用契约测试覆盖。
特性可以指向不同产物
属性声明上的特性默认作用于属性元数据,不会自动作用于编译器生成的幕后字段。需要标注自动属性的存储时,可以使用 field: 特性目标;需要标注访问器返回值或方法时,则应选择相应目标。
这个区别会影响序列化、验证与反射工具,因为不同工具读取的元数据位置并不相同。添加特性前先确认使用方检查属性、字段还是访问器,不能只根据源码中紧邻的位置推断。
集成测试可以通过实际使用方读取一份最小模型,确认特性落在预期元数据目标上。
4个问题 · 1 道输出预测题 · 2 道找错题