记录类型中的意外不一致性
前几天我在排查代码bug时,发现这源于我对C#记录类型工作原理的误解。可能只有我会期待它们以我设想的方式工作,但考虑到潜在的影响,我认为值得撰文说明。
这个问题是我在修改2029年英国大选网站时发现的,不过它实际上与选举无关,因此我没有将其纳入选举网站的博客系列中。
回顾:非破坏性变更
当记录类型被引入C#时,同时引入了带有with
运算符的"非破坏性变更"概念。其核心思想是记录类型可以是不可变的,但你可以轻松高效地创建新实例,这些实例包含现有实例的数据,但某些属性值不同。
例如,假设有以下记录类型:
|
|
然后可以这样使用:
|
|
这不会改变第一个实例的数据(entry.Score
仍为5000)。
回顾派生数据
记录类型不允许为基本构造函数指定构造函数体(这是我在之前关于记录类型和集合的文章中打算写的内容),但你可以根据基本构造函数参数的值初始化字段(以及自动实现的属性)。
举个简单(且高度人为设计的)例子,你可以创建一个在初始化时判断数值奇偶性的记录类型:
|
|
乍看运行良好:
|
|
直到本周前,我都以为一切正常。
问题:混合使用with和派生数据
当混合使用这两个特性时问题就出现了。如果我们修改上面的代码(保持记录类型定义不变),使用with
运算符而不是调用构造函数来创建第二个Number,输出就会出错:
|
|
“Value = 3, Even = True"显然不正确。
原因分析
出于某种原因,我一直假设with
运算符会使用新值调用构造函数。但实际情况并非如此。上面的with
运算符大致会转换为如下代码:
|
|
<Clone>$
方法(至少在本例中)调用生成的拷贝构造函数(Number(Number)
),它会复制Value和Even的支撑字段。
这些都是有文档记载的——但目前没有任何关于可能引发不一致性的警告。(我会发邮件给微软团队看看能否添加相关内容。)
注意由于Value是在克隆操作之后设置的,我们无论如何也无法编写一个能正确处理这种情况的拷贝构造函数。(至少没有简单的方法——稍后我会提到一个复杂的方法。)
如果有人想"为什么不直接使用计算属性?",显然这样没问题:
|
|
任何可以这样按需计算的属性都很完美——不仅避免了本文描述的问题,内存效率也更高。但这对于我在选举网站中使用的大多数记录类型并不适用,这些记录通常使用集合初始化,然后按ID索引,或执行其他相对昂贵的计算。
解决方案
目前我想到四种方案,都不够理想。非常期待听到其他人的建议。
方案1:接受现实继续开发
既然知道了这个问题,我可以避免在"简单"记录类型之外使用with
运算符。如果没有计算属性或字段,with
运算符仍然非常有用。
风险在于我可能会在最初"简单"但后来添加了计算成员的记录类型上使用with
运算符。
方案2:编写Roslyn分析器检测问题
理论上,至少对于在声明它们的解决方案中使用的任何记录类型(我的选举网站中的所有内容),可以编写一个Roslyn分析器来:
- 分析每个声明记录中的每个成员初始化器,查看使用了哪些参数
- 分析每个
with
运算符使用,查看设置了哪些参数 - 当两者有任何交集时记录错误
这很有吸引力,可能对其他人也有用。缺点是必须实现Roslyn分析器。我已经很久没写分析器了,但估计这仍然是个相当复杂的过程。如果真能找到时间,这很可能是我会采取的做法——但我希望有人评论说要么这个分析器已经存在,要么解释为什么它不需要。
2025-07-29更新:我已经写了一对分析器!详见我的后续文章。
方案3:找到安全使用with的方法
我一直在尝试使用Lazy<T>
延迟计算属性直到首次使用,这样就会在with
运算符设置新属性值之后。我想出了以下模式——我认为可行,但非常混乱。采用这种模式不需要父记录中的每个新参数都反映在嵌套类型中——只需要用于计算属性的参数。
|
|
这种方案:
- 需要记住这样做很痛苦
- 开始时有大量额外代码(虽然设置好后,添加新计算成员不算太糟)
- 由于添加了
Lazy<T>
实例,内存效率低
这种低效在"大型"记录中可能无关紧要,但在只有几个参数(特别是如果只是数字等)的"小型"记录中使用计算属性会很痛苦。
方案4:请求语言变更
我提出这个只是为了完整性。我非常信任C#设计团队:他们都是深思熟虑的聪明人。如果我是第一个提出这个"问题"的人,我会非常震惊。更可能的是,这种行为利弊已经被详细讨论过,各种替代方案也被讨论和原型化过,最终选择了当前行为作为最不坏的选项。
也许Roslyn编译器可以开始发出警告(方案2),这样我就不用写分析器了——也许可以为C#后续版本添加替代方案(理想情况下为记录类型提供更灵活的初始化方式,例如在实例"就绪"时调用的特殊命名成员,它仍然可以写入只读属性)…但如果没有明确鼓励,我可能不会开始为此创建提案。
结论
我很少在C#中发现"footgun”,但这确实让我感觉像一个。也许只是因为我