What’s New in C# 10: Simplify Nested Property Pattern Code

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

Pattern matching in C# was first introduced in C# 7 and has been added to in later versions.

C# 8 added property pattern matching to allow you to match on the values of properties and fields. Prior to C# 10, property pattern matching with simple (non-nested) types was fine but if the thing you were matching was in a nested property the syntax was slightly clumsy:

public record CurrencyExchangeRate(string SourceCurrencyCode,
                                   string DestinationCurrencyCode,
                                   decimal ExchangeRate);

public record Trade(int CustomerId, CurrencyExchangeRate ExchangeRate);

In the preceding code we have a Trade that has a nested CurrencyExchangeRate, in C# 9 if we wanted to match on this nested CurrencyExchangeRate such as the SourceCurrencyCode, we’d have to use the following syntax:

public static bool IsRelatedToAustralia(Trade trade) =>
    trade is { ExchangeRate: { SourceCurrencyCode: "AUD" } } or
             { ExchangeRate: { DestinationCurrencyCode: "AUD" } };

Notice the extra nested {} to access the nested currency codes.

From C# 10 you can access nested properties directly which makes the code a little more readable, for example:

static bool IsRelatedToAustralia(Trade trade) =>
    trade is { ExchangeRate.SourceCurrencyCode: "AUD" } or
             { ExchangeRate.DestinationCurrencyCode: "AUD" };

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: Value Type Records

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

In a previous post I wrote about records in C# 9.Prior to C# 10 record types were reference types. In C# 10 you can now declare records as value types.

You declare a value record type by adding the struct keyword.

You may also add the readonly modifier if you want to create an immutable value type:

// struct modifier - this will create a value type (mutable)
public record struct CurrencyExchangeRate3(string SourceCurrencyCode,
                                                    string DestinationCurrencyCode,
                                                    decimal ExchangeRate);

// struct modifier (and readonly) - this will create a value type (immutable)
public readonly record struct CurrencyExchangeRate4(string SourceCurrencyCode,
                                                string DestinationCurrencyCode,
                                                decimal ExchangeRate);

If you don’t specify the struct modifier you will get a reference record. If you want to you can add the class modifier if you think it will make the code more readable:

// No modifier - this will be a reference type record
public record CurrencyExchangeRate1(string SourceCurrencyCode,
                                    string DestinationCurrencyCode,
                                    decimal ExchangeRate);


// Explicit class modifier - this will also be a reference type record
public record class CurrencyExchangeRate2(string SourceCurrencyCode,
                                            string DestinationCurrencyCode,
                                            decimal ExchangeRate);

All of the above examples use the positional syntax for defining the record properties.

Record Struct Equality

The default equality for record structs is the same for non-record structs:2 objects will be equal if they are both the same type and have the same values.

There is one key difference and that is how the default equality is implemented. With normal non-record structs, to determine equality reflection is used behind the scenes which can be slow. With record structs however reflection is not used, the equality code is synthesized by the compiler.

If we use a tool like DotPeek to decompile the Equals method we get the following:

public bool Equals(CurrencyExchangeRate3 other)
{
  // ISSUE: reference to a compiler-generated field
  // ISSUE: reference to a compiler-generated field
  // ISSUE: reference to a compiler-generated field
  // ISSUE: reference to a compiler-generated field
  if (EqualityComparer<string>.Default.Equals(this.\u003CSourceCurrencyCode\u003Ek__BackingField, other.\u003CSourceCurrencyCode\u003Ek__BackingField) && EqualityComparer<string>.Default.Equals(this.\u003CDestinationCurrencyCode\u003Ek__BackingField, other.\u003CDestinationCurrencyCode\u003Ek__BackingField))
  {
    // ISSUE: reference to a compiler-generated field
    // ISSUE: reference to a compiler-generated field
    return EqualityComparer<Decimal>.Default.Equals(this.\u003CExchangeRate\u003Ek__BackingField, other.\u003CExchangeRate\u003Ek__BackingField);
  }
  return false;
}

Notice the preceding code is not using reflection to determine if the data items are equal. This means is some situations a record struct may perform better that a standard struct. Check out this related article on struct performance I wrote.

Another difference between record class and record struct is that in class records you can write a custom copy constructor, for example one that always set the exchange rate to 0:

public record class CurrencyExchangeRate5(string SourceCurrencyCode,
                                          string DestinationCurrencyCode,
                                          decimal ExchangeRate)
    {
        // Copy constructor
        protected CurrencyExchangeRate5(CurrencyExchangeRate5 previous)
        {
            SourceCurrencyCode = previous.SourceCurrencyCode;
            DestinationCurrencyCode = previous.DestinationCurrencyCode;
            ExchangeRate = 0;
        }
    }
}

Now if you wrote: CurrencyExchangeRate6 f2 = f1 with { SourceCurrencyCode = "xyz" }; f2 would have it’s currency set to 0.

If you tried this with a record struct, the custom copy constructor won’t be called.

SHARE:

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:

ICYMI C# 9 New Features: Create Immutable Objects with Records

This is part of a series of articles on new features introduced in C# 9.

C# 9 introduced a new type of object that is neither a class or a struct. This new type is called a  record.

In C# 9 a record is a reference type that has value type equality semantics (more on this below).

The main purpose of defining record types is to indicate immutability for a type that is “data-centric” or in other words does not have rich behaviour (such as data transfer objects, database records, etc).

How to Define a Record in C# 9

To define a record type you use the record keyword:

record Message1
{
    public int Priority { get; set; }
    public string MessageBody { get; set; }
}

We could now create an instance and then write it to the console window:

var m1 = new Message1();
m1.Priority = 1;
m1.MessageBody = "Hi";

Console.WriteLine(m1);

