深入解析C#记录类型的潜在陷阱:`with`表达式与派生数据的交互问题

本文深入探讨了C#记录类型中,使用`with`表达式进行非破坏性突变时,可能遇到的派生数据不一致性问题,并分析了其背后的编译器生成机制,同时提供了几种解决方案和思考。

Unexpected inconsistency in records

Unexpected inconsistency in records

前几天,我试图找出自己代码中的一个bug,结果发现是我对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
27
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分析器,我会编辑这篇文章并链接到它。(如前所述,这就是那篇文章。)

Share this:

Like Loading…

Related

Post navigation

Previous PostElection 2029: PostcodesNext PostRecords and the ‘with’ operator, redux

16 thoughts on “Unexpected inconsistency in records”

  • Kyralessa says:

    July 20, 2025 at 6:20 am

    我不知道,这有点像“医生,我这样做的时候会痛”那种问题。如果一个字段依赖于另一个字段的当前实际值,而不仅仅是该字段的初始化值,那么显然它应该始终是计算出来的,而不仅仅在初始化时计算。 但是,自从我发现字段级相等性不延伸到集合字段后,我自己就再也没用过记录。有了那个限制,记录对我来说就没有足够的有用性来证明它们存在的合理性。最好写一个具有完全自定义相等性的类。

    LikeLike

    Reply

  • jonskeet says:

    July 20, 2025 at 6:38 am

    考虑到记录通常被认为是不可变的(我创建的记录也是深度不可变的),我不认为“初始化值”和“当前实际值”之间会有任何区别。这就是让我惊讶的地方。 结果发现它们是“一次性可变”的——在字段初始化器运行之后,但在实例以其他方式可观察之前。对我来说,这就是令人惊讶的部分。

    LikeLike

    Reply

  • Mark Adamson says:

    July 20, 2025 at 10:28 am

    我默认使用计算属性,但正如你所说,它不适用于惰性初始化。 我认为惰性语言特性将是一个很好的解决方案,我相信它们会让它在克隆记录时行为正确。

    LikeLike

    Reply

  • ericlippert says:

    July 20, 2025 at 7:11 pm

    你不是唯一一个遇到这种误解的人;这里是另一个例子: https://langdev.stackexchange.com/questions/4372

    LikeLike

    Reply

  • macias says:

    July 21, 2025 at 8:31 am

    “记录不允许为主构造函数指定构造函数体”。我主要使用记录结构体,它们确实允许,但为了确认,我检查了类记录,结果……我没有看到错误:“` public sealed record class Number { public Number(int Value) { this.Value = Value; Even = (Value & 1) == 0; }

    public bool Even { get; }
    public int Value { get; init; }
    
    public void Deconstruct(out int Value)
    {
        Value = this.Value;
    }
    

    }“`

    LikeLike

    Reply

  • jonskeet says:

    July 21, 2025 at 8:45 am

    但那不是主构造函数。主构造函数是在类/记录声明体之前,作为类/记录声明一部分声明的构造函数,如 https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-12.0/primary-constructors 所述。

    LikeLiked by 1 person

    Reply

  • Pingback: Dew Drop – July 21, 2025 (#4463) – Morning Dew by Alvin Ashcraft

  • Nick says:

    July 21, 2025 at 9:22 pm

    我同意这很令人困惑,并且是潜在的bug来源。读了Eric在Stack Exchange上的回复后,我意识到一个人期望它如何工作可能取决于他们如何解读with: (1) “替换构造函数调用的语法糖”

    1
    2
    
    HighScoreEntry entry = new("Jon", 5000, 50);
    var updatedEntry = new(entry.PlayerName, 6000, 55);
    

    或者, (2) “复制并写入只读属性”

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

    然而,使用with和对象初始化之间存在一些一致性,因为你也可以通过以下方式遇到这个问题:

    1
    
    var n = new Number(10) { Value = 5}
    

    将显示n.Even = true。 也许这就是为什么with被设计成这样的行为?

    LikeLike

    Reply

  • Pingback: Unexpected inconsistency in records - Data Debug Spot

  • dmo says:

    July 24, 2025 at 11:07 pm

    另一种与字段复制行为一致的解决方案是传入一个计算值缓存实例,而不是在记录本身中缓存该值。这样做的好处是,如果更改的属性不影响特定计算属性的复合键,则不需要重新计算。

    LikeLike

    Reply

  • Brandon says:

    July 28, 2025 at 6:31 am

    是的,同意,第一眼看上去并不是那么直观地明显,记录的主构造函数,无论它只是一个位置记录,还是一个具有主构造函数的完整记录,都比典型的基类主构造函数更特殊一些,除非你已经阅读了文档。同样,生成的复制构造函数复制所有字段,并且实际上根本不关心属性,这也并不是立即直观明显的。 with是对复制构造函数的调用加上后面的块作为一个遵循对象初始化器正常规则的对象初始化器的简写。 如果你在with语句中改变任何东西,字段确实首先被复制,然后记录定义中的字段初始化器(如果有的话)被执行,然后with语句中的初始化器在你拿到闪亮的新修改后的记录实例之前被执行。 因此,如果你有一个具有相应主构造函数参数初始化器的显式幕后字段,然后在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
    27
    28
    29
    30
    31
    32
    33
    34
    35
    
    public record ExampleRecord(int A, int B)
    {
        public int A
        {
            get => field;
            set
            {
                Console.WriteLine($"Called setter for {nameof(A)}. Previous value was {field}.");
                field = value;
            }
        }
    }
    
    ExampleRecord thing1 = new(1,1);
    Console.WriteLine(JsonSerializer.Serialize(thing1));
    
    Console.WriteLine("Copying without mutation");
    ExampleRecord thing2 = thing1 with {};
    Console.WriteLine(JsonSerializer.Serialize(thing2));
    
    Console.WriteLine("Directly calling set accessor for A");
    thing2.A = 2;
    Console.WriteLine(JsonSerializer.Serialize(thing2));
    
    Console.WriteLine("Copying with mutation of A only");
    ExampleRecord thing3 = thing2 with { A = 3 };
    Console.WriteLine(JsonSerializer.Serialize(thing3));
    
    Console.WriteLine("Copying with mutation of A and B");
    ExampleRecord thing4 = thing3 with { A = 4, B = 4 };
    Console.WriteLine(JsonSerializer.Serialize(thing4));
    
    Console.WriteLine("Copying with mutation of B only");
    ExampleRecord thing5 = thing4 with { B = 5 };
    Console.WriteLine(JsonSerializer.Serialize(thing5));
    

    输出:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    {"B":1,"A":0}
    Copying without mutation
    {"B":1,"A":0}
    Directly calling set accessor for A
    Called setter for A. Previous value was 0.
    {"B":1,"A":2}
    Copying with mutation of A only
    Called setter for A. Previous value was 2.
    {"B":1,"A":3}
    Copying with mutation of A and B
    Called setter for A. Previous value was 3.
    {"B":4,"A":4}
    Copying with mutation of B only
    {"B":5,"A":4}
    

    如你所见,A在第一个记录中从未被设置,因为它只是主构造函数的一个参数,没有与之关联的自动生成属性或幕后字段,所以它只是丢失了。唯一一次它被设置为我们要求的值,是当我们显式设置它时,无论是直接通过set访问器还是在with语句的初始化器中。 B在所有四个中都被设置了,因为它是一个位置属性,并且有一个生成器知晓的自动生成的幕后字段。 但是A的幕后字段总是被复制的,无论如何,在自动生成的复制构造函数中,并且是直接设置的,thing3thing4的输出说明了这一点,当初始化器运行并设置属性时,导致输出显示先前的值,即被复制的值。 thing5证明A的幕后字段是直接复制的,即使它不是位置属性,因为它具有来自thing4的值,并且没有导致调用set访问器的输出。 我在这里使用了field关键字,但如果你显式声明A的幕后字段,行为是相同的。 但是,如果你声明那个幕后字段并使用初始化器,你可以修复所有问题。这使得A的行为与它是位置属性时相同,而且如果你在with语句中改变它,也会调用setter。 不过,我惊喜地注意到,Visual Studio中的智能感知知晓两个略微不同的符号AB之间的区别,分析器也因我从未使用它们而对我大喊大叫。它们都正确地称主构造函数中的A为“parameter A”,称B为“positional property B”,即使它们在最终生成的记录中都有同名的属性。

    LikeLike

    Reply

  • Brandon says:

    July 28, 2025 at 6:50 am

    所有这些都是为了说明,我并不认为这里真的存在不一致。 当你在结构体中使用with语句复制结构体(不仅仅是记录结构体)时,这些属性的行为方式相同,就像在具有主构造函数的普通类中一样,然后使用对象初始化器实例化。这只是意识到你在这些初始化器中处于静态上下文的结果,如果你引用的符号恰好出现在主构造函数中。 因此,使用静态初始化器设置的计算并存储的属性更多是正在编写的类型的设计缺陷,而不是语言缺陷,我认为。

    LikeLike

    Reply

  • Andrew Rondeau says:

    July 28, 2025 at 1:32 pm

    但这对于我在大选网站中使用的大量记录中的属性来说并不适用,这些记录通常是用集合构造的,然后按ID索引,或者执行其他相对昂贵的计算。

    我怀疑记录可能不是这里正确的选择。 在之前的工作中,在记录出现之前,我们有用“旧”方式实现的不可变类。 一位开发人员想出了一个复制模式,其中Clone方法有默认为null的可选参数。将参数保留为null告诉克隆方法保留该值。对于允许null的属性,我们有一个简单的包装类型,可以轻松类型转换。 我怀疑这种方法在你的情况下会更好,因为它允许你在复制时进行你需要的这种细微逻辑。

    LikeLike

    Reply

  • Pingback: Records and the ‘with’ operator, redux | Jon Skeet’s coding blog

  • jeremy.cook says:

    August 7, 2025 at 2:00 am

    为什么不直接添加你自己手工制作的复制构造函数呢?也许不理想,但对我来说很管用。

    LikeLike

    Reply

  • lpttlm says:

    November 26, 2025 at 9:41 pm

    C++化语言的果实。有没有任何初始化方法不是从C++复制过来的,或者,更优选地,从一种早已消亡但重要(或者让我们说,!important)的语言复制过来的?

    LikeLike

    Reply

Leave a comment

Categories async Benchmarking Book reviews Books C# C# 4 C# 5 C# 6 CSharpDev CSharpDevCenter Design Diagnostics DigiMixer Eduasync Edulinq Election 2029 Evil code General Google Java LINQ Noda Time Parallelization Performance Protocol Buffers Speaking engagements Stack Overflow Uncategorized V-Drums Wacky Ideas Archives

  • July 2025 (2)
  • April 2025 (1)
  • March 2025 (5)
  • February 2025 (2)
  • November 2024 (1)
  • October 2024 (2)
  • July 2024 (1)
  • June 2024 (1)
  • January 2024 (2)
  • November 2023 (1)
  • June 2023 (1)
  • January 2023 (1)
  • October 2022 (2)
  • April 2022 (1)
  • March 2022 (1)
  • February 2022 (4)
  • July 2021 (1)
  • June 2021 (1)
  • March 2021 (1)
  • January 2021 (1)
  • December 2020 (1)
  • October 2020 (1)
  • August 2020 (1)
  • July 2020 (2)
  • March 2020 (1)
  • February 2020 (2)
  • November 2019 (2)
  • October 2019 (4)
  • June 2019 (1)
  • May 2019 (1)
  • March 2019 (2)
  • February 2019 (1)
  • September 2018 (1)
  • April 2018 (2)
  • March 2018 (2)
  • November 2017 (1)
  • October 2017 (1)
  • August 2017 (7)
  • June 2017 (2)
  • April 2017 (2)
  • December 2016 (1)
  • June 2016 (2)
  • March 2016 (2)
  • January 2016 (1)
  • July 2015 (1)
  • June 2015 (1)
  • May 2015 (1)
  • April 2015 (1)
  • March 2015 (1)
  • January 2015 (2)
  • December 2014 (2)
  • November 2014 (2)
  • October 2014 (1)
  • September 2014 (1)
  • August 2014 (3)
  • July 2014 (2)
  • June 2014 (1)
  • May 2014 (1)
  • April 2014 (3)
  • January 2014 (3)
  • September 2013 (2)
  • June 2013 (2)
  • May 2013 (1)
  • April 2013 (1)
  • March 2013 (1)
  • February 2013 (2)
  • November 2012 (2)
  • September 2012 (1)
  • August 2012 (2)
  • May 2012 (2)
  • April 2012 (1)
  • March 2012 (2)
  • February 2012 (1)
  • January 2012 (6)
  • December 2011 (1)
  • November 2011 (3)
  • September 2011 (2)
  • August 2011 (2)
  • July 2011 (1)
  • June 2011 (6)
  • May 2011 (9)
  • April 2011 (1)
  • March 2011 (1)
  • February 2011 (4)
  • January 2011 (27)
  • December 2010 (20)
  • November 2010 (9)
  • October 2010 (7)
  • September 2010 (11)
  • August 2010 (3)
  • July 2010 (4)
  • June 2010 (2)
  • May 2010 (1)
  • April 2010 (1)
  • March 2010 (2)
  • February 2010 (1)
  • January 2010 (4)
  • December 2009 (1)
  • November 2009 (8)
  • October 2009 (6)
  • September 2009 (4)
  • August 2009 (2)
  • July 2009 (5)
  • June 2009 (2)
  • May 2009 (3)
  • April 2009 (1)
  • March 2009 (5)
  • February 2009 (4)
  • January 2009 (10)
  • December 2008 (4)
  • November 2008 (6)
  • October 2008 (9)
  • September 2008 (9)
  • August 2008 (15)
  • July 2008 (4)
  • June 2008 (7)
  • May 2008 (2)
  • April 2008 (6)
  • March 2008 (12)
  • February 2008 (15)
  • January 2008 (7)
  • December 2007 (5)
  • November 2007 (9)
  • October 2007 (4)
  • September 2007 (6)
  • August 2007 (2)
  • July 2007 (2)
  • June 2007 (4)
  • May 2007 (2)
  • April 2007 (2)
  • March 2007 (1)
  • February 2007 (6)
  • January 2007 (3)
  • December 2006 (1)
  • November 2006 (2)
  • September 2006 (2)
  • July 2006 (3)
  • June 2006 (1)
  • May 2006 (2)
  • April 2006 (3)
  • March 2006 (2)
  • February 2006 (2)
  • January 2006 (5)
  • December 2005 (8)
  • November 2005 (3)
  • October 2005 (4)
  • September 2005 (6)
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计