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

Conditional compilation for ignoring method calls with the ConditionalAttribute

$
0
0

The other day at work I ran into an attribute I hadn't heard about, the [Conditional] attribute. In this post I start by describing conditional compilation using pre-processor directives like #if, and then introduce the [Conditional] attribute, and describe how it differs to using #if.

Conditional compilation with #if

With the release of .NET Core 5 years ago (has it really been that long?!) the need to multi-target .NET libraries and applications became more prominent. As the early versions of .NET Core supported far fewer features than .NET Framework, it was common to use conditional compilation, so that when you compile a library for .NET Framework, your library uses one code path, and when you compile for .NET Core (or .NET Standard), it uses another. Recent releases of .NET Core (and .NET 5+) now have more features and APIs than .NET Framework, so the need for conditional compilation remains.

The common way to do conditional compilation in .NET is using pre-processor directives like #if <symbol>. These directives tell the compiler "only include this section of code if <symbol> is defined". If the compilation symbol isn't defined, the code is skipped entirely.

There are various compilation symbols built in, such as DEBUG, or you can define your own. Some of the most commonly used symbols these days are related to the target framework. For example, if you're targeting .NET Framework 4.6.1, the symbols NETFRAMEWORK and NET461 will be defined. If you're targeting .NET Core 3.1, the symbols NETCOREAPP and NETCOREAPP3_1 will be defined.

A common example using #if..#else..#endif

Let's consider a concrete example. The following code shows a method to extract the "value" portion of a string like key=value, and parse it as an int. For .NET Framework, we use the old string manipulation APIs, and parse the integer. On .NET Core (2.1+), we can use the more performant Span<T> APIs, that reduce the allocations required. For the purposes of this example, I'm assuming our app targets .NET Framework 4.6.1 and .NET 5.

public int GetValuesFromEnvironmentVariable(string pair)
{
    var equals = pair.IndexOf('=');

#if NETCOREAPP
    // This code is only included if we target .NET Core / .NET 5+
    var value = pair.AsSpan().Slice(equals + 1);
    return int.Parse(value);
#else
    // This code runs if we target anything else, e.g. .NET Framework, .NET Standard
    var value = pair.Substring(equals + 1);
    return int.Parse(value);
#endif
}

Note that this is just sample code, we're not validating inputs, handling errors, or supporting all target frameworks. It is simply to demonstrate conditional compilation.

The #if..#else..#endif is very useful for cases like this, where you want to execute one code branch or another based on the target framework.

Conditionally including a method using #if

Let's consider another example, where you want to take an action if you're running on .NET Framework, and do nothing otherwise. Your code might look something like this:

public void DoSomethingOnAllPlatforms()
{
    // ...do something

#if NETFRAMEWORK
    // Only included when we target .NET Framework
    Console.WriteLine("Running extra .NET Framework steps");
    // ...
#endif

    // ...do other stuff
}

In this case, the code inside the #if will only be executed if we're targeting .NET Framework. This pattern works well for now, but what if the code inside grows from 1 line, to 100? We'd likely want to extract that to a method, but then we have a decision to make—where should we put the #if? We have two choices:

  • Place an #if around the call site of the platform-specific method.
  • Place an #if around the body of the platform-specific method.

Let's consider both of those. First, we extract the platform-specific method, called DoNetFrameworkStuff() below. We then call that from our DoSomethingOnAllPlatforms() method, but wrapping the call in #if

public void DoSomethingOnAllPlatforms()
{
    // ...do something

#if NETFRAMEWORK
    DoNetFrameworkStuff();
#endif

    // ...do other stuff
}

public void DoNetFrameworkStuff()
{
    Console.WriteLine("Running extra .NET Framework steps");
}

There are pros and cons to this approach:

  • Pro: The DoNetFrameworkStuff method isn't called at all if NETFRAMEWORK is not defined.
  • Con: If the DoNetFrameworkStuff uses APIs that aren't available on other platforms, this code won't compile. You could work around this by adding an additional #if around the whole DoNetFrameworkStuff method.
  • Con: You have to remember to add the #if to every call site if you call DoNetFrameworkStuff from additional places in your code.

Let's consider an alternative approach. Instead of making the DoSomethingOnAllPlatforms be responsible for adding the #if, lets include that in the method body of the framework-specific function:

public void DoSomethingOnAllPlatforms()
{
    // ...do something
    DoNetFrameworkStuff();
    // ...do other stuff
}

public void DoNetFrameworkStuff()
{
#if NETFRAMEWORK
    Console.WriteLine("Running extra .NET Framework steps");
#endif
}

There are pros and cons to this too:

  • Pro: You can safely call DoNetFrameworkStuff on any target platform, and the platform-specific code is only executed if NETFRAMEWORK is defined.
  • Pro: DoNetFrameworkStuff can use APIs that aren't available on other platforms without any other changes.
  • Con: The compiler will generate an unnecessary method call to DoNetFramework stuff. The method itself is empty, so will have minimal impact, but in high-performance scenarios the unnecessary method call may be annoying.

I've seen both of these approaches used, so there's no right or wrong answer, I just wanted to highlight the common patterns. Now we'll take a look at the [Conditional] attribute.

The [Conditional] attribute

The [Conditional] attribute has been around since .NET Framework 1.1 and was in all versions of .NET Core, but I hadn't heard about it until last week! This attribute performs a similar purpose to the #if pre-processor directives, indicating that calls to the method should not be included in the compilation output.

The following example shows how we could use the [Conditional] attribute with the DoNetFrameworkStuff() from the previous section. The attribute is placed on the method, passing in a symbol that must be defined for the method to be called.

using System.Diagnostics;

public void DoSomethingOnAllPlatforms()
{
    // ...do something
    DoNetFrameworkStuff();
    // ...do other stuff
}

[Conditional("NETFRAMEWORK")]
public void DoNetFrameworkStuff()
{
    Console.WriteLine("Running extra .NET Framework steps");
}

This code is equivalent to the first of my #if examples in the previous section, where we place #if around the method call:

public void DoSomethingOnAllPlatforms()
{
    // ...do something

#if NETFRAMEWORK
    DoNetFrameworkStuff();
#endif

    // ...do other stuff
}

public void DoNetFrameworkStuff()
{
    Console.WriteLine("Running extra .NET Framework steps");
}

In contrast to the "manual" approach with pre-processor directives, using the [Conditional] attribute effectively ensures the #if is always added around calls to the method, which mitigates one of the "cons" I mentioned in the previous section.

Downsides to the [Conditional] attribute

One of the downsides of the [Conditional] attribute is the fact that you can only use it onvoid methods. That makes sense if you think about it, as the compiler needs to safely remove the method call—if the return value was assigned to a variable, then what should the compiler do about that assignment? Restricting it to void methods avoids issues like these.

Another drawback to the [Conditional] attribute is the same as for the equivalent #if approach; the body of DoNetFrameworkStuff must compile on all platforms, even though it will only be called if the target symbol is defined.

As a concrete example of this, at work we needed to write some test code to add some libraries to the Global Assembly Cache (GAC) (I know, I know, 😩). This seemed like an ideal place for the [Conditional] attribute, as we needed the method to only be called when we targeted .NET Framework, as .NET Core doesn't use the GAC (🎉). We added a conditional reference to System.EnterpriseServices in the project:

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFrameworks>net461;netcoreapp3.1;net5.0</TargetFrameworks>
    <LangVersion>latest</LangVersion>
  </PropertyGroup>

  <ItemGroup Condition="'$(TargetFramework)' == 'net461'">
    <Reference Include="System.EnterpriseServices" />
  </ItemGroup>

</Project>

And added the [Conditional] attribute to the method call:

[Conditional("NETFRAMEWORK")]
public void AddAssembliesToGac()
{
    var publish = new System.EnterpriseServices.Internal.Publish();
    publish.GacInstall("a");
}

The problem is, this won't compile on the other target frameworks! Even though calls to AddAssembliesToGac() will be removed from the compilation for the .NET Core targets, the AddAssembliesToGac() method itself will still be compiled, and therefore must compile correctly.

You can get the best of both worlds if you add an additional #if around the method body, for example:

[Conditional("NETFRAMEWORK")]
public void AddAssembliesToGac()
{
#if NETFRAMEWORK
    var publish = new System.EnterpriseServices.Internal.Publish();
    publish.GacInstall("a");
#endif
}

Now, all the calls to AddAssembliesToGac() will be removed for .NET Core targets and the body of the method doesn't need to compile on .NET Core.