This would produce the following output:

Message1 { Priority = 1, MessageBody = Hi }
Console.WriteLine automatically calls ToString() on the object passed to it, notice that we get built-in ToString() formatting support for all record types.

 

Notice in the preceding code that we are able to set Priority and MessageBody even after we have created the object – this is not immutable behaviour. To make a record immutable when declaring properties manually (see positional records below) you need to make the property setter init only:

record Message2
{
    public int Priority { get; init; }
    public string MessageBody { get; init; }
}

Now if you try and write the following code you’ll get a compiler error (“Init-only property or indexer … can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor”):

var m2 = new Message2();
m2.Priority = 2;
m2.MessageBody = "Hey there!";

To create Message2 instances you now need to set the properties when you create it, for example:

var m2 = new Message2()
{
    Priority = 2,
    MessageBody = "Hey there!"
};
You can also add constructors to record types if you want to.

What Are Positional Records in C#?

Positional records are a shorthand syntax for defining C# records. Behind the scenes they create init-only properties.

We could define a message class that is essentially the same as Message2 above with the following syntax:

record Message3(int Priority, string MessageBody);

Now we could create one with the following syntax:

var m3 = new Message3(3, "Good day sir!");

Or if you wanted to be explicit:

var m3 = new Message3(Priority: 3, MessageBody: "Good day sir!");

Even though behind the scenes we’re getting init-only properties, when you define a positional record you can’t use the following syntax:

var m3 = new Message3() // error missing arguments
{
    Priority = 3,
    MessageBody = "Good day sir!"
};

You can think of positional records as a shorthand syntax that creates init only properties and a parameterized constructor automatically behind the scenes.

Equality

Records have value-like equality semantics:

Record instances in C# 9 by default are considered equal if they store the same values and are of the same record type:

var m3a = new Message3(Priority: 3, MessageBody: "Good day sir!");
var m3b = new Message3(Priority: 3, MessageBody: "Good day sir!");
var m3c = new Message3(Priority: 3, MessageBody: "BOO!");

Console.WriteLine($"m3a == m3b : {m3a == m3b}"); // Outputs: TRUE
Console.WriteLine($"m3b == m3c : {m3b == m3c}"); // Outputs: FALSE

If you tried to compare a Message3 object with a Message2 object you’ll get a compiler error.

If you want to, you can override things like Object.Equals in a record.

Note: C# 10  will be introducing record structs .

Immutability of Record Types

One thing to be aware of is that immutability of records types is “shallow” for properties that are reference types.

In other words while you can’t change the value of a value type property you can change the properties of reference type properties in a record:

var m4 = new Message4(4, new[] { "Dear sir", "Good to see you.", "Good bye." });
Console.WriteLine(m4.MessageLines[0]); // OUTPUTS: Dear sir

m4.MessageLines[0] = "Yo yo!"; // NO COMPILER ERROR
Console.WriteLine(m4.MessageLines[0]); // OUTPUTS: Yo Yo!

m4.MessageLines = new[]; // ERROR MessageLines property object reference itself IS immutable

You can create a new immutable record object based on an existing immutable instance:

var one = new Message3(Priority: 3, MessageBody: "Good day sir!");
var two = one; 

Object two is a copy of one.

Note: record copies are “shallow” - any value type properties will have the value copied, but any reference type properties will only have the reference copied. That means that 2 record instances can have reference type properties that point to the same object. You change the object they point to and both records will be “updated” with (point to) the new value (because they share the reference to the same object in memory).

If a record is immutable, you can “update” it by creating a copy of it, and update some properties as required during the “copy” – you do this using the with keyword. For example to “update” the priority of an immutable record:

var priority3Message = new Message3(Priority: 3, MessageBody: "Good day sir!");
var priority1Message = priority3Message with { Priority = 1 };

As before, if you create a copy and use with a shallow copy is still created.

Custom C# Record Output Formatting

When you declare a record, under the hood a PrintMembers method is generated. You can also provide your own:

record Message5(int Priority, string[] MessageLines)
{
    protected virtual bool PrintMembers(StringBuilder builder)
    {
        builder.Append($"P:{Priority}");
        for (int i = 0; i < MessageLines.Length; i++)
        {
            builder.Append($" {MessageLines[i]} ");
        }

        return true;
    }
}

Now the following code:

var m5 = new Message5(5, new[] { "Dear sir", "Good to see you.", "Good bye." });

Console.WriteLine(m5);

Would output:

Message5 { P:5 Dear sir  Good to see you.  Good bye.  }

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:

New Pluralsight Course Update: Working with Nulls in C#

A new version of my Working with Nulls in C# Pluralsight course was just released. This new version features updated demos in .NET 5 & C# 9 plus a brand new module has been added.

The course contains the following modules:

  • Working with Nullable Value Types and Strings
  • Accessing and Checking for Null Values
  • Eliminating Null Reference Exceptions with the Null Object Pattern
  • Understanding Nullable and Non-nullable Reference Types
  • Using Additional Attributes to Describe Nullability

From the course description: “Making use of an object when it is not supposed to be null can result in unexpected exceptions that can cause your program to crash. These unexpected null related errors may cause data loss or corruption, system downtime, and unhappy users. In this course, Working with Nulls in C#, you’ll gain the ability to reduce the likelihood of getting null related exceptions in production. First, you’ll explore which objects can be set to null and how to check for null values. Next, you’ll discover a design pattern to help eliminate null related exceptions. Finally, you’ll learn how to opt-in to the ability to create non-nullable reference types that enable the compiler to detect null-related problems before you even run your application. When you’re finished with this course, you’ll have the skills and knowledge of nulls in C# needed to reduce null related errors in your production code.”

You can start watching with a Pluralsight free trial.

SHARE: