Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Blocking primary constructor member capture using a Roslyn Analyzer

$
0
0

In my previous posts I talked about primary constructors, how they work, and some of the things I like and dislike about them. In this post I show a Roslyn analyzer that you can add to your project to ensure that primary constructors are only used for initialization. It blocks using primary constructors to implicitly capture the parameters as fields.

The main approaches to primary constructors

Before we look at the analzyer, we should make sure we clear on the different ways to use primary constructors. As I described previously, there are two main ways to use primary constructors:

  • Using the parameters to initialize fields and properties.
  • Using the parameters directly in members, implicitly capturing the parameters as fields.

In the first case, you use the constructor parameters in initialization code for fields and properties:

public class Person(string firstName, string lastName)
{
    private readonly string _firstName = firstName; // 👈 initialized
    private readonly string _lastName = lastName;
}

and the compiler synthesizes a constructor that does the assignment for you:

public class Person
{
    private readonly string _firstName;
    private readonly string _lastName;

    // This constructor is synthesized by the compiler
    public Person(string firstName, string lastName)
    {
        _firstName = firstName;
        _lastName = lastName;
    }
}

The other approach is to reference the parameters directly in members:

public class Person(string firstName, string lastName)
{
    // Directly referenced       👇          👇
    public string FullName => $"{firstName} {lastName}";
}

In this case, the compiler also adds mutable fields to the class to capture the variables:

public class Person
{
    private string <firstName>P; // Generated by the compiler
    private string <lastName>P; // Generated by the compiler

    public string FullName =>  string.Concat(<firstName>P, " ", <lastName>P);

    // The generated constructor sets the values of the generated fields
    public Person(string firstName, string lastName)
    {
        <firstName>P = firstName;
        <lastName>P = lastName;
    }
}

I go into more detail in my previous post, so I'll leave it there for now. Now we'll look at how to prevent using primary constructors with the second approach.

Blocking implicit parameter capture

Primary constructors are great, but as I described in my previous post, there are a whole bunch of downsides you need to consider if you choose to implicitly capture the primary constructor parameters as fields. And that makes them fundamentally harder to reason about than normal constructors.

But I like primary constructors for simple initialization, so I don't really want to have to block them entirely. Well, it turns out, I'm not alone.

I was listening to Jeffrey Palermo on a recent podcast talking to Jared Parsons, principal developer lead on the C# compiler team, when the conversation turned to primary constructors. And it seems that the Roslyn compiler team have similar feelings about primary constructors - they don't want the implicit capture behaviour either!

What's more, the Roslyn team have an analyzer that checks for these problematic usages and explicitly blocks them (by adding a warning, which fails in CI where they have TreatWarningsAsErrors enabled).

Unfortunately, adding that analyzer to your application is slightly more complex than you might expect. It's included in the latest Roslyn.Diagnostics.Analyzers NuGet package, but the description for that package makes it clear it's not really for public consumption:

Private analyzers specific to Roslyn repo. These analyzers are not intended for public consumptions outside of the Roslyn repo.

That doesn't mean you can't use it though, just that you'll get a bunch of additional analyzers you might not want! But we can work around that 😃

Using the Roslyn.Diagnostics.Analyzers package to block primary constructor capture

In this section I'll show how you can add the Roslyn.Diagnostics.Analyzers package to your app, enable the primary constructor analyzer, and disable all the other analyzers that come along for the ride.

1. Install the NuGet package

First of all, install the Roslyn.Diagnostics.Analyzers in your favourite way. For example, using the dotnet CLI:

dotnet add package Roslyn.Diagnostics.Analyzers --version 3.11.0-beta1.24165.2

or add the following directly to your .csproj file

<PackageReference Include="Roslyn.Diagnostics.Analyzers" Version="3.11.0-beta1.24165.2">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>

This adds a whole bunch of analyzers to your project which are primarily tailored to the dotnet/roslyn repository, so you likely won't want most of them active on your code. The documentation is also a little sparse for many of them, reflecting their internal nature!

2. Disabling all the analyzers by default

