C# 9 新功能“源代码生成器”,你用了吗?

浅谈 C# 9.0 中的新功能——源代码生成器,是怎样帮助用户自动生成代码的。

摘要

2020 年 11 月发布的 C# 9.0,融入了.NET 5 的新功能——源码生成器(source generators),它能够基于已有代码的预期条件来生成可重复的代码。由于该功能被嵌入到了编译器中,因此代码生成的整个过程是无感的。.

在本文中,我将会深入分析原代码生成器,并演示怎样使用它来满足现实需求。

作为一个开发者,硬件设备在我们的控制下所开发出来的应用让人们生活变得更好,使冗余任务自动化,简化复杂流程。

在开发的过程中常常会出现必须重写之前代码的情况,如果能尽量降低对代码的扰动,我们就会使用相关技术来复用代码,但是也会经常出现这些技术不管用的情况,这样就会导致程序不如预想的方案运行,否则就需要开发人员牢记必须准确实现的规范和模式。让我们来看几个例子以说明这种情况。

实现相等

首先,让我们来看看怎么实现类的值相等判断。例1给出了类person的简单定义。

例1 Person的简单定义

public sealed class Person
{
    public Person(uint age, string name) =>
    (this.Age, this.Name) = (age, name);
    public uint Age { get; }
    public string Name { get; }
}

如果要比较两个Person对象,我们会想到比较他们的Age和Name。但是对于开发人员来说,实现值相等还有许多事情要做:

  • 必须重写 Equals() 和 GetHashCode()

  • 必须实现IEquatable<T>

  • 需要重写操作符 == 和 !=

在例2中,你能够看到原本简单的定义现在变得复杂一点了。

例2 实现相等方法的Person定义

public sealed class Person
: IEquatable<Person?>
{
    public Person(uint age, string name) =>
    (this.Age, this.Name) = (age, name);
    public uint Age { get; }
    public string Name { get; }
    public override bool Equals(object? obj) =>
    this.Equals(obj as Person);
    public bool Equals(Person? other) =>
    other is not null &&
    this.Age == other.Age &&
    this.Name == other.Name;
    public override int GetHashCode() =>
    HashCode.Combine(this.Age, this.Name);
    public static bool operator ==(Person? left, Person? right) =>
    EqualityComparer<Person>.Default.Equals(left, right);
    public static bool operator !=(Person? left, Person? right) =>
    !(left == right);
}

实现相等不难,但是很麻烦,耗时且容易出错,要是开发人员不在状态把某处的布尔逻辑反转了那就麻烦了。作为一名开发人员,出于各种原因我们想让所有的事情能够自动完成,以最小化人为错误、提高效率。机器能处理的事务越多,我们就越能专注于感兴趣和紧迫的问题。

注意例2中的存在反复使用的代码,无论给什么类型,这些代码都能通过工具创建,Visual Studio自身就提供了这一工具,如下图所示。

C# 9 新功能“源代码生成器”,你用了吗?

使用Visual Studio重构工具生成 Equals and GetHashCode

当你选中生成Equals and GetHashCode,会出现一个对话框,让你能够选择不同的选项来实现相等方法,例如选择是否会用于实现操作符==和!=的属性,如下图所示。

C# 9 新功能“源代码生成器”,你用了吗?

这样就能实现例2中的相等方法的代码。此外,使用C# 9.0中新增的record关键字修饰的类就能完成上述操作。现在创建一个record类型的Person:

public record Person(uint Age, string Name);

把包含Person的程序集载入类似ILSpy的反编译软件,你会发现相等方法已被自动实现了。如下图所示。

C# 9 新功能“源代码生成器”,你用了吗?

然而,record类型实现相等方法是在编译器内部。例3通过在类上添加一个特性,可以自动实现类的相等方法。

例3 Equaltable特性

[Equatable]
public partial sealed class Person
{
    public Person(uint age, string name) =>
    (this.Age, this.Name) = (age, name);
    public uint Age { get; }
    public string Name { get; }
}

[Equatable]特性就像一个标记,通过它能够在其它partial类中生成相等方法代码,开发人员不需要做额外的工作,它也能独立于具体的集成开发环境进行工作。当然,自动实现相等方法代码听起来很厉害,但是我们还无法在C#中立即使用,因为还没有一种本地机制使开发人员能够通过阅读已有代码来进行进一步开发。

