2.2 属性与封装的机器视角


2.2 属性与封装的机器视角

本节摘要:属性是"长得像字段的方法"。本节从编译产物讲清自动属性脱糖出什么、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; } }

initset 的区别是机器级的:编译器把 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 是真实项目里常见的坏味道:写入与读出的规则不对称,一旦折扣率变化,历史写入的值就解释不清。属性只做状态存取与一致性维护(如非空检查、范围裁剪),业务规则进显式方法——ApplyDiscountRecalculate 这样的方法名才是复杂逻辑的合法居所。

⚠️ 常见坑:在自动属性的类型上做隐式转换重载,或让 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:可变状态越少,能并行推理的代码越多。

本节要点回顾

  • 属性 = 后台字段加一对方法:语法像字段,身份是方法,接口可声明、ref 不可传;
  • init 限时机、private set 限位置、required 强制初始化:三代限写手段各管一段;
  • 属性保持廉价:复杂逻辑与不对称读写进显式方法;
  • 不暴露可变集合:只读接口或拷贝是封堵漏洞的标准做法;
  • 封装的标尺是承诺面:语义方法优于状态搬运,公共 setter 能少则少。

下一节看行为通道:方法的重载决议与调用机制,编译器如何在多个候选中拍板。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U