当前位置: 首页 > news >正文

C#记录类型中意外的数据不一致问题解析

意外的记录不一致性

前几天,我在排查代码中的一个bug时,发现这源于我对C#记录类型工作方式的误解。可能只有我一个人期望它们以我理解的方式工作,但我觉得值得写下来分享。

这个问题是我在修改2029年英国大选网站时发现的,但它实际上与大选无关,因此我没有将其包含在大选网站的博客系列中。

回顾:非破坏性变更

当记录类型被引入C#时,同时引入了使用with操作符的"非破坏性变更"概念。其思想是记录类型可以是不可变的,但你可以轻松高效地创建一个新实例,该实例具有与现有实例相同的数据,但具有一些不同的属性值。

例如,假设你有这样一个记录:

public sealed record HighScoreEntry(string PlayerName, int Score, int Level);

然后你可以编写以下代码:

HighScoreEntry entry = new("Jon", 5000, 50);
var updatedEntry = entry with { Score = 6000, Level = 55 };

这不会改变第一个实例中的数据(因此entry.Score仍为5000)。

回顾派生数据

记录类型不允许为主要构造函数指定构造函数体(这是我在早期关于记录和集合的文章中打算写的内容),但你可以基于主要构造函数中的参数值初始化字段(以及自动实现的属性)。

作为一个非常简单(且高度人为设计)的示例,你可以创建一个记录,在初始化时确定值是奇数还是偶数:

public sealed record Number(int Value)
{public bool Even { get; } = (Value & 1) == 0;
}

乍一看,这看起来不错:

var n2 = new Number(2);
var n3 = new Number(3);
Console.WriteLine(n2); // 输出: Number { Value = 2, Even = True }
Console.WriteLine(n3); // 输出: Number { Value = 3, Even = False }

到目前为止一切顺利。直到本周,我一直认为这一切都很好。

问题:混合使用with和派生数据

问题出现在混合使用这两个功能时。如果我们更改上面的代码(同时保持记录本身不变),使用with操作符而不是调用构造函数来创建第二个Number,输出就会变得不正确:

var n2 = new Number(2);
var n3 = n2 with { Value = 3 };
Console.WriteLine(n2); // 输出: Number { Value = 2, Even = True }
Console.WriteLine(n3); // 输出: Number { Value = 3, Even = True }

"Value = 3, Even = True"真的不好。

这是怎么发生的?出于某种原因,我一直假设with操作符使用新值调用构造函数。但实际上并非如此。上面的with操作符大致转换为如下代码:

// 这不会编译,但大致是生成的内容。
var n3 = n2.<Clone>$();
n3.Value = 3;

<Clone>$方法(至少在这种情况下)调用生成的复制构造函数(Number(Number)),该构造函数复制ValueEven的支持字段。

这一切都有文档记录——但目前没有任何关于可能引入的不一致性的警告。(我将给微软的同事发邮件,看看能否在其中添加一些内容。)

请注意,由于Value是在克隆操作之后设置的,我们无论如何都无法编写一个复制构造函数来正确处理这个问题。(至少,没有直接的方法——我稍后会提到一个复杂的方法。)

如果有人想"为什么不直接使用计算属性?",显然这是可行的:

public sealed record Number(int Value)
{public bool Even => (Value & 1) == 0;
}

任何可以像这样轻松按需计算的属性都很棒——不仅不会出现本文中的问题,而且在内存方面也更高效。但这对于我在大选网站中使用的大多数记录中的属性来说真的不起作用,这些记录通常使用按ID索引的集合构建,或者执行其他相对昂贵的计算。

我们能做什么?

到目前为止,我想到了四种前进的方法,但没有一种是令人愉快的。我很想听听其他人的建议。

选项1:耸耸肩继续生活

现在我知道了这个问题,我可以避免在除了"简单"记录之外的任何东西上使用with操作符。如果没有计算属性或字段,with操作符仍然非常有用。

当然,存在一种风险,我可能在一个最初"简单"的记录类型上使用with操作符,然后后来引入一个计算成员。嗯。

选项2:编写Roslyn分析器来检测问题

至少在理论上,对于任何在声明它们的同一解决方案中使用的记录(这对我的大选网站来说是一切),编写一个Roslyn分析器应该是可行的,该分析器:

  • 分析每个声明记录中的每个成员初始化器,以查看使用了哪些参数
  • 分析每个with操作符的使用,以查看正在设置哪些参数
  • 如果两者之间存在任何交集,则记录错误

这很有吸引力,可能对其他人也有用。它的缺点是需要实现Roslyn分析器。我很久没有编写分析器了,但我猜这仍然是一个相当复杂的过程。如果我真正找到时间,这很可能是我会做的事情——但我希望有人评论说分析器已经存在,或者解释为什么不需要它。

更新,2025-07-29:我写了一对分析器!请参阅我的后续文章了解更多细节。

选项3:找出安全使用with的方法

