blog post image
Andrew Lock avatar

Andrew Lock

~6 min read

[CallerArgumentExpression] and throw helpers

Exploring .NET 6 - Part 11

In this post I describe a new feature in C# 10, the [CallerArgumentExpression] attribute, how it can be used to create "throw helpers", and the addition of a throw helper to ArgumentNullException.

[Caller*] attributes in C# 5

There have been three [Caller*] attributes available in .NET since C# 5 (back in the .NET Framework 4.5 days!):

  • [CallerMemberName]
  • [CallerFilePath]
  • [CallerLineNumber]

You can apply these attributes to method arguments, give them a default value, and they will be "magically" populated by the compiler, passing the name of the caller's method/property, as well as the file name and the line number. For example you could create a method like this:

public static class ErrorLog
{
    public static void Record(
        string message, 
        [CallerMemberName] string caller = null, // You must make these optional
        [CallerFilePath] string filePath = null, // by providing a default value.
        [CallerLineNumber] int lineNumber = 0) // This is replaced at compile time
    {
        // ..
    }
}

You would call this from your code, without passing a value for the optional parameters:

public void SomeMethod()
{
    // ..
    ErrorLog.Record("Unexpected occurence"); // don't pass the optional attributes.
}

At compile-time this method is essentially re-written to something like the following:

public void SomeMethod()
{
    // ..
    ErrorLog.Record(
        "Unexpected occurence",
        "SomeMethod",
        "c:\repos\myproject\Program.cs",
        31);
}

There's a lot of different ways you can use these attributes, but some of the most common are for logging/tracing and implementing INotifyPropertyChanged. In C# 10, we get another [Caller*] attribute to add to the collection: [CallerArgumentExpression].

Introducing [CallerArgumentExpression]

[CallerArgumentExpression] is an interesting attribute. The easiest way to understand it, is to see it in action, so let's start with the following simple helper:

public class Verify
{
    public void IsTrue(bool value, 
        [CallerArgumentExpression("value")] string expression = null)
    {
        if(!value) 
          Throw(expression);
    }

    private static void Throw(string expression)
        => throw new ArgumentException($"{expression} must be True, but was False");
}

The Verify.IsTrue() method takes a single Boolean value; if the value is false, it throws an ArgumentExpression. As with the other [Caller*] attributes, the [CallerArgumentExpression] is applied to an optional argument. To see the effect of the attribute, let's call the function in an example:

public void SomeExample(int value, string someString)
{
    Verify.IsTrue(value > 0);
    Verify.IsTrue(someString == "TEST");
}

At compile time, the compiler will replace the optional argument with:

public void SomeExample(int value, string someString)
{
    Verify.IsTrue(value > 0, "value > 0");
    Verify.IsTrue(someString == "TEST", "someString == \"TEST\"");
}

As you can see in the above example, the whole expression is turned into a string, and passed in the [CallerArgumentExpression] parameter. Not the result of the expression, the actual expression itself.

Also note that you had to pass the name of the argument that you want to capture to the attribute, in this case [CallerArgumentExpression("value")]. Unfortunately, you can't use nameof() here, as the parameter names are out of scope for the attribute, so you're stuck with magic strings for now. Luckily tools like Rider can help with this!

Rider will tell you if the parameter you've entered is wrong

Some of the most obvious use cases for [CallerArgumentExpression] are in Assertion/Verification libraries, whether that's for the purposes of argument validation in your application, or in assertions for test libraries. They can also be used in so called "throw helpers".

Throw helpers in C#

Throw helpers are classes similar to the Verify example I showed above, whose purposes is to throw an exception, sometimes conditionally. These throw helpers have a few advantages over throwing an exception inline:

  • They provides a central location for managing the resources associated with an exception. This can be useful if you need to localise your exception messages, or you want to throw an exception with the same message from multiple locations.
  • They move code unrelated to the core of a method elsewhere. This may improve the readability of the method.
  • They can improve performance by making the method inline-able, and reducing the overall assembly code size.

The first two points are fairly self explanatory. For example, imagine a method that "processes" an array i some way. It would be very common to see guard clauses at the start of the method which checks that the arguments are valid:

public void Process(string[] array, int start)
{
    if(array == null)
    {
        throw new ArgumentNullException(nameof(array));
    }

    if(start < 0 || start >= array.Length)
    {
        throw new ArgumentOutOfRangeException(nameof(start));
    }

    // ...
}

That's a lot of lines of code that don't have anything much to do with the actual body of the method. And in fact, if you look at the assembly code the above results in, you end up with a lot of instructions for just the precondition checks:

