NetEscapades.EnumGenerators 最新更新:[EnumMember] 支持、分析器与错误修复

本文详细介绍了 NetEscapades.EnumGenerators 源生成器 NuGet 包的最新版本更新,包括对 [EnumMember] 属性的支持、元数据属性的使用方式重构、新增的 Roslyn 分析器以及针对预览语言版本和保留字处理等边缘情况的错误修复。

为什么应该使用枚举源生成器?

NetEscapades.EnumGenerators 是我在 .NET 6 中引入增量生成器支持后创建的首批源生成器之一。创建这个包是为了解决使用枚举时的一个恼人特性:某些操作出乎意料地慢。

需要注意的是,虽然这在历史上是事实,但这情况并不一定会永远持续。实际上,.NET 8+ 在运行时中对枚举处理进行了一系列改进。

例如,假设你有以下枚举:

1
2
3
4
5
public enum Colour
{
    Red = 0,
    Blue = 1,
}

在某些时候,你想打印一个 Color 变量的名称,于是你创建了这个辅助方法:

1
2
3
4
public void PrintColour(Colour colour)
{
    Console.WriteLine("You chose " + colour.ToString()); // You chose Red
}

虽然这看起来应该很快,但实际上并非如此。NetEscapades.EnumGenerators 通过自动生成一个快速的实现来工作。它会生成一个类似这样的 ToStringFast() 方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public static class ColourExtensions
{
    public static string ToStringFast(this Colour colour)
        => colour switch
        {
            Colour.Red => nameof(Colour.Red),
            Colour.Blue => nameof(Colour.Blue),
            _ => colour.ToString(),
        };
}

