Record Structs and Property Initializers


C# v10.0—​released with .NET 6 in 2021—​introduced a couple of new features that were somewhat subdued in their respective announcements: record structs, and automatic property initializers for value types. Combining the two things in a simple type is, however, not so simple.

Record structs are the value type (struct-based) equivalent of records (class-based reference types with value-like equality semantics) that were introduced in C# v9.0 with .NET 5. Using positional syntax with either records or record structs makes defining simple types compact and convenient. Here’s an example of a positional record struct to represent a UK postal address:

public readonly record struct Address(string House, string PostCode);

The compiler translates this positional syntax to a struct with a read-only property for each positional argument, and a constructor taking those parameters to initialize the properties. Since a record struct is really just a normal struct when it’s compiled, a default-initialized instance will have null for any reference type properties. That means both properties of a default-initialized Address will be null. Classes have been able to use automatic property initializers since C# v6.0 to address problems like this by allowing automatic properties to be given a default value. From C# v10.0, this syntax is also permitted for record structs and normal structs:

public readonly record struct Address(string House, string PostCode)
{
    public string House { get; } = "";
    public string PostCode { get; } = "";
}

Here we define our own House and PostCode properties (inhibiting the compiler from generating them from the Address type’s positional parameters) and use the property initializer to assign an empty string as the default value for each property. The intention of using the property initializers is to try to prevent null values for those properties when an Address is default-initialized, like this:

var defaultAddress = new Address();
Assert.That(defaultAddress.House, Is.Not.Null);
Assert.That(defaultAddress.PostCode, Is.Not.Null);

The property initializers in the Address type are valid since C# v10.0, but unfortunately, this test doesn’t pass.

1. Initialization Order

The problem here is that positional parameters and property initializers don’t mix. Property initializers are part of object construction, so the initializers are only applied when we call a constructor. In the example, the defaultAddress variable is default-initialized, meaning that no constructor call occurs, and thus the property initializers are never applied.

Since the compiler uses the positional parameters to generate a constructor for our record struct, if we use that constructor to create an object, the property initializers are indeed applied:

var address = new Address(House: "1", PostCode: "2");

Assert.That(address.House, Is.Not.Null);
Assert.That(address.PostCode, Is.Not.Null);

The named arguments used to create the address variable aren’t mandatory, but they emphasize how the arguments are applied to the positional parameters (or rather, the constructor parameters created by the compiler). This test passes, but hides a deeper problem: the property initializers have been applied to the properties, but the arguments we passed to the constructor have not! Neither of these tests pass:

Assert.That(address.House, Is.EqualTo("1"));
Assert.That(address.PostCode, Is.EqualTo("2"));

The compiler-generated constructor hasn’t initialized the properties from its parameter values. This latter behaviour applies equally to record types. The first problem with default initialization doesn’t apply to records. Records are reference types, which have a default constructor inserted by the compiler if no other constructors are defined. Since the compiler uses the positional parameters to create a constructor (called the primary constructor), the default constructor is inhibited, with the result that creating a new object without arguments would fail to compile.

For both records and record structs, however, the primary constructor will only use its parameter values to initialize properties generated by the compiler; if we define any property of our own, even if it has the same name as a positional parameter, it is not initialized by the primary constructor.

Did I mention that positional parameters and property initializers don’t mix?

2. Requiring Properties to be Initialized

Our initial problem was that the default values for the string properties of Address would be null in a default-initialized instance. There are a couple of ways to address this—​at least for most common cases—​but no perfect solutions.

Since C# v11.0 we can force the user to assign a value to the properties by using the required keyword to modify the property definitions like this:

public readonly record struct Address
{
    public required string House { get; init; }
    public required string PostCode { get; init; }
}

Note that we’ve added an init accessor for the properties, enabling object initialization for Address objects. The compiler will reject the use of required without either a public init or set accessor, and init means an Address is immutable once it’s been created.

This doesn’t prevent the user from assigning null (although we could use the nullable reference type feature available since C# v8.0 to warn them), but we no longer need property initializers. These tests all pass:

var address = new Address {
    House = "",
    PostCode = ""
};

Assert.That(address.House, Is.Not.Null);
Assert.That(address.PostCode, Is.Not.Null);

address = new Address {
    House = "1",
    PostCode = "2"
};

Assert.That(address.House, Is.EqualTo("1"));
Assert.That(address.PostCode, Is.EqualTo("2"));

Note that we’re no longer using positional syntax for Address. We might have used a plain struct here, although using a record struct brings other benefits, but the required keyword means Address objects must be created using object initialization: the primary constructor for a positional record struct won’t initialize our custom properties, which is why Address doesn’t use the positional syntax. We can revert to a positional record struct, and even make Address slightly simpler, while keeping the same guarantees we’ve realized by using the required modifier.

3. The Syntaxy Solution

Prior to C# v10.0, defining a parameterless constructor for a value type wasn’t allowed. Constructing an instance of a struct type without arguments always used the built-in default-initialization, which always sets its fields (including property backing fields) to a pattern of all-zero bits—​essentially, either 0 or null, depending on the field’s type.

Since C# v10.0, user-defined parameterless constructors for either structs or record structs are allowed, and we can use this facility to achieve the outcomes needed here: an instance created with no arguments has non-null values for the properties, while keeping the convenience of the positional syntax to properly intialize properties with those values.

We don’t need property initializers, and our record struct representation of Address actually becomes a little simpler:

public readonly record struct Address(string House, string PostCode)
{
    public Address() : this("", "") 
    {
    }
}

Here we’re defining our own parameterless constructor which uses constructor forwarding to invoke the compiler-generated primary constructor with the default, non-null, values as the arguments. The compiler synthesizes the properties based on the positional parameters, and those properties are correctly initialized by the primary constructor. The following tests all pass:

var defaultAddress = new Address();

Assert.That(defaultAddress.House, Is.Not.Null);
Assert.That(defaultAddress.PostCode, Is.Not.Null);

var address = new Address(House: "1", PostCode: "2");

Assert.That(address.House, Is.EqualTo("1"));
Assert.That(address.PostCode, Is.EqualTo("2"));

We still can’t prevent a default-initialized Address, such as default(Address), or the elements of an array of Address objects, which will both still have null for their properties.

4. Closing Thoughts

Disallowing automatic property initializers for structs (record structs were added at the same time this restriction was lifted) was a frequent source of friction, so removing the restriction is beneficial, but needs to be used with care. We’ve not explored all the complexities here—​plenty of time for that—​but the take-away from this article is that mixing positional record types and automatic property initializers will give you a headache. You have been warned!

The required keyword in C# v11.0 certainly has its uses, but it doesn’t play well with constructors; we need to mark a type’s constructor with the [SetsRequiredMembers] attribute where the required keyword is used to satisfy the compiler, but it’s not foolproof, as shown here:

using System.Diagnostics.CodeAnalysis;

public sealed record class Address
{
    [SetsRequiredMembers]
    public Address(string house, string postcode)
        => (House) = (house);
    
    public required string House { get; init; }
    public required string PostCode { get; init; }
}

var address = new Address("", "");

Assert.That(address.PostCode, Is.Not.Null);

We must import the System.Diagnostics.CodeAnalysis namespace in order to use [SetsRequiredMembers], but even though we’ve applied that attribute to the constructor here, the compiler still can’t catch the fact that the constructor does not initialize all the required properties. This test fails because the PostCode property isn’t initialized in the constructor.

Beware of lies in your code!

Photo by Kotagauni Srinivas on Unsplash

Comments

Popular Posts