深入解析NetEscapades.EnumGenerators:新增[EnumMember]支持、分析器与错误修复

本文详细介绍了NetEscapades.EnumGenerators源码生成器NuGet包的最新更新。新版本添加了对[EnumMember]属性的支持,改进了元数据属性的使用方式,引入了多个Roslyn分析器以捕获潜在问题,并修复了若干边缘情况的错误。

Recent updates to NetEscapades.EnumGenerators: [EnumMember] support, analyzers, and bug fixes

在这篇文章中,我将介绍我创建的源码生成器NuGet包NetEscapades.EnumGenerators最近的一些更新。该包可用于为枚举添加快速操作方法。我将首先介绍这个包存在的原因及其用途,然后介绍一些最近的改动。

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

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
11
public static class ColourExtensions
{
    public string ToStringFast(this Colour colour)
        => colour switch
        {
            Colour.Red => nameof(Colour.Red),
            Colour.Blue => nameof(Colour.Blue),
            _ => colour.ToString(),
        }
    }
}

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

Method FX Mean Error StdDev Ratio Gen 0 Allocated
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.005 - -
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.007 - -

这些数字现在显然已经很旧了,但总体模式没有改变:.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]属性,警告你此用法无效。

枚举中的重复标签

此版本中添加的最后一个分析器处理“重复”枚举成员的情况,即枚举成员与其他成员具有相同的“值”。例如,在下面的代码中,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添加了一个分析器,用诊断NEEG003标记这些有问题的情况:

1
2
3
4
5
6
7
8
[EnumExtensions]
public enum Status
{
    Unknown = 0,
    Pending = 1,
    Failed = 2,
    Error = 2,  // NEEG003: Enum has duplicate values and will give inconsistent values for 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时,我都启用了它。长话短说,Preview可能意味着几乎任何事情,具体取决于您针对的目标和构建时使用的SDK版本,所以这不是一个好主意。😅

作为修复,我移除了扩展成员的生成,除非您明确针对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中分发该属性。这也减少了一些重复,移除了一个需要测试的配置组合,并为将来在“attributes” dll中分发“helper”类型开辟了可能性。

总结

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

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