You probably don't want to enable most of the analyzers by default, we're only here for the primary constructor analyzer after all! Luckily, we can set an MSBuild property in our project and disable all the analyzers in one shot. Set AnalysisModeRoslynDiagnosticsDesign to AllDisabledByDefault in your project. For example:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <!-- Add this property 👇 with this value 👇 to disable all the analyzers by default-->
    <AnalysisModeRoslynDiagnosticsDesign>AllDisabledByDefault</AnalysisModeRoslynDiagnosticsDesign>
  </PropertyGroup>

  <ItemGroup>
    <!-- The analyzer NuGet package is added here -->
    <PackageReference Include="Roslyn.Diagnostics.Analyzers" Version="3.11.0-beta1.24165.2">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
    </PackageReference>
  </ItemGroup>

</Project>

3. Enable the primary constructor analyzer

Now that you've disabled all the analyzers we need to enable just the primary constructor analyzer. You can do this using an .editorconfig file.

.editorconfig is a cross-language, cross-IDE file format that lets you control how code style and formatting.

If you don't already have an .editorconfig file in your project, you can add one by running:

dotnet new editorconfig

To enable the primary constructor implicit capture analyzer, add the following to the file:

# RS0062: Do not capture primary constructor parameters
dotnet_diagnostic.RS0062.severity = error

With that setting enabled, if you try to implicitly capture a primary constructor parameter, you'll get an error telling you it's not allowed!

Error when trying to implicitly capture a variable

I used error in the example above, but you can also take the same approach as the roslyn repository and enable it as a warning, relying on TreatWarningsAsErrors in CI:

# RS0062: Do not capture primary constructor parameters
# Warning so that it doesn't block local F5, but will fail in CI with WarnAsError
dotnet_diagnostic.RS0062.severity = warning

One thing you may have noticed in the image above, is that there are additional warnings on the type. Let's get rid of those.

4. Disable public API analyzer

The Roslyn.Diagnostics.Analyzers NuGet package transitively references two other packages:

The warnings shown in the previous image are related to the second of those analyzers, it's complaining because it's a public type, and there's no recorded public APIs

Warnings about public API usage

If you're not developing a NuGet package then you probably don't need the PublicApiAnalyzers package. And if that's the case, you can tell the analyzer to bail-out by setting the following in your .editorconfig file:

# Bail out of public API analyzer
dotnet_public_api_analyzer.require_api_files = true

Now the public API analyzer is disabled, we're left with the primary constructor error only:

Errors when public API Analyzer is disabled

You can fix the error by using the primary constructor to initialize fields instead:

Errors resolved

So now you can get all the benefits of primary constructors without having to worry about the complexity of implicitly captured variables!

Creating your own analyzer implementation

Now, if that seems like quite a lot of work to add the analyzer, I understand. We wanted to use this analyzer in the Datadog client library, but at that point, it wasn't even available in the public NuGet feed, so it seemed like a lot of hassle.

Luckily, the analyzer implementation is actually remarkably simple. The following file is the whole analyzer that we added. Compared to the original, it uses simple string constants and defaults to error instead of warning, but otherwise it's the same. I've commented the important points in the code below:

