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

本文深入探讨C#记录类型中使用with操作符进行非破坏性突变时可能引发的数据不一致问题。通过具体代码示例展示派生属性在克隆过程中的异常行为,并提出四种解决方案包括使用Roslyn分析器检测问题、Lazy<T>延迟计算等。文章还分析了记录类型的底层实现机制,为开发者避免此类陷阱提供实用指导。

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

最近我在调试代码时发现了一个bug,结果是我对C#记录类型工作方式的误解。可能只有我一个人期望它们以这种方式工作,但我觉得值得写出来分享。

回顾:非破坏性突变

当记录类型引入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的支持字段。

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

我们可以做什么?

到目前为止,我想到了四种前进的方法,都不令人愉快。

选项1:耸肩继续生活

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

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

理论上,至少对于在声明它们的同一解决方案中使用的任何记录,编写Roslyn分析器应该是可行的,该分析器可以:

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

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

我一直在尝试如何使用Lazy<T>来延迟计算任何属性,直到它们第一次被使用,这将在with操作符为属性设置新值之后发生。

选项4:请求更改语言

我提出这一点只是为了完整性。我对C#设计团队非常信任:他们是聪明的人,会仔细思考问题。

结论

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

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

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