What’s New in C# 10: Take Control of Interpolated String Handling

This is part of a series on the new features introduced with C# 10.

In C# you can create an interpolated string such as: $"{DateTime.Now}: starting..."

The compiler will transform this to a single string instance using a call to String.Format or String.Concat.

Starting with C# 10 you can override this behaviour if you want more control such as:

  • Not interpolating the sting for performance reasons if it won’t be used
  • Limiting the length of resulting interpolated strings
  • Enforcing custom formatting of interpolated strings
  • Etc.

Take the following simple logging class:

// Simplified implementation
public static class SimpleConsoleLogger
{
    public static bool IsLoggingEnabled { get; set; }

    public static void Log(string message)
    {
        if (IsLoggingEnabled)
        {
            Console.WriteLine(message);
        }            
    }
}

We could call this as follows:

SimpleConsoleLogger.IsLoggingEnabled = true;
SimpleConsoleLogger.Log($"{DateTime.Now}: starting...");
SimpleConsoleLogger.IsLoggingEnabled = false;
SimpleConsoleLogger.Log($"{DateTime.Now}: ending...");

The second call (SimpleConsoleLogger.Log($"{DateTime.Now}: ending...");) won’t output a log message because IsLoggingEnabled is false, however the interpolation of the string $"{DateTime.Now}: ending..." will still take place.

Ideally if logging is not enabled we would not even want to bother interpolating the string. This could improve the performance of the application if logging was identified as a problem.

We can do this by taking control of when (or if) an interpolated string is processed by:

  • Applying the System.Runtime.CompilerServices.InterpolatedStringHandler attribute to a custom handler
  • Creating a constructor with int parameters: (int literalLength, int formattedCount)
  • Adding a public AppendLiteral method
  • Adding a generic public AppendFormatted method

Within this custom handler you can decide how to turn the interpolated string into a single string instance, for example by using a StringBuilder. In the code you could also enforce any custom formatting/length restrictions that are required.

The following code shows a simple example using a StringBuilder:

using System.Runtime.CompilerServices;
using System.Text;

namespace ConsoleApp1
{
    [InterpolatedStringHandler]
    public ref struct LogMessageInterpolatedStringHandler
    {
        readonly StringBuilder logMessageStringbuilder;
     
        public LogMessageInterpolatedStringHandler(int literalLength, int formattedCount)
        {
            logMessageStringbuilder = new StringBuilder(literalLength);
        }

        public void AppendLiteral(string s)
        {
            // For demo purposes
            Console.WriteLine($"AppendLiteral called for '{s}'");

            logMessageStringbuilder.Append(s);
        }

        public void AppendFormatted<T>(T t)
        {
            // For demo purposes
            Console.WriteLine($"AppendFormatted called for '{t}'");

            logMessageStringbuilder.Append(t?.ToString());
        }

        public string BuildMessage() => logMessageStringbuilder.ToString();
    }
}

To make the logging class use this we can add another overload of the log method that instead of a string takes a LogMessageInterpolatedStringHandler:

public static void Log(LogMessageInterpolatedStringHandler logMessageBuilder)
{
    if (IsLoggingEnabled)
    {
        Console.WriteLine("...interpolating message because logging is enabled...");
        Console.WriteLine(logMessageBuilder.BuildMessage());
    }
    else
    {
        Console.WriteLine("...NOT interpolating message because logging is disabled...");
    }
}

Now if Log is called with a non-interpolated string like "Hello - this is not an interpolated string" the original log method will be used.

If the Log method is called with an interpolated string, the custom handler will be invoked (if we choose to invoke it). For example, if logging is disabled we don’t even need to call the handler to build the final log message:

public static void Log(LogMessageInterpolatedStringHandler logMessageBuilder)
{
    if (IsLoggingEnabled)
    {
        Console.WriteLine("...interpolating message because logging is enabled...");
        Console.WriteLine(logMessageBuilder.BuildMessage());
    }
    else
    {
        Console.WriteLine("...NOT interpolating message because logging is disabled...");
    }
}

The final code looks like this:

namespace ConsoleApp1
{
    // Simplified implementation
    public static class SimpleConsoleLogger
    {
        public static bool IsLoggingEnabled { get; set; }

        public static void Log(string message)
        {
            Console.WriteLine("...may have already interpolated the message...");

            if (IsLoggingEnabled)
            {
                Console.WriteLine(message);
            }            
        }

        public static void Log(LogMessageInterpolatedStringHandler logMessageBuilder)
        {
            if (IsLoggingEnabled)
            {
                Console.WriteLine("...interpolating message because logging is enabled...");
                Console.WriteLine(logMessageBuilder.BuildMessage());
            }
            else
            {
                Console.WriteLine("...NOT interpolating message because logging is disabled...");
            }
        }

    }
}