实现ToString()

自动生成重复代码是一个让人兴奋的功能,但是代码生成还有另外一个好处,那就是实现高性能应用程序。

比方说你想通过ToString()来详细描述Person:

public override string ToString() =>$"Age = {this.Age}, Name = {this.Name}";

使用公共的可读属性生成用逗号分隔的name/value对的模式很容易,也很容易被忘记,在实现的过程中也有可能出错。还有一种方法就是使用反射,见例4.

例4 使用反射来实现ToString()

public static class ObjectExtensions
{
    public static string GetString(this object self) =>
    string.Join(", ",
    self.GetType().GetProperties(
    BindingFlags.Instance | BindingFlags.Public)
    .Where(_ => _.CanRead)
    .Select(_ => $"{_.Name} = {_.GetValue(self)}"));
}

这对.NET中的任何类都管用,你只需要像下面这样重写ToString():

public override string ToString() =>this.GetString();

这样有两个问题。一是在object类上使用扩展方法是不鼓励的,主要是因为这种方法不能作为通常的扩展方法被用于Visual Basic(尽管这个问题只有极少数人会会遇到,详情参见“Framework Design Guidelines, 3rd Edition” 一书第5.6节)。

另一个更紧迫的问题是反射会增加开销。我通过Benchmark.NET在两种ToString()方法之间进行了性能测试,使用反射的这一方案速度慢了四倍,同时花销了四倍的内存。

你可以通过其它方法来最大程度的降低开销,例如在运行时创建可编译、可执行和可缓存的表达树,但是太麻烦了。

最好的方法就是自动实现ToString()以便开发人员像下面这样使用:

[ToString]
public partial class Person { … }

就像[Equaltable]特性一样,代码生成器找到已存在的[ToString]特性后就会在其它的partial类中高效的实现它。

以上介绍了两个案例,对象值相等判断以及重写ToString(),它们都能够被自动生成。接下来,我将介绍源代码生成器是怎么工作的。

代码生成

代码生成并不是什么新概念,开发人员以多种方式使用代码生成器,或者通过诸如字符串构建之类的简单方法,也可以使用现有工具或他们开发的工具。

T4是其中一种可以利用模板引擎来生成代码的工具,Scriban是另一种,然而它们都没有在底层C#编译器中进行本地集成。正如前一节所描述,Person修饰类型的改变会影响其相等方法和ToString()的实现。自动重新生成代码可以减少差异和错误,再配合好用的IDE,开发人员也能看见已生成的代码并马上理解是什么意思。

什么是源代码生成器

源代码生成器由微软定义,指的是:编译过程中运行的一段代码,能够检查你的程序以生成额外的文件,并与其余代码一同编译。

其本质是在你看当前编译的内容时,就意味着通过语法树去查找特定条件的节点(nodes)、令牌(tokens)和(可能地)符号(symbols),如果存在的话则创建新的C#代码并将其作为编译过程中的一部分包含在内。

在我介绍源代码生成器的例子之前,记住你不能编辑已有的代码,也就是说你不能改变方法体或移除类的属性,你只能添加新代码。

创建映射器对象

我们将要使用源码生成处理的内容是映射对象。想法很简单:有两个对象,它们有或没有任何关系,从其中一个到另一个映射匹配的属性值。如例5,给了两个类:Source和Destination。

例5 定义可映射类

public sealed class Source
{
    public decimal Amount { get; set; }
    public Guid Id { get; set; }
    public int Value { get; set; }
    public string? Name { get; set; }
}
public sealed class Destination
{
    public Guid Id { get; set; }
    public int Value { get; set; }
    public string? Name { get; set; }
}

为了映射这两个类,需要忽略Source对象中的Amount属性,如例6.

例6 映射源类Source到目的类Destination

var source = new Source
{
    Amount = 33M,
    Id = Guid.NewGuid(),
    Value = 10,
    Name = "Woody"
};
var destination = new Destination
{
    Id = source.Id,
    Value = source.Value,
    Name = source.Name
};

我们可以自动创建映射代码,更进一步的,我们想直接映射属性而不是使用基于反射的方法,因为反射的方法不够快。所以,让我们来创建一个对象映射器以从Source类创建扩展方法返回属性设置正确的Destination类。