我一直在尝试如何使用Lazy<T>来延迟计算任何属性,直到它们第一次被使用,这将在with操作符为属性设置新值之后。我想出了下面的模式——我认为这是有效的,但非常混乱。采用这种模式不需要将父记录中的每个新参数都反映在嵌套类型中——仅用于计算属性中使用的参数。

public sealed record Number(int Value)
{private readonly Lazy<ComputedMembers> computed =new(() => new(Value), LazyThreadSafetyMode.ExecutionAndPublication);public bool Even => computed.Value.Even;private Number(Number other){Value = other.Value;// 延迟创建ComputedMembers实例,直到computed = new(() => new(this), LazyThreadSafetyMode.ExecutionAndPublication);}// 这是一个结构体(或者可以是一个类),而不是记录,// 以避免为Value创建字段。我们只需要计算属性。// (我们甚至不需要使用主要// 构造函数,在某些情况下最好不要使用。)private struct ComputedMembers(int Value){internal ComputedMembers(Number parent) : this(parent.Value){}public bool Even { get; } = (Value & 1) == 0;}
}

这是:

  • 痛苦地记住要做
  • 开始时需要大量额外的代码(尽管设置完成后,添加新的计算成员并不太糟糕)
  • 由于添加了Lazy<T>实例,在内存方面效率低下

在"大型"记录中,这种低效可能无关紧要,但它使得在只有几个参数的"小型"记录中使用计算属性变得痛苦,特别是如果这些只是数字等。

选项4:请求更改语言

我提出这一点只是为了完整性。我非常信任C#设计团队:他们是聪明的人,会非常仔细地思考问题。如果我是第一个提出这个"问题"的人,我会感到震惊。我认为更有可能的是,在确定当前行为是最不坏的选择之前,已经详细讨论了这种行为的优缺点,并讨论和原型化了替代方案。

现在也许Roslyn编译器可以开始发出警告(选项2),这样我就不必编写分析器——也许可以为以后的C#版本添加替代方案(理想情况下为记录中的初始化提供更多灵活性,例如一个特殊命名的成员,在实例"准备就绪"时调用,并且仍然可以写入只读属性)……但如果没有明确的鼓励,我可能不会开始为此创建提案。

结论

我很少在C#中发现陷阱,但这对我来说确实像一个陷阱。也许只是因为我在大选网站中如此广泛地使用了计算属性——也许记录真的不是设计用来这样使用的,我的一半记录类型真的应该是类。

我不想停止使用记录,我也绝对不鼓励其他人这样做。我不想停止使用with操作符,再次强调,我也不鼓励其他人这样做。我希望这篇文章能对那些以不安全的方式使用with的人起到一点警示作用。

哦,当然,如果我确实编写了一个能够检测到这一点的Roslyn分析器,我会编辑这篇文章以链接到它。(如前所述,这就是那篇文章。)
更多精彩内容 请关注我的个人公众号 公众号(办公AI智能小助手)
对网络安全、黑客技术感兴趣的朋友可以关注我的安全公众号(网络安全技术点滴分享)

公众号二维码

公众号二维码

http://www.jsqmd.com/news/36072/

相关文章:

  • Java 中 double 的精度问题,以及为什么 BigDecimal 没有这个问题
  • AI元人文:构建有界可信的人机文明新范式
  • synchronized` 的“锁升级/路径解析
  • synchronized` 的“锁升级/路径
  • HEAD^n和HEAD~n的区别
  • CountDownLatch 与 CyclicBarrier 区别深度解析
  • 【比赛游记】2025 ICPC 南京站游记
  • 变量和简单的数据类型
  • Not physics
  • 为啥ls -d */列出所有目录
  • 我的旮旯回忆录
  • 2025年11月AI搜索营销推荐全览:五强格局趋势与实操
  • 为啥ls -d */能列出所有目录
  • 2025年11月AI搜索营销推荐指南:五强全景对比助决策
  • 2025年11月AI搜索优化推荐榜:从诊断到落地的完整路径
  • 2025年11月AI搜索优化推荐榜:五强数据表现与落地案例对照
  • 2025年11月deepseek关键词排名优化推荐:五家优选机构对比助您高效落地
  • 2025年11月deepseek关键词排名优化推荐:五强榜单一文看懂选型
  • 2025年11月GEO品牌推荐:技术引擎驱动跨平台协同增长
  • 2025年11月geo优选推荐:五强对比与场景决策指南
  • 2025年11月geo服务商年度推荐榜:五强方案深度拆解
  • 2025年11月deepseek排名优化推荐榜:五强数据表现与选型指南
  • 2025年11月deepseek排名优化推荐:五强实测数据公开供理性参考
  • 2025年11月geo优化服务榜推荐:五强方案全景拆解
  • 2025年11月geo优化品牌榜推荐:五强格局与选型指南
  • 深入解析:从零打造2D射击游戏:我的Godot开发之旅
  • 第20天(简单题 二分查找递归)
  • 20251110周日日记
  • 当前操作系统的应用主题工具类 - C#小函数类推荐
  • 11.6总结