using System.Runtime.CompilerServices;
using System.Text;

namespace ConsoleApp1
{
    [InterpolatedStringHandler]
    public ref struct LogMessageInterpolatedStringHandler
    {
        readonly StringBuilder logMessageStringbuilder;
     
        public LogMessageInterpolatedStringHandler(int literalLength, int formattedCount)
        {
            logMessageStringbuilder = new StringBuilder(literalLength);
        }

        public void AppendLiteral(string s)
        {
            // For demo purposes
            Console.WriteLine($"AppendLiteral called for '{s}'");

            logMessageStringbuilder.Append(s);
        }

        public void AppendFormatted<T>(T t)
        {
            // For demo purposes
            Console.WriteLine($"AppendFormatted called for '{t}'");

            logMessageStringbuilder.Append(t?.ToString());
        }

        public string BuildMessage() => logMessageStringbuilder.ToString();
    }
}


SimpleConsoleLogger.IsLoggingEnabled = true;
SimpleConsoleLogger.Log($"{DateTime.Now}: starting...");
SimpleConsoleLogger.Log("Hello - this is not an interpolated string");
SimpleConsoleLogger.IsLoggingEnabled = false;
SimpleConsoleLogger.Log($"{DateTime.Now}: ending...");

And if we run this:

AppendFormatted called for '30/11/2021 11:52:02 AM'
AppendLiteral called for ': starting...'
...interpolating message because logging is enabled...
30/11/2021 11:52:02 AM: starting...
...may have already interpolated the message...
Hello - this is not an interpolated string
AppendFormatted called for '30/11/2021 11:52:02 AM'
AppendLiteral called for ': ending...'
...NOT interpolating message because logging is disabled...

Now the performance overhead of interpolating the strings will only happen if logging is enabled.

There’s a much more in-depth tutorial in the docs.

If you want to fill in the gaps in your C# knowledge be sure to check out my C# Tips and Traps training course from Pluralsight – get started with a free trial.

SHARE:

What’s New in C# 10: Easier Lambda Expressions

This is part of a series on the new features introduced with C# 10.

Prior to C# 10, working with lambda expressions required a bit more code to be written, for example to explicitly define the delegate type such as Action<T> or Func<T>:

Action<string, ConsoleColor> writeWithColor = (string s, ConsoleColor color) =>
{
    var originalColor = Console.ForegroundColor;
    Console.ForegroundColor = color;
    Console.WriteLine(s);
    Console.ForegroundColor = originalColor;
};

Func<string, string> upper = (string s) => s.ToUpperInvariant();

writeWithColor("Hello", ConsoleColor.Cyan);
Console.WriteLine(upper("This should be default color"));
writeWithColor("Bye", ConsoleColor.Yellow);

Console.ReadLine();

Notice in the preceding code the lambda statement writeWithColor and the lambda expression upper both need explicit delegate types: Action<string, ConsoleColor> and Func<string, string>

From C# 10 we can make use of the new feature of “natural” lambda expression types.

This “natural type” is inferred by the compiler when it can, this means in C# we could just use var: var writeWithColor = (string s, ConsoleColor color) => etc. and var upper = (string s) => s.ToUpperInvariant();

This natural type inference will not always be possible, for example when you haven’t defined lambda parameter types like: var upper = (s) => s.ToUpperInvariant(); If you tried to compile this line of code you would get: Error    CS8917    The delegate type could not be inferred.

From C# 10, you can specify an explicit return type for a lambda expression where the compiler can’t work it out for you. You add the return type before the lambda parenthesis:

//Error CS8917 The delegate type could not be inferred
var createException = (bool b) => b ? new ArgumentNullException() : new DivideByZeroException();

// No error
var createException = Exception (bool b) => b ? new ArgumentNullException() : new DivideByZeroException();

You can also sometimes benefit from natural types for method groups:

// C#9
Func getUserInput = Console.ReadLine;
Action tellUser = (string s) => Console.WriteLine(s);
Func waitForEnter = Console.ReadLine;

tellUser("Please enter name");
var name = getUserInput();
tellUser($"Your name is {name}");
waitForEnter();

From C# 10 we could just use var:

// C#10
var getUserInput = Console.ReadLine;
var tellUser = (string s) => Console.WriteLine(s);
var waitForEnter = Console.ReadLine;

tellUser("Please enter name");
var name = getUserInput();
tellUser($"Your name is {name}");
waitForEnter();

