Menu

C# 属性:get、set、自动属性、init 与 required

C# 属性的工作方式:基于后备字段的 get 和 set 访问器、自动属性、private set 与只读属性、计算属性、setter 中的验证,以及 C# 9 和 C# 11 引入的 init 与 required 关键字。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

对于使用它的代码来说,属性看起来就像一个字段(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; } 必须在创建时提供,之后也无法更改。

Coddy 编程语言插图

和 Coddy 一起学编程

开始