using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class DoNotCapturePrimaryConstructorParametersAnalyzer : DiagnosticAnalyzer
{
    /// <summary>
    /// The diagnostic ID displayed in error messages
    /// </summary>
    public const string DiagnosticId = "DD0003";

    private static readonly DiagnosticDescriptor Rule = new(
        DiagnosticId,
        "Do not capture primary constructor parameters",
        "Primary constructor parameter '{0}' should not be implicitly captured",
        "Maintainability",
        DiagnosticSeverity.Error,
        isEnabledByDefault: true,
        description: "Primary constructor parameters should not be implicitly captured. Manually assign them to fields at the start of the type.");

    /// <inheritdoc/>
    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);

    /// <inheritdoc/>
    public override void Initialize(AnalysisContext context)
    {
        // Don't analyse generated code
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();

        // We only care about operations that reference parameters
        context.RegisterOperationAction(AnalyzeOperation, OperationKind.ParameterReference);
    }

    private static void AnalyzeOperation(OperationAnalysisContext context)
    {
        var operation = (IParameterReferenceOperation)context.Operation;

        if (SymbolEqualityComparer.Default.Equals(operation.Parameter.ContainingSymbol, context.ContainingSymbol) || operation.Parameter.ContainingSymbol is not IMethodSymbol { MethodKind: MethodKind.Constructor })
        {
            // We're in the primary constructor itself, so no capture.
            return;
        }

        // Find the root operation
        IOperation rootOperation = operation;
        for (; rootOperation.Parent != null; rootOperation = rootOperation.Parent)
        {
        }

        if (rootOperation is IPropertyInitializerOperation or IFieldInitializerOperation)
        {
            // This is an explicit capture into member state. That's fine.
            return;
        }

        // This must be a capture. Error
        context.ReportDiagnostic(Diagnostic.Create(Rule, operation.Syntax.GetLocation(), operation.Parameter.Name));
    }
}

As you can see, the analyzer is pretty simple, there's basically 5 steps:

  • Register analyzer for each time a primary constructor parameter is referenced
  • Check if the parameter reference is in the constructor itself. If so, bail out.
  • If not, find the "root" operation where the parameter is used.
  • If the root operation is a field or property initializer, bail out.
  • Otherwise, it must be a capture, so error!

And that's it! There are also a bunch of tests for the analyzer, which neatly show which patterns the analyzer will reject, and which it will allow, so in the next section we'll enumerate those.

Viewing the tests to see blocked patterns

There are two sets of tests, one set for checking the analyzer does generate an error, and one set for checking it doesn't. The following are the cases that will generate an error:

// Referenced in a method
class C(int i)
{
    private int M() => i; // ERROR
}

// Referenced in property getters/setters
class C(int i)
{
    private int P
    {
        get => i;  // ERROR
        set => i = value;  // ERROR
    }
}

// Referenced in indexer
class C(int i)
{
    private int this[int param]
    {
        get => i;  // ERROR
        set => i = value;  // ERROR
    }
}

// Referenced in event
class C(int i)
{
    public event System.Action E
    {
        add => _ = i; // ERROR
        remove => _ = i; // ERROR
    }
}

// Referenced in other constructor
class C(int i)
{
    C(bool b) : this(1)
    {
        _ = i; // ERROR
    }
}

As for the cases that are valid and don't generate an error:

// Passing to base class
class Base(int i);
class Derived(int i) : Base(i);

// Initializing a field
class C(int i)
{
    public int I = i;
}

// Initializing a property
class C(int i)
{
    public int I { get; set; } = i;
}

// Captured in lambda passed to base
public class Base(Action action);
public class Derived(int i) : Base(() => Console.WriteLine(i));

// Local function parameter reference - looks a bit like primary constructors but it isn't!
class C
{
    void M()
    {
        Nested1(1);

        void Nested1(int i) // not a primary constructor!
        {
            Nested2();

            void Nested2() => Console.WriteLine(i);
        }
    }
}

So there you have it, an easy way to block the "bad" bits of primary constructors (in my opinion) while still keeping the good stuff.

I don't expect many people to take this approach, and if I was writing a line of business app, I wouldn't necessarily advocate for it. But as a library author that values the simpler (but slightly more verbose) approach of only using initializers, this gives the best of both worlds!

Summary

In this post I described an analyzer that's used by the Roslyn compiler team to ensure that you only use primary constructors for initializing fields and properties. The analyzer gives an error (or warning) if you try to use a primary constructor in a way that implicitly captures it in member state. Unfortunately, this analyzer is not really meant for "public" consumption, so you have to go through several steps to enable it. But once you have, it works like a charm.

If you don't want to add (and disable) all those extra analyzers in your project, and you already have your own analyzers, you could always copy the analyzer directly into your project instead. I showed the version of the analyzer we use in the Datadog .NET client library, walked through how it works, and showed examples of the cases it accepts and rejects.


Viewing all articles
Browse latest Browse all 743

Trending Articles