C# 9 Record:编译器生成的 ToString() 代码可能导致堆栈溢出甚至更糟






4.97/5 (15投票s)
如果记录声明创建了循环引用,则编译器生成的 ToString() 会导致堆栈溢出。
引言
C# 中的新记录功能旨在让开发者的生活更轻松。只需定义记录的属性,编译器就会自动生成一个包含有用方法(如 ToString()
)的类。但是,如果开发人员不小心,他们的设计可能会导致编译器编写出有问题的代码。本文将简要介绍 C# 9 记录,解释问题,说明如何解决该问题,并提出一些关于如何改进 C# 编译器的建议。
C# 9 记录
C# 9 中的新 record
关键字允许定义一个 class
类型,如下所示:
record Person(string Name);
这是一种名为 Person
的 class
声明,带有一个 string
属性:Name
。基于这一行简单的代码,编译器会生成 IL 代码,在 C# 中可能看起来像这样:
class Person: IEquatable<Person> {
public string Name { get; init; }
protected virtual Type EqualityContract => typeof(Person);
public Person(string name) {Name = name;}
public override bool Equals(object? obj) => Equals(obj as Person);
public virtual bool Equals(Person? other) {
return !(other is null) &&
EqualityContract == other.EqualityContract &&
EqualityComparer<string>.Default.Equals(Name, other.Name);
}
public static bool operator ==(Person? left, Person? right)
=> (object)left! == right || (left?.Equals(right) ?? false);
public static bool operator !=(Person? left, Person? right)
=> !(left == right);
public override int GetHashCode() {
return HashCode.Combine(EqualityComparer<Type>.Default.GetHashCode(EqualityContract),
EqualityComparer<string>.Default.GetHashCode(Name));
}
protected virtual bool PrintMembers(StringBuilder builder) {
builder.Append(nameof(Name));
builder.Append(" = ");
builder.Append(this.Name);
return true;
}
public override string ToString() {
var builder = new StringBuilder();
builder.Append(nameof(Person));
builder.Append(" { ");
if (PrintMembers(builder)) builder.Append(' ');
builder.Append('}');
return builder.ToString();
}
}
这是开发人员不再需要编写的大量样板代码。
record
是一个 class
,它使用属性的值来决定两个记录是否相等。
var smith1 = new Person("Smith");
var smith2 = new Person("Smith");
Console.WriteLine($"smith1==smith2: {smith1==smith2}");
输出
smith1==smith2: True
如果 person
是一个没有重写 Equals()
的类,则比较将返回 false
。
循环引用
当一个类型(class
、record
、struct
)引用自身时,就会创建循环引用。
class Twin {
string Name;
Twin? OtherTwin;
}
这个 Twin
类没什么特别的,它只是引用了它自己。
使用 record
而不是 class
也可以定义同样的内容:
record Twin(string Name, Twin OtherTwin);
看起来足够无害,并且可以轻松编译。
然后我写了一个单元测试:
#nullable enable
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace CSharpRecordTest {
record Twin(string Name) {
public Twin? OtherTwin { get; set; }
}
[TestClass]
public class UnitTest1 {
[TestMethod]
public void TestMethod1() {
var twinA = new Twin("A");
var twinB = new Twin("B");
twinA.OtherTwin = twinB;
twinB.OtherTwin = twinA;
Assert.AreEqual(twinA, twinA.OtherTwin.OtherTwin);
}
}
}
请注意,我不得不将 OtherTwin
写成普通的 property
,record
允许这样做,并将其视为与在 record Twin(...)
中定义的相同。括号内的属性是 readonly
的。如果 OtherTwin
是 readonly
的,则无法首先创建 TwinA
,因为构造函数将要求 TwinB
已经存在。因此,需要先创建 twinA
,然后才能将 twinB
作为 OtherTwin
分配。
测试运行得很好。但是,如果您尝试调试它,在 Assert.AreEqual()
上设置断点,并将鼠标悬停在 twinA
上,您将收到一个奇怪的错误消息,并且调试会话会突然中断。
这是我第一次使用 C# 记录时的实际体验。然后我花了一天多的时间来弄清楚发生了什么。错误消息没有给出丝毫线索,也无法检查任何内容,因为调试会话被迫结束。因此,我决定向微软报告这个问题。为了方便重现,我编写了一个使用 ToString()
的控制台应用程序,我推测这是导致问题的原因。在那里我得到了问题的线索:
static void Main(string[] args) {
var twinA = new Twin("A");
var twinB = new Twin("B");
twinA.OtherTwin = twinB;
twinB.OtherTwin = twinA;
Console.WriteLine(twinA);
}
屏幕输出
Stack overflow.
Repeat 4585 times:
--------------------------------
at RecordToStringProblem.Program+Twin.PrintMembers(System.Text.StringBuilder)
at RecordToStringProblem.Program+Twin.ToString()
at System.Text.StringBuilder.Append(System.Object)
--------------------------------
at RecordToStringProblem.Program+Twin.PrintMembers(System.Text.StringBuilder)
at RecordToStringProblem.Program+Twin.ToString()
at System.IO.TextWriter.WriteLine(System.Object)
at System.IO.TextWriter+SyncTextWriter.WriteLine(System.Object)
at System.Console.WriteLine(System.Object)
at RecordToStringProblem.Program.Main(System.String[])
发生了什么?
TwinA.ToString()
调用 TwinA.OtherTwin.ToString()
,后者调用 TwinA.OtherTwin.OtherTwin.ToString()
,依此类推,直到堆栈溢出。
注意:当一个记录引用另一个记录,而第二个记录又引用第一个记录时,也会创建循环引用。循环引用可能实际上涉及许多记录,并且可能很难被开发人员发现。
C# 语言设计者的回应
我认为我发现了一个重大错误,并在 Github 上进行了报告:
令我惊讶的是,回应是:
感谢报告 @PeterHuberSg。是的,这是正确的存储库,但是,此行为是“按设计”的。请参阅 #49396。
令人难以置信,但属实。对他们来说,这不是错误,而是功能。我一直认为将错误称为功能是一种玩笑,但事实摆在眼前。
更糟糕的是,C# 记录规范 中没有任何提示表明,识别循环引用是开发人员的职责,在这种情况下,开发人员需要重写其记录语句中的 Equals()
方法。
当然,这种设计对编译器来说工作量最小,但我认为这种情况需要改进。我认为有三种可能性:
- 编译器检测到属性导致循环引用,并且不调用该属性的
ToString()
。 - 编译器检测到属性导致循环引用,并在源代码中生成警告,并向开发人员提供提示,告知他们需要重写
Equals()
。 - 编译器检测到属性导致循环引用,并在源代码中生成警告,并向开发人员提供提示,告知他们需要重写
Equals()
。开发人员可以将该属性标记为不包含在ToSring()
中。
他们从一开始就应该做好 1)。2)是最低要求。然而,3)的想法是,可以标记属性不包含在 ToString()
(以及 Equals()
和 GetHashCode()
)中。这可能很有用,因为相等性应该只取决于 readonly
属性,否则 Dictionary
等类将无法正常工作。
如果我们(=所有开发者)多年来学到了一件事,那就是:编译器应该尽可能多地捕获错误。然而,我已放弃希望,认为我可以让 C# 语言设计者相信任何事情。
如果您读到这里,您似乎对编码非常感兴趣。在这种情况下,我推荐我的文章 实时调试多线程代码。最难找到的错误之一是由多线程中的竞争条件引起的。我设法编写了一些代码,实现了不可能的事情:在不明显减慢运行代码的情况下提供调试信息。这篇文章获得了 5 星中的 4.96 星。
更新
从下面的讨论(请参阅 wkempf 等人)中,我现在更能理解为什么编译器可能难以检测到运行时是否确实会发生循环引用。如果看我上面的代码,创建 TwinA
和 TwinB
,然后将 TwinA
链接到 TwinB
,此时并未创建循环引用。如果我的程序到此为止,ToString()
会运行得很好。
在单向链表中,正是这样发生的,一个成员链接到下一个成员。当然,当链表有数千个链接时,这仍然会导致堆栈溢出。即使没有堆栈溢出,即使只有 10 多个成员,ToString()
也会看起来很糟糕,因为每个成员都会显示出来,这可能会占用大量空间。
实际上,许多链表是双向链表,这意味着每个成员链接到前一个和下一个成员。这允许从列表中快速删除任何成员。在长单向链表中,这非常慢。当然,双向链表总是会创建循环引用,只是编译器很难在不进行复杂代码分析的情况下检测到它。
因此,讨论中提出的观点是,编译器无法在编译时轻松检测到运行时是否真的会生成循环引用,因此编译器根本不应该发出警告。
我的看法不同。就像可空性功能一样,警告仅仅是警告,即使它可能不正确,这种情况经常发生在可空警告中。但是可空性功能仍然会发出警告,而开发人员决定这是否确实是一个真正的问题。对于记录和可能的循环引用,应该采用相同的方法。即使运行时从不出现循环引用,ToString()
仍然可能无法使用。
历史
- 2021 年 4 月 26 日:初始版本
- 2021 年 4 月 28 日:更新