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

本文深入探讨C#记录类型中with操作符的潜在陷阱,分析开发者对记录可变性的不同理解,并介绍如何使用自定义Roslyn分析器检测危险用法,最后提出在复杂数据场景下结合类与记录的混合模式。

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

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

这到底是不是问题?

上一篇博客文章登上了Hacker News,那里的评论、Bluesky上的评论以及文章本身的评论褒贬不一。 一些人认为这是按设计工作的,一些人则认为这是C#语言团队的一个糟糕决定。 也许不足为奇,最有见地的评论来自Eric Lippert,他引用了编程语言设计与实现Stack Exchange网站上的一个帖子。Eric一如既往地全面回答了这个问题。

我相信意见分歧源于对“with”请求含义的解读。Eric写道:

“with”创建记录的副本,只更改您标识为希望更改的属性值,因此不应该对其它内容未更改感到惊讶。

这并不是我一直以来对“with”的思考方式——我并没有期望它表达“这个对象,但具有这些不同的属性”,而是期望“一个新记录,具有与原始记录相同的参数,但使用这些不同的参数”。这是一个微妙的区别——微妙到我直到遇到这个问题才去思考它——但我怀疑这解释了不同的人如何以不同的方式思考同一功能。我原本不会想到“设置属性”,因为我认为记录从一开始就是不可变的:你只能通过提供不同的值作为构造的一部分来获得属性返回不同值的记录。(再次明确:我认为我没有发现任何不正确的文档。是我的心智模型错了。)

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

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

那么,在这段开场白之后,这篇文章有两个方面:

  1. 我如何防止自己再次陷入同样的陷阱?
  2. 我在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"/>

显然,它仍处于测试阶段——而且目前可能有很多角落情况找不到。(欢迎提交Pull Request。)但它暂时满足了我的特定需求。(如果有人想借鉴这个想法,并以更专业、受支持的方式推进,最好是在一个包含几十个其他有用分析器的包中,那就太好了。)

正如我提到的,有两个分析器,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)。

在这一点上:

  • 源代码向人类清楚地表明,我们知道在Numberwith运算符中设置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运算符,我会收到警告。

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

 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 设计