这个简单的 switch 语句检查 Colour 的每个已知值,并使用 nameof 返回枚举的文本表示。如果是未知值,则回退到内置的 ToString() 实现,以确保正确处.理未知值(例如,PrintColour((Colour)123) 是有效的 C# 代码)。如果我们使用 BenchmarkDotNet 比较这两种实现对于一个已知颜色的性能,你可以看到 ToStringFast() 实现快了多少:

方法 框架 平均时间 误差 标准差 比率 Gen 0 分配内存
ToString net48 578.276 ns 3.3109 ns 3.0970 ns 1.00 0.0458 96 B
ToStringFast net48 3.091 ns 0.0567 ns 0.0443 ns 0.01 - -
ToString net6.0 17.985 ns 0.1230 ns 0.1151 ns 1.00 0.0115 24 B
ToStringFast net6.0 0.121 ns 0.0225 ns 0.0199 ns 0.01 - -

这些数字现在显然已经相当旧了,但整体模式没有改变:.NET 比 .NET Framework 快得多,而 ToStringFast() 实现比内置的 ToString() 快得多。显然,你的情况可能有所不同,结果将取决于你使用的具体枚举,但通常来说,使用源生成器应该能免费带来性能提升。

基础部分已经介绍完毕,现在让我们看看有什么新内容。

1.0.0-beta.16 版本中的更新

NetEscapades.EnumGenerators 的 1.0.0-beta16 版本于 11 月 4 日发布到 nuget.org,包含了许多生活质量改进和错误修复。我将在下面更详细地描述每个更新,但它们属于以下三类之一:

  1. 对诸如 [Display][Description] 等“附加元数据属性”工作方式的重构。
  2. 额外的分析器,以确保 [EnumExtensions] 被正确使用。
  3. 针对边缘情况的错误修复。

让我们先来看看更新的元数据属性支持。

更新的元数据属性和 [EnumMember] 支持

很长时间以来,你一直能够使用应用于枚举成员的 [Display][Description] 属性来自定义 ToStringFastParse 与库的交互方式。例如,如果你有以下枚举:

1
2
3
4
5
6
7
8
[EnumExtensions]
public enum MyEnum
{
    First,

    [Display(Name = "2nd")]
    Second,
}

那么会生成三种不同的 ToString 方法:ToStringFast() 的两个重载和 ToStringFastWithMetadata()

 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
public static partial class MyEnumExtensions
{
    // 使用布尔值决定是否使用“元数据”属性
    public static string ToStringFast(this MyEnum value, bool useMetadataAttributes)
        => useMetadataAttributes ? value.ToStringFastWithMetadata() : value.ToStringFast();

    // 使用原始的枚举成员名称
    public static string ToStringFast(this MyEnum value)
        => value switch
        {
            MyEnum.First => nameof(MyEnum.First),
            MyEnum.Second => nameof(MyEnum.Second),
            _ => value.ToString(),
        };

    // 如果提供了元数据属性则使用它们,否则回退到原始枚举成员名称
    private static string ToStringFastWithMetadata(this MyEnum value)
        => value switch
        {
            MyEnum.First => nameof(MyEnum.First),
            MyEnum.Second => "2nd", // 👈 来自元数据名称
            _ => value.ToString(),
        };
    // ... 更多生成的成员
}

使用这些额外元数据值的能力非常有用,我也经常使用它们。长期以来,我支持 [Display][Description] 属性,但有一个请求是也要支持 [EnumMember]。问题是当你在枚举成员上有多个元数据属性时——生成器应该使用哪一个?以前,生成器会任意优先选择 [Display],然后回退到 [Description]。但那种排序没有充分的理由,完全是因为一个比另一个先实现而已😬。而将 [EnumMember] 作为另一个回退选项感觉太糟糕了。😅

因此,在 #163 中,我明确添加了对 [EnumMember] 的支持,但也更新了代码,使得对于给定的枚举,只能使用单一的元数据属性源。这意味着对于给定的枚举,只考虑一种类型的元数据属性。你可以通过设置 [EnumExtensions] 属性上的 MetadataSource 属性来选择要使用的源。在下面的例子中,生成的源代码明确选择使用 [Display] 属性:

1
2
3
4
5
6
7
8
[EnumExtensions(MetadataSource = MetadataSource.DisplayAttribute)]
public enum EnumWithDisplayNameInNamespace
{
    First = 0,
    [Display(Name = "2nd")]
    Second = 1,
    Third = 2,
}

应用于上述枚举成员中的任何其他元数据属性([Description][EnumMember])都将被忽略。或者,你可以使用 MetadataSource.None 来选择不使用任何元数据属性。在这种情况下,不会生成接受 useMetadataAttributes 参数的重载。

这本身就是一个破坏性变更,但还有一个更大的变化:默认的元数据源已更改为 [EnumMember],作为这些属性在语义上更好的选择。

你可以通过在项目中设置 EnumGenerator_EnumMetadataSource 属性来更改整个项目的默认元数据源:

1
2
3
<PropertyGroup>
  <EnumGenerator_EnumMetadataSource>DisplayAttribute</EnumGenerator_EnumMetadataSource>
</PropertyGroup>

重申一下,这是一个破坏性变更,如果你当前正在使用元数据属性,将会受到影响。我可能在后续版本中添加一个分析器来尝试警告这个潜在问题,这引出了下一个类别:分析器。

警告不正确使用的新分析器

在某些情况下,NetEscapades.EnumGenerators 包生成的代码将无法编译。这些通常是生成器中难以处理的边缘情况,但如果你在应用程序中遇到它们,可能会非常令人困惑。为了解决这个问题,我添加了几个 Roslyn 分析器来解释和警告那些会导致问题的情况。

标记生成的扩展类名称冲突

目前,你可以用 [EnumExtension] 属性装饰枚举,使得两种情况下都使用相同的扩展类名,从而导致名称冲突。例如,以下代码为每个枚举生成了 SomeNamespace.MyEnumExtensions 两次:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
namespace SomeNamespace;

[EnumExtensions]
public enum MyEnum
{
    One,
    Two
}

public class Nested
{
    [EnumExtensions]
    public enum MyEnum
    {
        One,
        Two
    }
}

理想情况下,我们会通过为第二种情况生成嵌套类 SomeNamespace.Nested.MyEnumExtensions 来消除歧义,但不幸的是,扩展方法类不能是嵌套类。另一个选择是在生成的命名空间中包含类名,但这又会遇到另一个可能产生冲突的问题。最终,总是有一种方法会导致冲突,尤其是你可以显式设置要生成的类的名称!鉴于这些类型的冲突将非常罕见,#158 添加了一个分析器,诊断 ID 为 NEEG001,它直接在 [EnumExtensions] 属性上标记冲突,作为一个错误诊断。这并不是绝对必要的,因为生成重复的扩展类会导致大量编译器错误,但拥有一个分析器有望让问题更明显。😅

处理嵌套在泛型类型中的枚举

另一个我们根本无法生成有效代码的情况是,如果你有一个嵌套在泛型类型中的枚举:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
using NetEscapades.EnumGenerators;

public class Nested<T> // 类型是泛型的
{
    [EnumExtensions]
    public enum MyEnum // 枚举嵌套在其中
    {
        First,
        Second,
    }
}

不幸的是,在这种情况下没有简单的方法来生成有效的扩展类。我们不能将生成的扩展类放在 Nested<T> 内部,因为扩展方法不能放在嵌套类型中。我们可以通过使扩展类本身成为泛型类来做一些事情,但这都有点令人困惑,并为复杂性打开了闸门。相反,在 #159 中,我选择不支持这种场景。如果你编写了上述代码,则不会生成扩展方法,而是将 NEEG002 诊断应用于 [EnumExtensions] 属性,警告你这不是有效的。

枚举中的重复 case 标签

此版本中添加的最后一个分析器处理你有“重复”枚举成员的情况,即与其他成员具有相同“值”的枚举成员。例如,在下面的代码中,FailedError 具有相同的值:

1
2
3
4
5
6
7
8
[EnumExtensions]
public enum Status
{
    Unknown = 0,
    Pending = 1,
    Failed = 2,
    Error = 2,
}

这是完全有效的,但由于枚举生成器处理 switch 表达式的方式,这意味着如果你调用 ToStringFast()(或其他方法),你将不会总是得到预期的值。这本身不是生成器的问题,因为使用内置的 ToString() 方法也会看到类似的行为:

1
2
var status = Status.Error;
Console.WriteLine(status.ToString()); // 打印 Failed

这只是 .NET 中枚举幕后工作方式的产物,但这可能会令人困惑,因此 #162 添加了一个分析器,用诊断 ID NEEG003 标记这些有问题的案例:

1
2
3
4
5
6
7
8
[EnumExtensions]
public enum Status
{
    Unknown = 0,
    Pending = 1,
    Failed = 2,
    Error = 2,  // NEEG003: 枚举具有重复值,将导致 ToStringFast() 返回值不一致
}

此诊断仅为信息级别,因此它不会中断你的构建,因为在这些情况下使用 [EnumExtensions] 仍然是有效的,只是重要的是要意识到生成的扩展可能不会按你预期的方式工作!

这涵盖了所有新的分析器,最后我们将看看一些修复。

错误修复

第一个修复在 #165 中引入,然后在 #172 中正确修复,是为了更好地处理用户将项目的 LangVersion 设置为 Preview 的情况。在 NetEscapades.EnumGenerators 的先前版本中,我添加了对 C#14 扩展成员的支持。这让你可以像静态扩展成员定义在类型本身上一样调用它们。例如,假设你有这个枚举:

1
2
3
4
5
6
7
[EnumExtensions]
public enum MyColours
{
    Red,
    Green,
    Blue,
}

源生成器生成一个 MyColoursExtensions.Parse() 方法,但有了扩展成员,你可以像它定义在 MyColours 枚举本身上一样调用它:

1
var colour = MyColours.Parse("Red");

我原本打算只在您使用 C#14 时启用此功能,但我犯了一个错误。当您使用 C#14 或设置了 LangVersion=Preview 时,我都启用了它。长话短说,根据您面向的目标和构建时使用的 SDK 版本,Preview 几乎可以意味着任何东西,所以这不是一个好主意😅 作为修复,我移除了扩展成员的生成,除非您明确面向 C#14 或更高版本(忽略 Preview 情况)。为了在使用 Preview 时允许选择加入扩展成员,我添加了一个 EnumGenerator_ForceExtensionMembers 设置,您可以将其设置为 true 以在通常不会生成的情况下明确选择加入。不幸的是,我最初不小心将其默认设置为 true,所以 #172 将其修复为默认 false。🙈

另一个主要的修复是为了处理枚举成员名称为保留字的情况,例如:

1
2
3
4
5
6
7
[EnumExtensions]
public enum AttributeFieldType
{
    number,
    @string, // 保留字,所以用 @ 转义
    date
}

不幸的是,我没有正确处理这个问题,所以生成器生成了无效的代码:

1
2
3
4
5
6
7
8
public static string ToStringFast(this AttributeFieldType value)
    => value switch
    {
        global::AttributeFieldType.number => "number",
        global::AttributeFieldType.string => "string", // ❌ 无法编译
        global::AttributeFieldType.date => "date",
        _ => value.AsUnderlyingType().ToString(),
    };

修复涉及用这个方便的函数更新生成器,以确保我们根据需要正确转义标识符:

1
2
3
4
5
6
private static string EscapeIdentifier(string identifier)
{
    return SyntaxFacts.GetKeywordKind(identifier) != SyntaxKind.None
        ? "@" + identifier
        : identifier;
}

这样生成的代码就能正确转义:

1
2
3
4
5
6
7
8
public static string ToStringFast(this AttributeFieldType value)
    => value switch
    {
        global::AttributeFieldType.number => "number",
        global::AttributeFieldType.@string => "string", // ✅ 正确转义
        global::AttributeFieldType.date => "date",
        _ => value.AsUnderlyingType().ToString(),
    };

最后一个变化是在 #160 中移除了 NETESCAPADES_ENUMGENERATORS_EMBED_ATTRIBUTES 选项,这移除了将标记属性嵌入目标 dll 的能力。这很少是正确的事情,而且该包已经在做将属性放在专用 dll 中的工作。这也减少了一些重复,删除了需要测试的配置组合,并为未来在“属性”dll 中交付“辅助”类型打开了可能性。

总结

在这篇文章中,我介绍了 NetEscapades.EnumGenerators 在 1.0.0-beta16 版本中发布的一些近期更新。这些生活质量更新添加了对 [EnumMember] 的支持,更新了元数据属性的使用方式,并添加了额外的分析器来捕获潜在的陷阱。最后,它修复了一些边缘情况的错误。如果你还没有更新,我建议你更新并尝试一下!如果你遇到任何问题,请在 GitHub 上记录一个问题。🙂

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