对于使用它的代码来说,属性看起来就像一个字段(order.Total、order.Total = 5),但它其实是一对方法:返回值的 get 访问器和接收值的 set 访问器。这层间接让类可以检查值、计算值或拒绝写入,而不必改变调用方的使用方式。
字段与属性
公共字段会把对象存储的直接访问权交出去。任何人都能往里面放任何东西:
public class Account
{
public decimal Balance; // any code can write -500 here
}
属性在调用方和存储之间加了一层代码。下面是完整的手写形式:一个私有的后备字段,加上一个带有两个访问器的属性。在 set 内部,关键字 value 就是被赋予的值。
输出:
250
Rejected a negative balance
250
除了验证之外,属性之所以重要,还因为 .NET 生态中的大部分东西都只认属性、忽略字段:接口可以声明属性,但不能声明实例字段;WPF 和 MAUI 中的数据绑定绑定的是属性;System.Text.Json 默认序列化公共属性,跳过字段。
自动属性
如果访问器只是读写字段,就让编译器替你写。{ get; set; } 是一个自动实现的属性:编译器会为你生成隐藏的后备字段。
输出:
Notebook: 9.99, 10 left
它看起来就像字段,那何必多此一举?因为以后把公共字段改成属性,对使用它的已编译代码(以及 ref 和 out 参数)来说是破坏性变更;而把自动属性改成带验证的完整属性,调用方完全察觉不到。一开始就写 { get; set; },可以免费保留这个选择余地。
private set 与只读属性
大多数状态应该让所有人都能读取,但只能由对象自己修改。给 setter 一个范围更窄的访问修饰符:
输出:
Ines: 2 items, total 15.75
这里有三种只读形式:
{ get; private set; }:类可以随时修改它,外部代码不行。{ get; }(只读自动属性,C# 6):只能在构造函数或初始化器中赋值。对象构造完成后,谁都不能再修改它,类自己也不行。这就是创建不可变属性的方式。=> expression(计算属性):完全不占存储。每次读取都会执行这个表达式,所以Total总是与列表保持同步。
计算属性应该开销很小,并且没有副作用,因为调用方读取属性时往往很随意,会在循环里读,也会在调试器里读。如果获取值需要真正的工作量(一次数据库查询、一次大计算),就把它写成方法,比如 CalculateTotal(),让开销一目了然。
表达式主体访问器
访问器只有一行的完整属性,可以给每个访问器使用 =>:
private string title;
public string Title
{
get => title;
set => title = value?.Trim() ?? "";
}
public decimal Total => SumPrices(); 是只有 getter 的属性的简写;get => ... 是同样的思路,只是用在同时还有 setter 的属性里。
getter 和 setter 中的逻辑:通知与延迟值
因为访问器是方法,所以它们能做的不只是存储值。两种常见模式:在 setter 中发出变更通知,以及在第一次读取时计算值并缓存起来。
输出:
(building summary)
Theme: light
Theme: light
Theme changed
(building summary)
Theme: dark
Changed 是一个事件,这种 setter 写法正是 WPF 和 MAUI 中 INotifyPropertyChanged 实现的样子。nameof(Theme) 能在属性被重命名时让字符串保持同步。
无限递归错误
最常见的属性 bug,是 setter 给属性本身赋值,而不是给后备字段赋值:
public string Name
{
get { return Name; } // calls get again, forever
set { Name = value; } // calls set again, forever
}
每个访问器都在调用自己,程序最终因 StackOverflowException 而崩溃,而这个异常无法被捕获。修复方法是读写一个单独的字段(小写的 name),或者使用自动属性。C# 14 正是为这种情况加入了 field 关键字:在访问器内部,field 指代编译器生成的后备字段,所以不用声明字段,set => field = value.Trim(); 也能正常工作。
init 访问器(C# 9)
只读属性迫使你通过构造函数传入每一个值。C# 9 增加了 init,这是一种只允许在对象创建期间使用的 setter:在构造函数中,或者在对象初始化器中。
public class Product
{
public string Sku { get; init; }
public decimal Price { get; init; }
}
var p = new Product { Sku = "MUG-01", Price = 8.50m }; // fine: during creation
p.Price = 4m; // error CS8852: Init-only property or indexer 'Product.Price' can only be assigned
// in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor
结果是一个可以使用初始化器语法的不可变对象,记录类正是这样实现它们的位置属性的。要“修改”这样的对象,就创建一个新对象;记录类型用 with 一行就能做到。
在 C# 7 中,最接近的等价写法是只读属性加一个构造函数参数:
public class Product
{
public string Sku { get; }
public decimal Price { get; }
public Product(string sku, decimal price) { Sku = sku; Price = price; }
}
required 成员(C# 11)
对象初始化器无法强制调用方设置任何东西:即使 Sku 绝不能为空,new Product { } 也能通过编译。C# 11 增加了 required 修饰符:
public class User
{
public required string Email { get; init; }
public string DisplayName { get; init; } = "";
}
var ok = new User { Email = "ana@example.com" };
var bad = new User { DisplayName = "Ana" }; // error CS9035: Required member 'User.Email'
// must be set in the object initializer or attribute constructor.
required 可以和 set 以及 init 一起使用。如果某个构造函数自己会设置这些必需成员,可以用 [SetsRequiredMembers] 特性声明这一点,这样使用该构造函数的调用方就不必再设置一遍。
常见错误
- 访问器中的递归。
set { Name = value; }会调用自己。使用后备字段或自动属性。 - 开销大或有副作用的 getter。 读代码的人会认为读取属性很快,而且重复读取是安全的。需要真正干活时用方法。
- “暂时先用”公共字段。 以后把字段改成属性会破坏二进制兼容性,而且序列化器默认会忽略字段。一开始就用自动属性。
- 所有东西都有公共 setter。 给余额或状态写
{ get; set; },等于邀请无效状态出现。使用private set、init或只读属性,并通过方法修改状态。 - 从只读属性返回可变集合。
public List<string> Tags { get; }只能阻止调用方替换整个列表,却挡不住他们往里添加元素。当集合不应该被外部修改时,返回IReadOnlyList<string>。
常见问题
C# 中 { get; set; } 是什么意思?
它声明了一个自动实现的属性:编译器会创建一个隐藏的私有字段,以及一个返回该字段的 get 访问器和一个给它赋值的 set 访问器。对调用方来说,public string Name { get; set; } 用起来就像一个公共字段,但它其实是一对方法,所以你以后可以加上验证,或者把它改成 private set,而不用修改使用它的代码。
C# 中字段和属性有什么区别?
字段是存放在对象中的变量。属性是一对方法(get 和 set),从外部看起来就像字段。属性让类可以验证值、计算结果或限制写入,数据绑定、序列化器和接口用的也都是属性。公共数据应该以属性的形式公开;字段通常保持私有。
C# 中的 private set 是什么?
public int Stock { get; private set; } 让任何代码都能读取这个属性,但只有类内部的代码能修改它。类就是这样公开自己拥有的状态(比如余额或计数)的,同时让所有修改都经过那些执行规则的方法。
C# 中 init 的作用是什么?
init(C# 9)是一种只在对象创建期间有效的 setter:在构造函数或对象初始化器中。public string Sku { get; init; } 允许 new Product { Sku = "A-1" },但之后再写 product.Sku = "B-2" 会被拒绝,报错 CS8852。它让不可变对象也能使用初始化器语法。
C# 中的 required 属性是什么?
标记为 required(C# 11)的属性必须由每个创建该类型的对象初始化器来设置;漏掉它就是编译错误(CS9035)。它和 init 搭配得很好:public required string Email { get; init; } 必须在创建时提供,之后也无法更改。