You can’t however write: var write = Console.Write; because the Write method has multiple overloads so the compiler doesn’t know which one to choose.

SHARE:

What’s New in C# 10: New Possibilities for Validation and Logging Methods

This is part of a series on the new features introduced with C# 10.

From C# 10 we can make use of the [CallerArgumentExpression] attribute.

This attribute can be applied to a parameter to get information about another specified parameter in the method.

Take the following method as an example:

static bool ValidInput(string? inputFromUser,
                string inputDataName,
                bool validationCondition,
                out string? validationErrorMessage,
                [CallerArgumentExpression("validationCondition")] string? validationConditionText = null)
{
    if (validationCondition)
    {
        validationErrorMessage = null;
        return true;
    }

    validationErrorMessage = $"input '{inputFromUser ?? "null"}' from user for {inputDataName} is invalid because '{validationConditionText}'";
    return false;
}

In this method the validationConditionText argument has the [CallerArgumentExpression] applied.

When you use the [CallerArgumentExpression] attribute you need to supply a single constructor parameter. This is a string parameter that specifies which of the other parameters we want to capture information about. In this case it’s the bool validationCondition parameter.

We could make use of this method in a console application:

using System.Runtime.CompilerServices;
using static System.Console;

WriteLine("Please enter your user name");
string? userName = ReadLine();

WriteLine("Please enter your age");
string? age = ReadLine();

string? validationErrorMessage;

if (!ValidInput(userName,
                inputDataName: "user name",
                validationCondition: userName is not null,
                validationErrorMessage: out validationErrorMessage))
{
    WriteLine(validationErrorMessage);
}

if (!ValidInput(inputFromUser: age,
                inputDataName: "age",
                validationCondition: age is not null && int.TryParse(age, out _),
                validationErrorMessage: out validationErrorMessage))
{
    WriteLine(validationErrorMessage);
}

ReadLine();

Every time we call the ValidInput method, we pass a Boolean expression that needs to be satisfied for the input to be recognized as valid, for example: userName is not null.

If we ran the console app and entered a null for user name and some non numeric input for age:

Please enter your user name
^Z
Please enter your age
aaa
input 'null' from user for user name is invalid because 'userName is not null'
input 'aaa' from user for age is invalid because 'age is not null && int.TryParse(age, out _)'

Notice the two validation error messages output contain the boolean expression used in the source code: userName is not null and age is not null && int.TryParse(age, out _).

The [CallerArgumentExpression] attribute pulls out those expressions and lets us access them as strings to be used at runtime.

This kind of user validation is not the primary intended use case for this attribute as telling an end user 'age is not null && int.TryParse(age, out _)' is not very helpful or user friendly, however the example above illustrates the possibilities. This approach could still be used with a more generic error message given to the user and a more detailed one written to logs/traces. The Microsoft documentation states: “Diagnostic libraries may want to provide more details about the expressions passed to arguments. By providing the expression that triggered the diagnostic, in addition to the parameter name, developers have more details about the condition that triggered the diagnostic. That extra information makes it easier to fix.”

If you want to fill in the gaps in your C# knowledge be sure to check out my C# Tips and Traps training course from Pluralsight – get started with a free trial.

SHARE:

What’s New in C# 10: Write Less Code and Reduce Repeated Using Directives

This is part of a series on the new features introduced with C# 10.

There are 2 related features in C# 10 that will reduce the clutter of repetitive using directives: global using directives and implicit global using directives.

C# 10 Global Usings

At the top of every code file you will usually find a number of using directives:

using ConsoleApp1.Configuration;

namespace ConsoleApp1
{
    internal class Calculator
    {
        public int Add(int a, int b)
        {
            if (CalculatorConfiguration.SomeConfigProperty)
            {
                // etc.
            }
            return a + b;
        }
    }
}

In the preceding code there is a single using ConsoleApp1.Configuration; to get access to a CalculatorConfiguration class.

For one file this is not much overhead in terms of repeated code.

Imagine however if the CalculatorConfiguration class was referenced in 100’s of code files throughout the project. This would mean we’d have 100 using ConsoleApp1.Configuration; lines throughout the project.

As an alternative you can use the new global using declarations. To use these just prefix the directive with global, for example: global using ConsoleApp1.Configuration;

Now all code files in the project will act as if though they have a using ConsoleApp1.Configuration; at the top of them. You only need one global using directive for any given namespace in the project.

You can add global usings to any code file, however it makes sense to centralize them. For example you could create a GlobalUsings.cs file in the project and inside that just have all your global using directives.

C# 10 Implicit Global Usings