我已经创建了名为InlineMapping的NuGet包来实现这一目的,可以在这里找到代码。现在来看看为对象映射创建源代码生成器的具体步骤。

首先,你需要创建一个会参与到代码生成过程的类,给这个类标记[Generator]特性,并且实现ISourceGenerator接口,如例7.

例7 实现ISourceGenerator接口

[Generator]
public sealed class MapToGenerator
: ISourceGenerator
{
    public void Execute(GeneratorExecutionContext context) { ... }
    public void Initialize(GeneratorInitializationContext context)
    { ... }
}

Initialize()方法主要用于过滤通过编译管道的语法节点,在上面的例子中,需要去找到在定义时标记了具体特性的类,接下来我会进行说明。如果你不需要这一功能,那么让Initialize()不做任何操作就行。为了放置这个过滤器,需要注册一个action:

RegisterForSyntaxNotifications() :

public void Initialize(GeneratorInitializationContext context) =>
context.RegisterForSyntaxNotifications(() => new MapToReceiver());

MapToReceiver类实现了ISyntaxReceiver接口,通过其中的OnVisitSyntaxNode()方法你可以看到编译中的语法节点以确定其是否有意义。例8展示了映射对象是怎样实现的。

例8 定义语法接收器

public sealed class MapToReceiver
: ISyntaxReceiver
{
    public List<TypeDeclarationSyntax> Candidates { get; } =
    new List<TypeDeclarationSyntax>();
    public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
    {
        if (syntaxNode is TypeDeclarationSyntax typeDeclarationSyntax)
        {
            foreach (var attributeList in
            typeDeclarationSyntax.AttributeLists)
            {
                foreach (var attribute in attributeList.Attributes)
                {
                    if (attribute.Name.ToString() == "MapTo" ||
                    attribute.Name.ToString() == "MapToAttribute")
                    {
                        this.Candidates.Add(typeDeclarationSyntax);
                    }
                }
            }
        }
    }
}

我们找到有MapToAttribute特性的类,它指定了我们想要映射到的类。对于类Source,我们添加这一特性以声明我们想将其映射到Destination类:

[MapTo(typeof(Destination))]
public class Source { … }

标记这一特性的类会被存储在Candidates表中,用于生成器更进一步的检查。你希望这样做是为了减少需要在生成器内部进行的分析量,在生成器中你可能会使用符号来决定应生成的代码。使用语法接收器可简化这一过程。

所有需要进行的工作都要放在Execute()方法里,现在开始创建MapToAttribute类:

var (mapToAttributeSymbol, compilation) =
Assembly.GetExecutingAssembly().LoadSymbol(
"InlineMapping.MapToAttribute.cs",
"InlineMapping.MapToAttribute", context);

LoadSymbol()方法读取InlineMapping.MapToAttribute.cs中的代码并添加到当前编译中。初看之下也许会感到奇怪——为什么不直接把包含MapToAttribute的文件放在工程中并且像其它.cs文件那样进行编译?因为引用ProjectReference或PackageReference所得的组件需要作为分析器组件进行引用。在这种情况下,有着上述组件的类型在编译过程中会被严格使用,在引用组件中也不可用。通过添加类型到编译中,开发人员能够“看到”MapToAttribute。

之后,Execute()实现从MapToReceiver实例的Candidates表中读取类,找到类中所有MapToAttribute值并且生成映射方法:

例9 实现Execute()

if (context.SyntaxReceiver is MapToReceiver receiver)
{
    foreach (var candidateTypeNode in receiver.Candidates)
    {
        var model = compilation.GetSemanticModel(
        candidateTypeNode.SyntaxTree);
        var candidateTypeSymbol = model.GetDeclaredSymbol(
        candidateTypeNode) as ITypeSymbol;
        if (candidateTypeSymbol is not null)
        {
            foreach (var mappingAttribute in
            candidateTypeSymbol.GetAttributes()
            .Where(
            _ => _.AttributeClass!.Equals(
            mapToAttributeSymbol, SymbolEqualityComparer.Default)))
            {
                var (diagnostics, name, text) =
                MapToGenerator.GenerateMapping(
                candidateTypeSymbol, mappingAttribute);
                foreach (var diagnostic in diagnostics)
                {
                    context.ReportDiagnostic(diagnostic);
                }
                if (name is not null && text is not null)
                {
                    context.AddSource(name, text);
                }
            }
        }
    }
}

