
属性、事件、委托与运算符从 C# 语法到元数据契约系列C# 与常用数据结构源码剖析 · 高级特性篇版本边界C# 12、Roslyn 与 .NET 8Unity 工具链另行核验阅读原则元数据契约可以依赖编译器生成的私有字段名和逐条 IL 不应依赖一、不是简单“语法糖”而是语言、元数据和运行时三层协作属性在元数据中关联访问器方法事件关联 add/remove 方法委托是继承MulticastDelegate的运行时类型用户定义运算符编译成带特殊名称的静态成员。C# 编译器负责名称绑定、可访问性、过载决议和代码生成CLR/类库负责委托对象、原子操作和调用。博客常把它们展开成一段“完全等价 C#”但那只能当概念模型。Roslyn 可以改变 backing field 名称、属性实现细节、事件 CAS 循环形态和委托缓存策略Debug/Release、语言版本、目标框架也会改变 IL。本文所有“概念展开”均非 Roslyn 逐字输出。需要精确事实时固定 SDK 后检查 metadata 和 IL。二、属性一组访问器不等于一个公开字段public string Name { get; set; } unknown;在元数据层面会有名为 Name 的 property以及get_Name、set_Name这类 special-name 方法自动属性通常还需一个私有后备字段。调用方依赖的是属性和访问器签名不应通过反射寻找类似Namek__BackingField的名字。那是编译器实现细节也可能因手写属性、生成器或其他编译器而不存在。// 概念展开不是逐字生成代码。 private string storage unknown; public string get_Name() storage; public void set_Name(string value) storage value;自动属性与手写属性的可观察语义可能相同IL 不保证“完全没有任何区别”。初始化器、调试特性、nullable 注解、方法体形态和未来优化都可能不同。2.1 init 是调用限制不是字段 initonlypublic string Id { get; init; } ;init 访问器在元数据签名中使用IsExternalInit相关 modreq 来表达仅初始化调用约束不能说它只是 setter 上加 IL 字段的initonly标记。支持该规则的 C# 编译器阻止普通对象初始化阶段之后的赋值。它也不是深不可变public sealed class Config { public Liststring Tags { get; init; } new(); } var config new Config { Tags new Liststring { A } }; config.Tags.Add(B); // 合法属性引用没换List 内容在变反射、不安全代码和某些序列化器还可能绕过普通源码调用规则。init 主要是 API 构造阶段契约不是运行时对象图冻结。2.2 required 是调用方初始化契约C# 11/12 的 required 要求对象创建方在对象初始化器或合适构造路径中初始化成员public sealed class Endpoint { public required Uri Address { get; init; } }编译器通过 required-member 相关特性在元数据传播约束并检查构造调用。构造函数可用[SetsRequiredMembers]声明自己满足要求但该特性主要是给编译器的承诺不会自动证明每条路径真的赋值。反射激活、旧编译器和某些反序列化器可能不执行同样检查运行时仍需验证关键不变量。required 不等于 non-null。required string?可以明确赋 nullrequired string也可能被调用方用 null-forgiving 或反射传入 null。nullable 是静态分析领域验证仍需构造/工厂。2.3 特性目标决定它落在哪对自动属性特性可以明确施加到 property、field、method、param/return 等目标前提是该位置合法public sealed class Model { [field: NonSerialized] [property: Obsolete(Use DisplayName)] public string Name { get; set; } ; }field:针对编译器生成后备字段但不能因此依赖字段名称。property:由反射属性 API 读取序列化器究竟读取字段还是属性决定哪个特性有效。字段目标只适用于确实有编译器后备字段的语法形态手写计算属性未必有该目标。2.4 属性不是免费的数据槽getter 可以计算、加锁、抛异常、做 I/O 或每次分配public IReadOnlyListItem Items BuildSnapshot();调用方不应因属性语法像字段就默认 O(1)。API 设计上避免惊讶性重工作性能分析看访问器方法体而不是关键字。三、事件外部只能订阅/退订发布权留在类型内部字段式事件public event EventHandlerChangedEventArgs? Changed;元数据暴露 add_Changed 与 remove_Changed。类外可以、-不能直接赋值、读取调用列表或 invoke声明类型内部保留发布权。这是事件与 public delegate 字段的关键封装差异。字段式事件通常有私有委托存储。现代编译器常用Interlocked.CompareExchange的 CAS 循环实现线程安全的 add/remove避免两个线程同时组合时丢失订阅// 概念惯用实现不是 Roslyn 逐字输出。 EventHandler? observed; EventHandler? updated; do { observed handlers; updated (EventHandler?)Delegate.Combine(observed, value); } while (Interlocked.CompareExchange(ref handlers, updated, observed) ! observed);remove 同理使用 Delegate.Remove。CAS 只使一次订阅字段更新原子不让处理器线程安全也不让“检查后发布、退订、Dispose”成为一个事务。自定义事件访问器可以把订阅存到别处、加锁、拒绝重复或桥接原生系统public event EventHandler? Changed { add { lock (_gate) _handlers value; } remove { lock (_gate) _handlers - value; } }自定义访问器必须成对维护同一身份规则并避免持锁调用外部代码。事件元数据不要求一定存在同名委托字段。3.1 null 条件调用的局部快照与竞态Changed?.Invoke(this, args);编译器会确保对事件委托引用做一次局部观察再进行 null 检查和调用从而避免“检查非空后另一个线程设为 null导致 NullReferenceException”的经典两次读取竞态。概念上类似// 概念展开。 EventHandlerChangedEventArgs? snapshot Changed; if (snapshot is not null) snapshot(this, args);它不能保证退订返回后处理器绝不会被正在进行的快照调用。发布线程可能已经取得旧调用列表。需要强生命周期保证时要设计锁、generation token、停止并等待 in-flight 调用等协议。不要把?.Invoke称为完整线程安全。四、委托不可变调用列表和值相等规则委托对象表示一个或多个调用项。单个调用项包含目标对象实例方法和方法身份静态方法通常没有实例目标。MulticastDelegate 的具体私有字段布局不是契约不能写成永远是_next链表。现代运行时可用对象/数组等内部表示调用列表。委托是不可变值式对象Combine/返回新组合结果Remove/-返回移除后的结果原对象不改。字段d h实质是读取 d、组合、再赋回对普通委托字段不是自动原子多线程更新需 CAS/锁。字段式 event 的编译器访问器通常替你处理这一步。Action a First; Action b Second; Action combined a b; combined(); // 按调用列表顺序调用 First再 Second4.1 Combine、Remove 与相等两个兼容委托组合时调用列表连接。Delegate.Remove 从源列表中移除与 value 调用列表匹配的最后一个连续片段它不等于“删除所有相同方法”Action h Handler; Action chain h h h; chain - h; // 通常只移除最后一个匹配项委托相等比较委托类型与调用列表中每个调用项的目标/方法等语义。两个分别写出的 lambda 即使源代码相同也通常不是可互相退订的同一委托publisher.Changed (_, _) Log(); publisher.Changed - (_, _) Log(); // 新委托通常无法移除原订阅应保存实例EventHandler handler (_, _) Log(); publisher.Changed handler; publisher.Changed - handler;方法组转换也可能由编译器缓存静态委托是否缓存、何时分配是 C# 12 Roslyn 实现细节必须用 IL 和基准核验。4.2 闭包与生命周期捕获 lambda 的目标通常是编译器生成闭包对象LargeScene scene LoadScene(); publisher.Changed (_, _) scene.Refresh();publisher 持有委托委托持有闭包闭包持有 scene。只要 publisher 是长寿命根scene 就不会回收。即便 lambda 只捕获this的一个字段通常也可能持有整个 this。static lambda 禁止捕获但要传状态需重新设计处理器。4.3 调用异常与异步误区多播委托按顺序同步调用。某个处理器抛异常时Invoke 立即停止后续处理器不会执行异常传给发布者。若要隔离观察者可取得调用列表逐个 try/catch但要定义日志、聚合和继续策略foreach (EventHandlerChangedEventArgs handler in handlers?.GetInvocationList() ?? Array.EmptyDelegate()) { try { handler(this, args); } catch (Exception ex) { Report(handler, ex); } }上例的类型转换/数组分配行为要按实际实现测量也不是默认 event 语义。不要把asynclambda 挂到返回 void 的 EventHandler 后期待发布者 await。它会成为 async void发布者在第一个未完成 await 时认为处理器已经返回异步异常走 SynchronizationContext/未处理路径。事件签名不可改时处理器边界捕获异常并调用一个返回 Task 的方法需要真正组合异步订阅者时定义FuncTArgs, ValueTask/专用异步管线并明确逐个还是并行等待。五、订阅就是强引用Dispose、弱事件与所有权事件泄漏通常发生在短寿命订阅者订阅长寿命发布者后忘记退订。发布者的事件字段形成强引用链。常见方案是让订阅返回 IDisposablepublic sealed class Subscription : IDisposable { private Action? _unsubscribe; public Subscription(Action unsubscribe) _unsubscribe unsubscribe; public void Dispose() Interlocked.Exchange(ref _unsubscribe, null)?.Invoke(); }Interlocked.Exchange使重复 Dispose 只执行一次但仍不能阻止已经快照的并发发布。拥有订阅的对象应在明确生命周期点 Dispose容器可用 CompositeDisposable 集中管理。一次性处理器可以在调用后自退订但异常路径也要 finally 退订。发布者寿命不长于订阅者时忘退订通常不会单独阻止回收但显式释放仍能避免意外调用。弱事件通过 WeakReference 避免发布者强持有订阅者可减少某些 UI 生命周期泄漏却有成本与语义变化处理器可能在任意 GC 后消失需要清理死条目静态方法/闭包目标识别复杂并发也需同步。弱事件不是默认最佳方案优先清晰所有权和 Dispose只有无法控制订阅生命周期时采用成熟框架实现。事件不会自动 Dispose 订阅者订阅者 Dispose 也不会自动从所有发布者退订除非代码显式实现协议。六、运算符静态特殊成员与编译期重载决议public readonly record struct Distance(double Meters) { public static Distance operator (Distance left, Distance right) new(left.Meters right.Meters); }元数据中用户定义通常对应名为op_Addition的 public static special-name 方法。调用表达式先由编译器做候选收集、用户定义转换和“最佳成员”决议再发出调用。不能把所有a b都机械翻成某个类型的 op_Addition内置数值、字符串连接、动态绑定、泛型静态抽象各有规则。应成对维护语义重载通常也提供!值类型/类若改变相等运算符还要让 Equals/GetHashCode 与集合语义一致。运算符不应制造令人惊讶的副作用或与普通数学直觉冲突。6.1 隐式与显式转换public static explicit operator Distance(double meters) new(meters); public static implicit operator double(Distance value) value.Meters;隐式转换应保证不会丢信息、不会意外失败且成本不惊讶可能抛异常或丢精度时使用 explicit。转换链并非任意拼接编译器按规范限制用户定义转换数量和候选类型。as不调用用户定义转换模式匹配也通常不把转换当类型匹配。6.2 checked 用户定义运算符C# 11 允许为部分运算符/显式转换声明 checked 版本checked 上下文可选择相应成员public readonly struct Counter { public int Value { get; } public Counter(int value) Value value; public static Counter operator (Counter a, Counter b) new(unchecked(a.Value b.Value)); public static Counter operator checked (Counter a, Counter b) new(checked(a.Value b.Value)); }具体允许哪些 checked operator、是否必须有非 checked 配对以 C# 12 编译器诊断为准。checked 只控制规定的溢出操作不会自动验证单位、范围或所有被调用代码。6.3 static abstract interface members 与泛型数学C# 11/12 可在接口中声明 static abstract operator让泛型算法通过类型参数调用运算符public interface IAdditiveTSelf where TSelf : IAdditiveTSelf { static abstract TSelf operator (TSelf left, TSelf right); static abstract TSelf Zero { get; } } static T SumT(ReadOnlySpanT values) where T : IAdditiveT { T total T.Zero; foreach (T value in values) total value; return total; }这里的T.Zero和在编译期受约束不是通过实例接口虚调用。IL/JIT 可能使用 constrained generic 和特化是否内联/装箱要实测。接口设计必须避免不同实现对单位、溢出和恒等元理解不一致。dynamic 运算符则由运行时 binder 根据实际类型解析异常时机和性能不同Unity AOT/裁剪还可能限制动态绑定依赖。七、UnityC# event、UnityEvent 和序列化是三套边界普通 C# event 不由 Unity Inspector 序列化订阅列表。UnityEvent 是 UnityEngine 的可序列化事件体系可在 Inspector 配置 persistent listener并通过反射/序列化数据定位目标与方法运行时AddListener的监听与 persistent listener 生命周期规则不同。UnityEvent 不是 C# event 的语法翻译也不自动具有 CAS add/remove。选择依据纯代码、类型安全、无需 Inspector 配置C# event/delegate 通常更直接设计师需要 Inspector 连线、UnityEngine.Object 目标评估 UnityEvent需要 await 多个处理器两者都不是天然异步流应设计专用接口高频热路径在目标 Player 测调用、分配和序列化成本不写固定倍数。MonoBehaviour/ScriptableObject 订阅静态事件或长寿命服务时应在 OnEnable/OnDisable 或 Awake/OnDestroy 成对管理并考虑 Unity 对象被 Destroy 后的特殊 null 语义。重复 OnEnable 会导致重复订阅lambda 不保存实例则难以退订。Unity 内置序列化主要面向字段而非普通属性。[field: SerializeField]可把特性放到自动属性后备字段但是否被当前 Unity 版本识别、重命名字段后的数据迁移、IL2CPP/裁剪都要测试。不能把编译器私有字段名当长期存档 schema需要重命名时使用 Unity 支持的迁移特性/显式 DTO。IL2CPP AOT 对静态泛型运算符、反射事件绑定、dynamic 和未保留方法可能有裁剪/实例化问题。固定 Unity、Roslyn、API compatibility 与 linker 配置在目标平台做 Player 构建编辑器 Mono 成功不代表 AOT 成功。八、失败反例反例一反射写死自动属性 backing field 名换编译器或手写属性即失效。读取 PropertyInfo/访问器或定义显式稳定序列化字段。反例二以为 init 和 required 保证非空深不可变引用对象仍可变反序列化可能绕过调用方检查。构造/工厂验证并使用不可变成员。反例三if (Changed ! null) Changed(...)两次读取产生退订竞态。使用Changed?.Invoke/局部快照若要求强退订保证设计同步协议。反例四用两个相同文本 lambda 订阅和退订它们是不同委托实例原订阅保留。保存 handler/token 并 Dispose。反例五事件处理器异常互相隔离默认多播调用在首个异常停止。发布者明确 fail-fast 或逐个捕获策略。反例六async 事件处理器可被 Invoke 等待EventHandler 返回 voidasync lambda 变 async void。把核心逻辑返回 Task并在事件边界观察异常。反例七用户定义 与 GetHashCode 不一致HashSet/Dictionary 行为失去等价契约。相等、哈希与 comparer成组测试。反例八Unity 静态事件忘记退订场景对象被长寿命静态委托持有或 domain reload 配置下累积重复订阅。生命周期成对管理并测试重载模式。九、IL、反射与并发实验固定最小项目Project SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknet8.0/TargetFramework LangVersion12.0/LangVersion Nullableenable/Nullable Optimizetrue/Optimize /PropertyGroup /Project用反射区分 property/event 与方法/字段Type type typeof(Model); foreach (PropertyInfo p in type.GetProperties()) Console.WriteLine($P {p.Name}: get{p.GetMethod}, set{p.SetMethod}); foreach (EventInfo e in type.GetEvents()) Console.WriteLine($E {e.Name}: add{e.AddMethod}, remove{e.RemoveMethod}); foreach (FieldInfo f in type.GetFields( BindingFlags.Instance | BindingFlags.NonPublic)) Console.WriteLine($F {f.Name}: {string.Join(,, f.GetCustomAttributesData())});用 ILSpy/ildasm/SharpLab 查看init setter 的 modreqrequired-member 特性字段目标特性事件 add/remove 是否 CAS?.Invoke是否单次加载捕获与非捕获 lambda 的目标静态方法组缓存op_Addition/op_CheckedAddition/op_Implicit 等名称泛型静态抽象调用的 IL。记录 SDK/Roslyn、Release 和源码不能把一次输出推广到所有编译器。并发测试让多个线程重复 add/remove 唯一 handler同时发布并统计每个 handler 的允许调用范围。注意不能断言退订返回后零次 in-flight测试要基于设计协议。用处理器故意抛异常验证后续是否停止再测试隔离发布器。GC 测试创建短寿命订阅者订阅长寿命 publisher以 WeakReference 和内存快照观察退订前后引用链。局部变量/JIT 可能延长生命周期最好把创建放进 NoInlining 辅助方法WeakReference 单次结果不是泄漏的唯一证据。BenchmarkDotNet 分开测单处理器/多播、捕获/非捕获、订阅变更频率、直接方法调用、C# event 与目标 UnityEvent。报告完整环境与原始分配不虚构固定数字UnityEvent 必须在目标 Player 用 Unity Profiler 测不能与桌面 CoreCLR结果混用。运算符性质测试覆盖交换/结合仅当领域承诺、零元、checked 溢出、隐式转换往返、Equals/GetHashCode一致和泛型实现。浮点与饱和算术不一定满足传统代数律测试应来自领域契约而不是盲套数学模板。十、设计清单与结论属性审查访问器是否有惊讶性工作init/required 的边界是否被运行时验证补足特性目标是否与序列化器一致是否依赖编译器字段名可变引用是否暴露。事件/委托审查谁拥有发布权add/remove 是否需并发安全处理器异常策略同步还是异步订阅由谁 Dispose退订是否需等待 in-flight闭包捕获什么是否重复订阅弱事件是否真的必要。运算符审查语义是否直观Equals/GetHashCode 配套隐式转换是否无损且不失败checked 行为是否明确泛型静态抽象实现是否一致dynamic/AOT 是否验证。属性不是字段换皮它是访问器元数据契约。事件不是委托字段换名它限制外部只能 add/remove并需要发布与生命周期协议。委托是不可变调用列表但组合字段更新、处理器代码和异步异常都不自动线程安全。运算符是编译期重载决议到静态特殊成员的桥梁而非简单文本替换。固定 C# 12/.NET 8 后反射可以证明元数据IL 可以证明 Roslyn 当前翻译并发/GC实验可以证明运行行为。到了 Unity 和 IL2CPP还要重新验证序列化、UnityEvent、裁剪和 AOT。只依赖公开契约不依赖私有字段形状代码才能跨编译器和运行时演进。下一篇Unity 中的 C# 语言与运行时边界