.method public hidebysig 
        instance void Process (
            string[] 'array',
            int32 start
        ) cil managed 
    {
        .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
            01 00 01 00 00
        )
        // Method begins at RVA 0x208e
        // Code size 36 (0x24)
        .maxstack 8

        IL_0000: ldarg.1
        IL_0001: brtrue.s IL_000e

        IL_0003: ldstr "array"
        IL_0008: newobj instance void [System.Private.CoreLib]System.ArgumentNullException::.ctor(string)
        IL_000d: throw

        IL_000e: ldarg.2
        IL_000f: ldc.i4.0
        IL_0010: blt.s IL_0018

        IL_0012: ldarg.2
        IL_0013: ldarg.1
        IL_0014: ldlen
        IL_0015: conv.i4
        IL_0016: blt.s IL_0023

        IL_0018: ldstr "start"
        IL_001d: newobj instance void [System.Private.CoreLib]System.ArgumentOutOfRangeException::.ctor(string)
        IL_0022: throw

        IL_0023: ret
    } // end of method C::Process

Contrast that with how using throw helpers helps to reduce the number of lines of code you have dedicated to the precondition checks:

public void Process(string[] array, int start)
{
    ThrowIfNull(array);
    ThrowIfOutOfRange(start, 0, array.Length);
}

The example above is very basic, and doesn't necessarily showcase the best case for simplifiying the precondition checks, but it does produce smaller IL:

    // Methods
    .method public hidebysig 
        instance void Process (
            string[] 'array',
            int32 start
        ) cil managed 
    {
        // Method begins at RVA 0x208e
        // Code size 27 (0x1b)
        .maxstack 8

        IL_0000: ldarg.1
        IL_0001: ldstr "array"
        IL_0006: call void C::ThrowIfNull(object, string)
        IL_000b: ldarg.2
        IL_000c: ldc.i4.0
        IL_000d: ldarg.1
        IL_000e: ldlen
        IL_000f: conv.i4
        IL_0010: ldstr "array"
        IL_0015: call void C::ThrowIfOutOfRange(int32, int32, int32, string)
        IL_001a: ret
    } // end of method C::Process

Now, you might be thinking "why do I care how much IL is in a method?" The most important reason is that the amount of code in a method affects the chance that it will be inlined. Method inlining can have a big impact on performance, and it can enable a slew of additional optimisations.

Back in .NET Core 2.0, the JIT added support for not inlining methods that never return. So moving the throw to a dedicated method which will not be inlined, increases the chance that your method will be inlined. Given the exception should rarely be thrown, this should have a generally positive performance, and is a common optimisation to see in very hot paths and performance-sensitive libraries.

ArgumentNullException.Throw()

All of which brings us to a new addition in .NET 6; the ArgumentNullException throw helper! This new static method on ArgumentNullException uses the new [CallerArgumentExpression] attribute and the [DoesNotReturn] attribute to provide an easy-to-use throw helper for null values:

public class ArgumentNullException
{
    public static void ThrowIfNull([NotNull] object? argument, [CallerArgumentExpression("argument")] string? paramName = null)
    {
        if (argument is null)
        {
            Throw(paramName);
        }
    }

    [DoesNotReturn]
    private static void Throw(string? paramName) =>
        throw new ArgumentNullException(paramName);
}

To use this throw helper, simply pass the argument to test in your method:

public void Process(string[] array, int start)
{
    ArgumentNullException.ThrowIfNull(array);
}

The [CallerArgumentExpression] means you don't need to use nameof(array) or similar, and it takes care of optimising your code for inlining!

If you're still stuck pre-.NET 6, or are multi-targeting for earlier versions of .NET, but are using C# 10, you could get the same benefits by making your own equivalent throw helper:

public static class ThrowHelper
{
    public static void ThrowIfNull([NotNull] object? argument, [CallerArgumentExpression("argument")] string? paramName = null)
    {
        if (argument is null)
        {
            Throw(paramName);
        }
    }
    
    [DoesNotReturn]
    private static void Throw(string? paramName) 
        => throw new ArgumentNullException(paramName);
}

Depending on the version of .NET you're targeting (if you're < .NET Core 3.0), you may also need to define the [DoesNotReturn] attribute in your project, in the System.Diagnostics.CodeAnalysis namespace. The compiler will treat it the same as the attribute that's "built-in" in later versions of the framework:

#if !NETCOREAPP3_0_OR_GREATER
namespace System.Diagnostics.CodeAnalysis
{
    [AttributeUsage(AttributeTargets.Method)]
    public class DoesNotReturnAttribute: Attribute { }
}
#endif

You can then use this with [CallerArgumentExpression] for all your throw helpers, and give your code the chance of a performance improvement!

Summary

In this post I described the [CallerArgumentExpression] introduced in C# 10, showed its similarity to the other [Caller*] attributes from C# 5, and showed how it could be used with throw helpers and assertions to provide more detailed error messages with less boilerplate. I then described why throw helpers are useful, both from an ergonomic and performance point of view, and introduced the new ArgumentNullException throw helper introduced in .NET 6.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?