C#记录类型与with操作符的深入解析

本文深入探讨C#记录类型中with操作符的意外行为,介绍如何通过自定义Roslyn分析器检测潜在问题,并分享在Election2029项目中的实际应用经验与代码重构模式。

记录类型与“with”操作符,再探讨

在我之前的博客文章中,我描述了C#记录类型的一些行为,这些行为对我来说是意外的,但根据文档是完全正确的。这是对该文章的后续,所以如果您还没有阅读过那篇文章,请先阅读——我不会重复相同的内容。

这是否是一个问题?

之前的博客文章登上了Hacker News,在那里的评论、Bluesky上的评论以及文章本身的评论都褒贬不一。

一些人认为这是按预期工作的,一些人认为这是C#语言团队的一个糟糕决定。

也许不足为奇的是,最有见地的评论来自Eric Lippert,他引用了编程语言设计与实现Stack Exchange站点上的一篇文章。Eric一如既往地彻底回答了这个问题。

我认为意见分歧来自于对“with”请求内容的解释。Eric写道:

with会复制记录,仅更改您标识为希望更改的属性值,因此其他内容未更改不应令人惊讶。

这不是我对with的思考方式——我从未期望它表示“这个对象但具有这些不同的属性”,而是期望“一个新记录,具有与原始记录相同的参数,但具有这些不同的参数”。这是一个微妙的区别——足够微妙,以至于我在遇到这个问题之前甚至没有费心去思考它——但我怀疑这解释了不同的人如何以不同的方式思考同一功能。我不会想到“设置属性”,因为我认为记录从一开始就是不可变的:只有在构造时提供不同的值,才能得到属性返回不同值的记录。(再次明确说明:我认为我没有发现任何不正确的文档。是我的心智模型错了。)

我没有回头查看之前描述该功能的YouTube视频——无论是来自C#团队本身还是其他开发人员——以查看a)它是用设置属性而不是参数来描述的;b)视频是否描述了区别以明确哪个是“正确的”。

为我辩护的是,即使你对记录的工作方式有了更好的心智模型,这也是一个相当容易犯的错误,你需要在代码审查中保持警惕才能发现它。该语言绝对允许你编写不仅仅是“轻量级数据记录”的记录,就像你为类所做的那样——所以我认为人们会这样做并不奇怪。

那么,在这段介绍性言论之后,本文有两个方面:

  • 我将如何避免再次陷入同样的陷阱?
  • 我在Election 2029代码库中做了哪些更改?

陷阱避免:Roslyn分析器

在之前的文章中,我提到了编写Roslyn分析器作为一种可能的前进方式。我最初的希望是有一个单一的分析器,它可以发现针对在初始化期间使用的任何参数的with操作符的使用。

那次初步尝试在一定程度上是有效的——它会发现原始博客文章中的危险代码——但仅当记录和使用with操作符的源代码在同一个项目中时才有效。我现在有一个稍好的解决方案,带有两个分析器,它甚至可以在包引用的情况下工作,你可能根本无法访问记录的源代码……只要包作者使用相同的分析器!(当你看到分析器时,这会更有意义。)

分析器的源代码在GitHub上,分析器本身在JonSkeet.RoslynAnalyzers NuGet包中。要在项目中安装它们,只需在项目文件的项组中添加以下内容:

1
2
3
<PackageReference Include="JonSkeet.RoslynAnalyzers" Version="1.0.0-beta.6"
        PrivateAssets="all"
        IncludeAssets="runtime; build; native; contentfiles; analyzers"/>

显然,这还非常测试版——并且目前可能有很多边缘情况它找不到。(欢迎拉取请求。)但它暂时满足了我的特定需求。(如果有人想接受这个想法并以更专业、受支持的方式运行它,理想情况下是在一个包含数十个其他有用分析器的包中,那就太好了。)

正如我提到的,有两个分析器,ID分别为JS0001和JS0002。让我们通过回到之前文章中的原始演示代码来看看它们是如何工作的。这是完整的错误代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Record
public sealed record Number(int Value)
{
    public bool Even { get; } = (Value & 1) == 0;
}

// Use of record
var n2 = new Number(2);
var n3 = n2 with { Value = 3 };
Console.WriteLine(n2); // Output: Number { Value = 2, Even = True }
Console.WriteLine(n3); // Output: Number { Value = 3, Even = True }

添加分析器包会高亮显示Number中的int Value参数声明,并带有此警告:

JS0001 记录参数‘Value’在初始化期间使用;应使用[DangerousWithTarget]注释

目前,没有代码修复,但我们需要做两件事:

  1. 声明一个名为DangerousWithTargetAttribute的属性
  2. 将该属性应用于参数

以下是应用修复后的完整属性和记录代码:

1
2
3
4
5
6
7
[AttributeUsage(AttributeTargets.Parameter)]
internal sealed class DangerousWithTargetAttribute : Attribute;

