C# 记录类型中 `with` 运算符的意外不一致性解析

本文深入探讨了C#记录类型在使用`with`运算符进行非破坏性变时,与构造函数中初始化的计算属性之间存在的意外不一致性,分析了问题根源并提出了几种解决方案,包括编写自定义Roslyn分析器来检测此类问题。

Unexpected inconsistency in records

一天,我试图找出代码中的一个错误,结果发现是我自己对 C# 记录(records)的工作方式存在误解。很可能只有我一个人曾期望它们以我设想的方式工作,但我觉得还是值得写下来,以防万一。

实际上,这是我在修改我的 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)),它同时复制了 ValueEven 的支持字段。

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

请注意,因为 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
26
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# 中发现 footgun(易误用特性),但这对我来说确实像一个。也许这只是因为我在大选网站中如此广泛地使用了计算属性——也许记录的设计初衷就不是这样使用的,我一半的记录类型实际上应该是类。

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

哦,当然,如果我编写了一个能够检测到这个问题的 Roslyn 分析器,我会编辑这篇文章以链接到它。(如前所述,这就是那篇文章。)

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