本节摘要:属性是"长得像字段的方法"。本节从编译产物讲清自动属性脱糖出什么、getter 与 setter 的真实身份、init 与 required 的演进逻辑,并回答一个设计问题:什么时候属性是错的封装。
public class Product { public decimal Price { get; set; } }
这行代码编译后不存在一个叫 Price 的存储位置。编译器生成了三样东西:一个隐藏后台字段(<Price>k__BackingField,命名里的尖括号就是为了不与人名冲突)、一个 get_Price 方法、一个 set_Price 方法。对属性的每次读写都被编译成对这两个方法的调用。验证方式很直接——反编译程序集,或者注意到调试器调用栈里出现过 set_Price 这样的帧。
理解了这一点,很多现象不言自明:属性可以在接口中声明(方法可以);属性不能当 ref 或 out 参数传递(方法调用没有存储地址);属性打断点会落在方法上;通过反射设置属性走的是 PropertyInfo.SetValue 即方法调用。属性是穿了字段外衣的方法对——外衣给了它字段般的调用语法,身体让它能执行任意逻辑。
早期 C# 写一个属性要手写全部:
private decimal _price; public decimal Price { get { return _price; } set { _price = value; } }
C# 3 的自动属性把五压成一(本文开头那行);C# 6 允许只读自动属性 get; 直接在构造中赋值;C# 9 的 init 把可写窗口限制在初始化那一刻:
public record Order(int Id) { public DateTime Created { get; init; } = DateTime.UtcNow; public decimal Total { get; private set; } }
init 与 set 的区别是机器级的:编译器把 init-only 的修改许可只授予对象初始化器与构造函数,其他位置的赋值一律 CS8852 报错。private set 则是把 setter 的可见性收窄到类内部——两种"限写"手段,一个限时机、一个限位置。
C# 11 的 required 补上了另一半:强制调用者必须初始化。
public class Report { public required string Title { get; init; } public required Author Writer { get; init; } } // var r = new Report(); // CS9035:缺少 required 成员 var r = new Report { Title = "月报", Writer = author };
它取代了"构造函数炸弹"——以前为了强制传参要写一堆重载,现在编译器直接检查初始化器是否填全了 required 成员。
public decimal FinalPrice { get => _price * (1 - Discount); set => _price = value / (1 - Discount); // 隐患写法 }
getter 里做少量计算是可接受的(调用者仍预期它快速、无副作用);但抛异常、做 IO、长时间计算都不该进属性——调用方对"点一下属性"的心理预期是廉价的。上面这个带折扣换算的 setter 是真实项目里常见的坏味道:写入与读出的规则不对称,一旦折扣率变化,历史写入的值就解释不清。属性只做状态存取与一致性维护(如非空检查、范围裁剪),业务规则进显式方法——ApplyDiscount、Recalculate 这样的方法名才是复杂逻辑的合法居所。
⚠️ 常见坑:在自动属性的类型上做隐式转换重载,或让 getter 返回内部集合的可变引用。后者等于把封装拆了个洞,外部改完集合你的不变量就碎了。集合暴露用
IReadOnlyList,或者防御性拷贝。
方法与属性都能用表达式体,属于同一轮语法演进:
public string Summary => $"{Title} by {Writer.Name}"; // 只读计算属性 public string this[int i] => Lines[i]; // 索引器
索引器本质是名为 get_Item 的参数化方法对,数组与字典的方括号访问全靠它。知道这个名字,你在序列化框架的"排除列表"里看到 Item 相关成员时就不会疑惑。
封装常被简化成"字段变私有加属性",这是形式不是本质。本质是:类型对外的承诺面要窄且稳定,内部实现随时可换。三个层次递进:公开字段(零封装,实现即承诺);自动属性(形式封装,承诺"读写这个值");带逻辑属性或方法(语义封装,承诺"完成这件事")。第三层次才是面向对象设计的主战场——外部依赖的应是 Order.Cancel() 这个语义,而不是 order.Status = Cancelled 这个字段搬运。前者可以在取消时发事件、记审计、补偿库存,后者什么都不能加。这也是为什么领域驱动设计强烈反对公共 setter:可变状态越少,能并行推理的代码越多。
下一节看行为通道:方法的重载决议与调用机制,编译器如何在多个候选中拍板。