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

本文深入探讨C#记录类型在使用with运算符进行非破坏性变更时可能引发的数据一致性问题,分析问题根源并提供四种解决方案,包括编写Roslyn分析器进行静态检测等实用技术方案。

意外的记录不一致性

前几天我在调试代码时发现了一个bug,结果是我对C#记录类型工作机制的理解存在偏差。虽然可能只有我一个人期望它们以我设想的方式工作,但我觉得还是值得写出来分享。

这个问题是我在修改2029年英国大选网站时发现的,但由于与选举无关,所以没有包含在选举网站博客系列中。

回顾:非破坏性变更

当记录类型引入C#时,同时引入了带有with运算符的"非破坏性变更"概念。记录类型可以是不可变的,但你可以轻松高效地创建新实例,这些实例具有现有实例的数据,但某些属性值不同。

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

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

你可以编写如下代码:

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

这不会改变第一个实例中的数据(所以entry.Score仍然是5000)。

回顾派生数据

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

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

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

乍一看这没问题:

1
2
3
4
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,输出就变得不正确了:

1
2
3
4
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运算符大致翻译成这样的代码:

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

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

这一切都有文档记录——但目前没有任何关于可能引入不一致性的警告。

请注意,由于Value是在克隆操作之后设置的,我们无论如何都无法编写一个复制构造函数来做正确的事情。

如果有人在想"为什么不直接使用计算属性?“显然这样可以正常工作:

1
2
3
4
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运算符为属性设置新值之后发生。我想出了下面的模式——我认为是有效的,但非常混乱。采用这种模式不需要父记录中的每个新参数都反映在嵌套类型中——只用于计算属性中使用的参数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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#中发现陷阱,但这对我来说确实像一个。也许只是因为我

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计