To clarify further, in the following example, when targeting .NET Core:

  • CompiledButNotCalled() will be present in the compiled assembly, but will never be called.
  • CalledButEmpty() will be present in the compiled assembly, will still be called, but will have no method body.
  • NotCalledAndEmpty() will be present in the compiled assembly, but will have an empty method body, and will never be called.
  • NotCompiled() will not be present in the compiled assembly.
using System;
using System.Diagnostics;
public class C {
    public void M() {
        CompiledButNotCalled(); // removed on .NET Core
        CalledButEmpty(); // never removed
        NotCalledAndEmpty(); // removed on .NET Core
#if NETFRAMEWORK
        NotCompiled(); // removed on .NET Core
#endif
    }
    
    [Conditional("NETFRAMEWORK")]
    public void CompiledButNotCalled() // present in compiled output in .NET Core
    {
        Console.WriteLine("CompiledButNotCalled");
    }
    
    public void CalledButEmpty() // has no method body in .NET Core
    {
#if NETFRAMEWORK
        Console.WriteLine("CalledButEmpty");
#endif
    }
    
    [Conditional("NETFRAMEWORK")]
    public void NotCalledAndEmpty() // has no method body in .NET Core
    {
#if NETFRAMEWORK
        Console.WriteLine("NotCalledAndEmpty");
#endif
    }
    
#if NETFRAMEWORK
    public void NotCompiled() // is removed from compiled output in .NET Core
    {
        Console.WriteLine("NotCompiled");
    }
#endif
}

You can view the IL for the example above to confirm my statements on https://sharlab.io. The following image shows that the CompiledButNotCalled() method is included in the assembly, even though it won't be called:

Example of method being removed from output

Out of all of these, the [Conditional] attribute with the #if removed method body makes the most sense to me from a usability point of view

Before we close, I'll mention one other possible use of the conditional attribute—on classes deriving from Attribute.

Applying the [Conditional] attribute to classes

In the previous section I said the [Conditional] attribute can only be used on void methods, but it can actually also be applied to classes. The limitation is that it can only be applied to classes that derive from Attribute. For example:

[Conditional("NETFRAMEWORK")]
public class MyTestAttribute : Attribute
{
    public MyTestAttribute(string value)
    {
        Value = value;
    }
    public string Value { get; }
}

So what does this mean? Well, similar to the method case, the [Conditional] attribute means "remove all usages of this attribute". So if you apply the [MyTest] attribute to a class or method, it will only really be applied if the NETFRAMEWORK symbol is defined. So consider the following example, in which apply the conditional [MyTest] attribute to a class, and then use reflection at runtime to read the value of the attribute:

using System;
using System.Diagnostics;
using System.Linq;

[MyTest("Some value")]
public static class C {
    public static void M() {
        var attr = typeof(C)
            .GetCustomAttributes(typeof(MyTestAttribute), inherit: false)
            .FirstOrDefault();

        if(attr is MyTestAttribute a)
        {
            Console.WriteLine($"MyTest.Value is {a.Value}");
        }
        else
        {
            Console.WriteLine("MyTest attribute not found");
        }
    }
}

[Conditional("NETFRAMEWORK")]
public class MyTestAttribute : Attribute
{
    public MyTestAttribute(string value)
    {
        Value = value;
    }
    public string Value { get; }
}

In this case, if NETFRAMEWORK is defined, C.M() will print

MyTest.Value is Some value

on .NET Core or other platforms that don't define NETFRAMEWORK, you'll see

MyTest attribute not found

Note that in both cases the MyTestAttribute class is still defined in the assembly, it's only the usages that are removed for .NET Core. Your attribute code still needs to compile on all platforms, and your usages must be valid on all platforms (e.g. in this case you must pass the required value parameter). It is only the attribute usage that is removed from the final compilation (as you can see in the disassembly here).

Summary

In this post I described the common approach to conditional compilation using #if pre-processor directives and showed several approaches to conditionally execute a method on one platform, and exclude it on other platforms. I then introduced the [Conditional] attribute and compared it to the #if equivalent.

[Conditional] can only be used on void methods, or on classes that derive from Attribute. All calls to a method decorated with [Conditional], where the symbol is not defined, will be removed. Similar functionality occurs for attributes. As the method and attribute code themselves are still included in the compilation, you must ensure the code compiles on all target platforms, not just the conditional platform you wish to target.


Viewing all articles
Browse latest Browse all 743

Trending Articles