Run configurations are one of those things that many .NET developers take for granted. Whenever we start a new project, we have two basic configurations: Debug and Release. For most of us, that’s more than enough to deliver a capable production application. For those of us targeting multiple platforms, we may need a configuration for each destination platform.

In this post, we’ll walk through the basics of C# preprocessor directives. We’ll use keywords like define, if, else, elif, and undef to change our application’s behavior. We’ll also look at some of the predefined preprocessor directives that come with the .NET Framework. Finally, we’ll see how we can alter the preprocessor directives on our run configurations.

What is a Preprocessor Directive

A preprocessor directive gives us the ability to define blocks of code that only get compiled if we meet criteria. Previously we mentioned that most C# projects start with two different run configurations of Debug and Release. If we look at the preprocessor directives defined for each run configuration, we’ll see they differ.

Run Configuration Preprocessor Directives
Debug DEBUG, TRACE
Release TRACE

The Debug run configuration has two preprocessors, while Release only has one preprocessor. In Debug, we can define additional code blocks that increase our ability to diagnose and fix issues.

internal class Program
{
    public static void Main(string[] args)
    {
    #if DEBUG
        Console.WriteLine("Before the hello, World!");
    #endif
        
        Console.WriteLine("Hello, World!");
    }
}

How To Use Preprocessor Directives

There are several ways to introduce preprocessor directives into our projects.

  1. Predefined by the .NET Framework
  2. Run Configuration Compile Constants
  3. Using the define keyword in code
  4. Using the -define option with the compiler.

Predefined Preprocessor Directives

C# has many predefined preprocessor symbols representing all the available target frameworks found in our development environment. These preprocessor directives can help us transition legacy code to newer platforms, or target future platforms while maintaining our codebase for the present. Here is a list of known predefined directives, and we can assume the .NET team will continue the pattern for future iterations of SDK releases.

Target Frameworks Preprocessor Directives
.NET Framework NETFRAMEWORK, NET20, NET35, NET40, NET45,NET451, NET452, NET46, NET461, NET462, NET47, NET471, NET472, NET48
.NET Standard NETSTANDARD, NETSTANDARD1_0, NETSTANDARD1_1,NETSTANDARD1_2, NETSTANDARD1_3, NETSTANDARD1_4, NETSTANDARD1_5,NETSTANDARD1_6, NETSTANDARD2_0, NETSTANDARD2_1
.NET Core NETCOREAPP, NETCOREAPP1_0, NETCOREAPP1_1, NETCOREAPP2_0, NETCOREAPP2_1, NETCOREAPP2_2, NETCOREAPP3_0, NETCOREAPP3_1

Other development platforms may also introduce a set of specific preprocessor directives. For example, the Unity Game Engine presents an abundant list of preprocessor directives for each target platform from Android, iOS, WebGL, and more.

Run Configuration Constants

One of the most straightforward approaches to adding additional compile constants is by adding them to a specific run configuration. We can modify our run configuration by right-clicking the properties of our project in our IDE. Here is a screenshot from Rider.

run configuration in JetBrains Rider

We can add additional preprocessor directives or remove them from our compilation.

Using the Define Keyword In Code

We can leverage the define keyword to create preprocessor directives inside of our C# files. There are caveats to this approach.

  1. The symbol must appear at the top of the file before any instructions.
  2. The new symbol does not conflict with another preprocessor directive.
  3. The scope of the symbol is the file in which we define it.

Let’s have a look at this use case:

#define Hello
using System;

namespace Changes
{
    internal class Program
    {
        public static void Main(string[] args)
        {
            #if Hello
            Console.WriteLine("Hello, World!");
            #endif
        }
    }
}

Using the Define Compiler Option

Instead of modifying our run configuration, we can also make compile-time decisions about our preprocessor directives. When calling the C# compiler, we can pass in our directives using the -define option.

> csc HelloWorld.cs -define:DEBUG;Hello

This approach works, but it is a little less convenient than modifying our run configurations. This approach may be useful in continuous integration environments that may be looking at environment variables. That said, C# projects support multiple run configurations, and that approach may be more deterministic.

Code Example

We can think of preprocessor directives as boolean values, and so does C#. Because of their logical nature, we get to use boolean operators to make preprocessor decisions. Take the following example.

#define FIZZ
#define BUZZ

using System;

namespace Changes
{
    internal class Program
    {
        public static void Main(string[] args)
        {
            #if (FIZZ && BUZZ)
                Console.WriteLine("FizzBuzz");
            #elif (FIZZ)
                Console.WriteLine("Fizz");
            #elif (BUZZ)
                Console.WriteLine("FizzBuzz");
            #else
                Console.WriteLine("Hello, World!");
            #endif
        }
    }
}

We can use keywords like if, elif, and endif to create a logical structure for compilation. We can also use operators like and (&&), or (||), and not (!) to create intricate scenarios.

Not commonly used, but we can also use the undef keyword to unregister a preprocessor directive.

#define FIZZ
#define BUZZ
#undef FIZZ
#undef BUZZ

using System;

namespace Changes
{
    internal class Program
    {
        public static void Main(string[] args)
        {
            #if (FIZZ && BUZZ)
                Console.WriteLine("FizzBuzz");
            #elif (FIZZ)
                Console.WriteLine("Fizz");
            #elif (BUZZ)
                Console.WriteLine("FizzBuzz");
            #else
                Console.WriteLine("Hello, World!");
            #endif
        }
    }
}

In the above example, its as if our symbols of FIZZ and BUZZ never existed. The undef keyword may be helpful to opt file out of a directive for debugging purposes.

Conclusion

Preprocessor directives are a powerful tool for removing code blocks from our compilation step. It can help us target multiple platforms and share a majority of our code between different audiences. Development platforms like Xamarin, Mono, and even .NET itself have used preprocessor directives to a high degree of success. The most recommended approach is to alter our project run configurations, but we have many options, as we can see. Finally, while this approach is powerful, many developers should consider a strategy outside of compilation, like feature flags, if they intend to toggle behavior during runtime.