意外的不一致性:C#记录类型中的陷阱
前几天我在调试代码时发现了一个bug,根源在于我对C#记录类型工作机制的误解。虽然可能只有我会以这种方式理解它们,但我觉得值得写出来分享。
这个问题是我在修改2029年英国大选网站时发现的,但由于与选举无关,因此没有包含在选举网站的博客系列中。
回顾:非破坏性变更
当记录类型引入C#时,同时引入了带有with
操作符的“非破坏性变更”功能。记录类型可以是不可变的,但你可以轻松高效地创建新实例,这些实例具有与现有实例相同的数据,但某些属性值不同。
例如,假设你有这样的记录:
|
|
你可以编写如下代码:
|
|
这不会改变第一个实例中的数据(因此entry.Score
仍然是5000)。
回顾派生数据
记录类型不允许为主构造函数指定构造函数体(这是我在之前关于记录和集合的文章中想写的内容),但你可以根据主构造函数参数的值初始化字段(以及自动实现的属性)。
作为一个非常简单(且高度人为)的例子,你可以创建一个记录,在初始化时确定值是奇数还是偶数:
|
|
乍一看,这看起来不错:
|
|
到目前为止一切顺利。直到本周,我还以为这一切都很好。
问题:混合使用with和派生数据
问题出现在混合使用这两个功能时。如果我们修改上面的代码(同时保持记录本身不变),使用with
操作符而不是调用构造函数来创建第二个Number,输出就变得不正确:
|
|
“Value = 3, Even = True"真的很不好。
这是怎么发生的?出于某种原因,我一直假设with
操作符会用新值调用构造函数。但实际上并不是这样。上面的with
操作符大致翻译成这样的代码:
|
|
<Clone>$
方法(至少在这种情况下)调用生成的复制构造函数(Number(Number)),它复制Value和Even的支持字段。
这一切都有文档记录——但目前没有任何关于可能引入的不一致性的警告。(我会给Microsoft的人发邮件,看看是否能加入一些警告。)
注意,由于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#中发现陷阱,但这对我来说确实像一个。也许只是因为我