public sealed record Number([DangerousWithTarget] int Value)
{
    public bool Even { get; } = (Value & 1) == 0;
}

该属性不必是内部的,实际上在我的选举代码库中也不是。但它可以是内部的,即使你从不同的程序集使用记录。分析器不关心它在哪个命名空间或任何其他细节(尽管它目前必须被称为DangerousWithTargetAttribute,而不仅仅是DangerousWithTarget)。

此时:

  • 源代码向人类清楚地表明,我们知道在Number的with操作符中设置Value属性是危险的
  • 编译后的代码也(向另一个分析器)清楚地表明了这一点

应用上述更改后,我们得到一个不同的警告——这次是在n2 with { Value = 3 }上:

JS0002: 记录参数‘Value’已使用[DangerousWithTarget]注释

(这两个警告都有更详细的描述以及摘要。)

现在你知道了问题的存在,由你来修复它……并且有多种不同的方法可以做到这一点。让我们尝试通过将预计算属性替换为按需计算的属性来消除警告。分析器不会尝试告诉你是否在不必要的地方应用了[DangerousWithTarget],所以这段代码编译时没有任何警告,但它没有移除我们的JS0002警告:

1
2
3
4
5
// 这里没有警告,但表达式 'n2 with { Value = 3 }' 仍然警告。
public sealed record Number([DangerousWithTarget] int Value)
{
    public bool Even => (Value & 1) == 0;
}

事实证明,这在Election2029代码中出乎意料地有用,即使参数未在初始化中使用,参数之间也存在预期的一致性,这应阻止使用with操作符来设置其中之一。

然而,一旦我们从参数中移除[DangerousWithTarget]属性,所有警告都消失了:

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

分析器忽略了Even属性,因为它没有初始化器——在初始化后使用Value进行计算属性是没问题的。

Election2029的新模式

那么,当我在我的Election2029项目中启用分析器时发生了什么?(暂且不提它第一次没有工作的部分……版本号已经是1.0.0-beta.6是有原因的。)

可以预见的是,一堆记录因未指定[DangerousWithTarget]而被标记……当我应用了该属性后,只有一两个地方我以不安全的方式使用with操作符。(当然,我检查了最初为我突出显示问题的原始错误是否被分析器捕获——它确实被捕获了。)

对于大多数记录,预计算对我来说感觉还可以。它们基本上仍然是相当轻量级的记录,带有少量预计算,如果我使其按需计算,会感觉毫无效率。我喜欢通过它们是记录而自动获得的功能。我选择保留那些作为记录,知道至少如果我尝试在危险操作中使用with操作符,我会收到警告。

然而,有两种类型——ElectionCoreContext和ElectionContext,我[之前写过]——它们有很多预计算。它们感觉更适合作为类。最初,我将它们转换为只是“普通”类,带有主构造函数和属性。感觉还可以,但不知何故不太对劲。我喜欢记录类型仅用于上下文的规范信息的想法……所以我像这样转换了ElectionContext(ElectionCoreContext也有类似的东西):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public sealed class ElectionContext : IEquatable<ElectionContext>
{
    public ElectionContextSourceData SourceData { get; }

    // 代理访问的一堆属性
    public Instant Timestamp => SourceData.Timestamp;
    // ...

    public ElectionContext(ElectionContextSourceData sourceData)
    {
        // 初始化和验证
    }

    public sealed record ElectionContextSourceData(Instance Timestamp, ...)
    {
        // Equals 和 GetHashCode,但没有其他内容
    }
}

此时:

  • 我能够向构造函数添加验证。我无法在记录的主构造函数中这样做。
  • 非常清楚什么是规范信息与派生数据——我甚至可能潜在地重构存储层,仅构造和使用ElectionContextSourceData,例如。(我现在想尝试这个。我怀疑这会有些低效,因为它在反序列化时使用派生数据来查找内容。)
  • 当我需要时(在几个地方很方便),我仍然可以对记录使用with操作符
  • 没有派生数据与规范数据不同步的风险,因为顺序非常明确

忽略命名(可能还有嵌套),这是一个有用的模式吗?我不想对每个记录都这样做,但对于这两个核心、复杂的类型,到目前为止感觉效果很好。不过这还是早期阶段。

结论

我很高兴我现在可以更安全地使用记录,即使我使用它们的方式其他人可能不完全赞同。我可能会改变主意,回到对所有除了最明确的情况使用常规类。但目前,我拥有的“在感觉合适的地方使用记录,即使这意味着预计算”和“在有足够行为证明合理的地方使用类包装记录”的方法效果相当好。

我并不真的期望其他开发人员使用我的分析器(当然,非常欢迎您使用)——但它们的可行性表明Roslyn有点像个奇迹。我还不推荐我的“谨慎使用记录稍微超出其预期用途”或“类包装记录”的方法。如果它们对Election2029项目不起作用,我有很多时间进行重构。但我仍然有兴趣获得反馈,了解我的决定至少对其他人来说是否有些合理。

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