C#记录类型与with操作符的陷阱与解决方案

本文深入探讨C#记录类型中with操作符的意外行为,分析参数设置与属性初始化的差异,并介绍如何使用自定义Roslyn分析器检测潜在问题,分享在Election2029项目中的实际应用经验。

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

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

这是否是一个问题?

上一篇博客文章登上了Hacker News,在那里的评论、Bluesky上的评论以及文章本身的评论意见不一。一些人认为这是按预期工作的,一些人认为这是C#语言团队的一个糟糕决定。

也许不出所料,最有见地的评论来自Eric Lippert,他引用了Programming Language Design and Implementation 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]注解

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

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

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

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;
}

该属性不必是内部的,实际上在我的选举代码库中也不是。但它可以是内部的,即使您从不同的程序集使用记录。分析器不关心它在哪个命名空间或任何其他细节。

在这一点上:

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

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

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

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

1
2
3
4
5
// No warning here, but the expression 'n2 with { Value = 3 }' still warns.
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项目中启用分析器时发生了什么?

可以预见的是,一堆记录因未指定[DangerousWithTarget]而被标记……当我应用该属性后,只有一两个地方我以不安全的方式使用with操作符。

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

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

 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; }

    // Bunch of properties proxying access
    public Instant Timestamp => SourceData.Timestamp;
    // ...

    public ElectionContext(ElectionContextSourceData sourceData)
    {
        // Initialization and validation
    }

    public sealed record ElectionContextSourceData(Instance Timestamp, ...)
    {
        // Equals and GetHashCode, but nothing else
    }
}

在这一点上:

  • 我能够向构造函数添加验证。我无法在记录的主构造函数中这样做。
  • 非常清楚什么是规范信息与派生数据——我甚至可能重构存储层,仅构造和使用ElectionContextSourceData。
  • 当我需要时,我仍然可以对记录使用with操作符
  • 由于顺序非常明确,派生数据与规范数据不同步的风险不存在

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

结论

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

我并不真的期望其他开发人员使用我的分析器——但它们是可行的这一事实表明Roslyn有点像一个奇迹。我还不推荐我的“在预期用途之外谨慎使用记录”或“类包装记录”的方法。如果它们对Election2029项目不起作用,我还有很多时间可以重构。但我仍然有兴趣获得反馈,了解我的决定在他人看来是否至少有些合理。

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