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.
Comments
Post a Comment