GenerateMapping()的实现并不容易,本文不会详细介绍每一行代码,只会对关键位置进行说明。首先我要说明的是无参公共构造函数:

var diagnostics = ImmutableList.CreateBuilder<Diagnostic>();
var destinationType =
(INamedTypeSymbol)attributeData.ConstructorArguments[0].Value!;
if (!destinationType.Constructors.Any(
_ => _.DeclaredAccessibility == Accessibility.Public &&
_.Parameters.Length == 0))
{
    diagnostics.Add(Diagnostic.Create(
    new DiagnosticDescriptor(...)));
}

语法生成器的好处在于你可以创建一个诊断来报告代码中你想让用户知道的条件已出现。本例中,为了创建映射,如果我们无法创建Destination对象的实例,那我们就创建一个新的DiagnosticDescriptor来声明可得到的构造器是必须的。

接下来,我们创建Source和Destination对象上的属性表并查找匹配。对于Source对象上的每一个属性都有一个可见的getter,Destination对象上也会有一个同名的并且有可见setter的属性吗?如果是的话,创建C#代码来映射这些值:

maps.Add(
$"\t\t\t\t\t{destinationProperty.Name} = self.
{sourceProperty.Name},");

一旦运行程序后发现任何能够被映射的属性,我们就在同一命名空间下创建一个静态类作为源类型,并生成名为MapTo(DestinationTypeName)的扩展方法。这一方法包含映射代码,在我们的例子中,它叫做MapToDestination()。幸运的是,Visual Studio 2019版号为 16.8的3.1预览版支持“Go To Defination”以生成代码。让我们使用生成的扩展方法来写代码,见例10:

var source = new Source
{
    Amount = 33M,
    Id = Guid.NewGuid(),
    Value = 10,
    Name = "Woody"
};
var destination = source.MapToDestination();
Using “Go To Definition” on MapToDestination() shows this:
using System;
namespace SourceNamespace
{
    public static partial class SourceMapToExtensions
    {
        public static Destination MapToDestination(this Source self) =>
        self is null ? throw new ArgumentNullException(nameof(self)) :
        new Destination
        {
            Id = self.Id,
            Value = self.Value,
            Name = self.Name,
        };
    }
}

如果源引用为null的话这里会抛出了一个ArgumentNullException异常(如果Source类是值类型的话生成器不会添加null检查)。否则,就会如我们所期望的那样精确映射。

我得承认,当我首次成功运行的时候我很开心。

像这样能够及时生成代码是多么神奇,当一个开发人员说我要源类映射到目的类,然后这个映射就自动的以一种高效的方式实现了。在这里我强调“高效”,是因为像这样的问题典型的处理方式是通过反射一类的方法,它们并不高效。我在InlineMapping解决方案中创建了一个名为InlineMapping.PerformanceTests的工程,把它和使用反射进行实现的工程进行比较,还有其它使用AutoMapper(流行的.NET映射包)的方法。结果如下:

C# 9 新功能“源代码生成器”,你用了吗?

通过在编译过程中生成代码,我们生成不消耗太多内存地高效代码。根据我提供的代码,如果你能找到更多的改进,我会很高兴的考虑进行修改(pull requests)。

其它例子

这里介绍的InlineMapping例子是C#源码生成器众多例子中的一个。像是上文提到的AutoMapper也非常受欢迎(在我写这篇文章的时候,其包的下载量已超过1忆)。

可以想象的是包的实现可以被修改为使用源代码生成器,使其更快。大量的其它例子展示了源码生成器的作用:

Roslyn Examples——多个源码生成器例子,例如实现IPropertyNotifyChanged以及围绕CSV文件键入模型;

StrongInject——一个高效的、经过编译时检查的控制反转(IoC)容器;

ThisAssembly——使用简单易用的界面公开组件信息;

Rocks——这是我的其它源码生成器工程,创建用于测试的mocks包。我对其进行了修改以使mocks在编译时生成,在这里你可以关注此项工作。

强烈建议仔细了解这些例子,说不定能鼓励你在其它领域使用源码生成器。

结论

源码生成器是C# 9.0 中一个强大的功能。通过这一功能,你能够以安全、高效的方式来使重复代码的编写模式现代化。最好自己尝试一下,你会惊讶代码生成居然可以完成这么多事,编程使我快乐!