If you create a new C# 10 project (e.g. a console app) and open the project file you’ll see a line: <ImplicitUsings>enable</ImplicitUsings>

This enables the new implicit global usings feature.

When this feature is enabled, a number of commonly used namespaces will automatically have global usings directives added for them.

For a Console project the following will be automatically added:

global using global::System;
global using global::System.Collections.Generic;
global using global::System.IO;
global using global::System.Linq;
global using global::System.Net.Http;
global using global::System.Threading;
global using global::System.Threading.Tasks;

This means for example you you could create a List<string> anywhere in the project without needing to add a using System.Collections.Generic; to the top of your code files.

Implicit global usings work behind the scenes by generating a file.

Assuming you had a console app called ConsoleApp1, you would find this generated file as follows: "ConsoleApp1\obj\Debug\net6.0\ConsoleApp1.GlobalUsings.g.cs".

If you opened this file you’d see the global usings that are implicitly added to your project.

If you don’t like the idea of implicit global usings you can opt out for new projects by making the following change in the project file: <ImplicitUsings>disable</ImplicitUsings>

SHARE:

What’s New in C# 10: Simplify Argument Null Checking Code

This is part of a series on the new features introduced with C# 10.

Prior to C# 10 you may have had code similar to the following:

public static string Join(string a, string b)
{            
    if (a is null)
    {
        throw new ArgumentNullException(nameof(a));
    }

    if (b is null)
    {
        throw new ArgumentNullException(nameof(b));
    }

    return a + b;
}

If the parameters a or b are null then an ArgumentNullException will be thrown.

The nameof operator will create a string from the parameter name so the exception will contain information about what parameter was null.

One potential problem with this code is that it is easier to reference the incorrect parameter, for example:

if (a is null)
{
    throw new ArgumentNullException(nameof(b));
}

The if statement is checking a but the nameof is referencing b.

C# 10 (.NET 6) introduces an improved way:

public static string Join(string a, string b)
{
    ArgumentNullException.ThrowIfNull(a);
    ArgumentNullException.ThrowIfNull(b);

    return a + b;
}

In the preceding code, a new static method called ThrowIfNull has been added on the ArgumentNullException class and allows us to quickly check and throw ArgumentNullExceptions.

Notice in this new version of the code we do not need to use the nameof operator, the parameter name will “magically” be worked out for us if an exception is thrown.

As an example, take the following code that passes a null to parameter a:

try
{
    SomeClass.Join(null, "bbbb");
}
catch (Exception ex)
{
    Console.WriteLine(ex);
}

If we run this we’ll get the following console output:

System.ArgumentNullException: Value cannot be null. (Parameter 'a')
   at System.ArgumentNullException.Throw(String paramName)
   at System.ArgumentNullException.ThrowIfNull(Object argument, String paramName)
   at ConsoleApp1.SomeClass.Join(String a, String b)

Notice the message contains a reference to the parameter named ‘a’ automatically.(Behind the scenes this is due to the new [CallerArgumentExpression] attribute – but you don’t need to know about the implementation details to make use of the ThrowIfNull method.

If you want to fill in the gaps in your C# knowledge be sure to check out my C# Tips and Traps training course from Pluralsight – get started with a free trial.

SHARE:

What’s New in C# 10: Reclaim Horizontal Editing Space and Simplify Nesting

This is the first part in a series on the new features introduced with C# 10.

Prior to C# 10, to define types as being part of a specific namespace you would use the following syntax:

namespace ConsoleAppCS9
{
    public class SomeClass
    {
        public void SomeMethod(bool b, string s)
        {
            if (b is true)
            {
                if (s is not null)
                {
                    // do something
                }
            }
        }
    }
    namespace SomeNestedNamespace
    {
        public class AClassInANestedNamespace
        {
            // etc.
        }
    }
}

 

With C# 10 we can use something called file-scoped namespace declarations.

All this means is that we can remove one level of nesting in { } brackets:

namespace ConsoleAppCS9;

public class SomeClass
{
    public void SomeMethod(bool b, string s)
    {
        if (b is true)
        {
            if (s is not null)
            {
                // do something
            }
        }
    }
}

The line namespace ConsoleAppCS9; means that any types defined in that one file will be inside the ConsoleAppCS9 namespace.

This has the effect in the editor of giving us more horizontal editing space and nesting to keep mental track of.

Also note in the second example that you can’t have nested namespaces in the file if file-scoped namespace declarations are being used – in this case you’d have to return to the traditional namespace syntax.

If you want to fill in the gaps in your C# knowledge be sure to check out my C# Tips and Traps training course from Pluralsight – get started with a free trial.

SHARE: