Incompatible Language Features in C#

Incompatible Language Features in C#

By Steve Love

Overload, 31(175):8-10, June 2023


Adding features to an established language can introduce sources of errors. Steve Love examines some of the pitfalls of combining positional record structs with automatic property initializers.

The C# language has undergone quite significant changes over the last three years or so. No longer tied to the (relatively infrequent) release cadence of Microsoft’s Visual Studio, the C# compiler and .NET platform designers have added a host of new features, as well as tidied up some incongruities and removed some restrictions since C# v8.0 in 2019. Overall those changes make C# a more consistent language, with fewer special corner cases, and therefore easier to write and to learn, but some changes have also introduced new complexities of their own.

C# v10.0 – released with .NET 6 in 2021 – introduced two new features that were somewhat subdued in their respective announcements1: record structs, and automatic property initializers for value types. We’ll get to automatic property initializers but first, let’s have a look at why record structs were introduced.

Why record structs?

To understand record structs, you need to understand records2. C# v9.0 and .NET 5 added a new way of creating user-defined types: the record. Before the introduction of records, we could choose between classes and structs. A class defines a reference type, meaning instances live on the heap and benefit from garbage collection. Copying a reference type variable creates a new reference to the same instance on the heap. A struct defines a value type, meaning the lifetime of an instance is tied (broadly speaking) to the scope of the variable associated with it. Copying a value type variable copies the entire instance – value type variables and instances have a 1-to-1 relationship – and this has important consequences for efficiency and equality semantics.

The default behavior of Equals for classes performs a reference-based comparison where two variables compare equal if they refer to the same object on the heap. By contrast, Equals for structs performs a value-based comparison whereby two variables are equal if all their fields and properties match. Value-based equality comparisons are common for some kinds of types in many programs, but copying large struct instances – those with several fields, for example – could negatively impact a program’s performance. We can override the default equality comparison for class types to perform a value-based rather than reference-based comparison, and still benefit from the reference-based copying behaviour for instances of the type.

A record is a reference type (in fact, once compiled it really is a class), and the compiler synthesizes an efficient and correct implementation of equality (along with a few other features), which represents a fairly significant saving on some boilerplate code that’s surprisingly easy to get wrong. Put simply, records are reference types with compiler generated value-based equality behaviour.

Record structs are merely the value type equivalent of records. The compiler translates a record struct into a struct. While structs have always had value-based equality semantics, the default behaviour suffers from performance issues; specifically, the default implementation of Equals usually (with some exceptions) requires the use of reflection, which is not generally associated with high performance. As a result, customizing Equals for structs is good practice, but as with classes, there are pitfalls to avoid. The compiler generates the implementation of Equals for a record struct in the same way as for a record.

On the face of it, record structs were introduced simply to re-establish the symmetry between reference types and value types, but there are good reasons for choosing value types over reference types in some circumstances. In particular, structs and record structs are a good choice where instances are short-lived and an application will have large numbers of them, because value types aren’t subject to garbage collection. Implementing them as reference types instead might add considerable heap memory pressure, causing extra work for the garbage collector.

Property initializers

Using positional syntax3 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 (owing to the use of readonly in the type’s declaration), 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 (the same is true for fields too, but we’re only considering properties here). From C# v10.0, automatic property initialization syntax is also permitted for record structs and normal structs, shown here for the Address type:

  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 Listing 1.

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

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

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: "221b",
    PostCode: "NW1 6XE");
  
  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("221b"));
  Assert.That(address.PostCode, 
    Is.EqualTo("NW1 6XE"));

Both properties now have the values assigned by the automatic property initializers, and so are both empty strings.

The compiler-generated constructor hasn’t initialized the properties from its parameter values. Note that this behaviour applies equally to record types. The earlier problem with default initialization doesn’t apply to records, which as reference types 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?

Requiring properties to be initialized

Our original 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 a property by using the required keyword to modify the property definition, 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 4 for both properties, enabling object initialization for Address objects. The init accessor was introduced in C# v9.0 along with records. 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 5 feature available since C# v8.0 to warn them), but we no longer need property initializers. The tests in Listing 2 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 = "221b",
  PostCode = "NW1 6XE"
};

Assert.That(address.House, Is.EqualTo("221b"));
Assert.That(address.PostCode,
  Is.EqualTo("NW1 6XE"));
Listing 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. That’s a little unfortunate, because positional record types are very convenient for the most simple types. Luckily, 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.

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 6 for either structs or record structs are allowed, and we can use this facility to achieve the outcomes needed here: an instance created using new but with no arguments has non-null values for the properties, while keeping the convenience of the positional syntax to properly initialize 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 syntax used here is somewhat arcane in that we’re forwarding to an invisible constructor, but it is arguably less surprising than the alternatives we’ve already explored. The compiler synthesizes the properties based on the positional parameters, and those properties are correctly initialized by the primary constructor which is directly invoked by our parameterless constructor with the required default values for those properties. The tests in Listing 3 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: "221b",
  PostCode: "NW1 6XE");

Assert.That(address.House, Is.EqualTo("221b"));
Assert.That(address.PostCode,
  Is.EqualTo("NW1 6XE"));
Listing 3

We still can’t prevent a default-initialized7 Address, such as default(Address), or the elements of an array of Address objects, which will both still have null for their properties; such instances will always be default-initialized, and it’s not possible to change or prevent that behaviour.

More on required properties

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 but the take-away 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. Consider this record:

  public sealed record class Address
  {
    public Address(string house, string postcode)
      => (House) = (house);
    
    public required string House { get; init; }
    public required string PostCode { get; init; }
  }
  
  var address = new Address(“221b”, “NW1 6XE”);

The creation of the address variable here doesn’t compile. Because the properties are marked required, we must set them in an object initializer (using braces { }) or add the [SetsRequiredMembers] attribute to the constructor.

Adding the attribute to the constructor satisfies the compiler, but it’s not foolproof, as shown in Listing 4.

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("221b", "NW1 6XE");

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

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.

Closing thoughts

Language design is undoubtedly hard, and adding new features to any non-trivial language can bring unforeseen consequences. C# may consider itself to be “simple”, but the truth is that usefulness almost always involves complexity. New features can interact with long-established semantics in … interesting ways. In this article we’ve examined how just some of the many new features in C# are intricately entwined with each other, and with features that have been part of the C# language from the very beginning.

Footnotes

  1. https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-10
  2. https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-9#record-types
  3. https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#positional-syntax-for-property-definition
  4. https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-9#init-only-setters
  5. https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
  6. https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/struct#struct-initialization-and-default-values
  7. https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/default

Steve Love is a programmer who gets frustrated at having to do things twice.






Your Privacy

By clicking "Accept Non-Essential Cookies" you agree ACCU can store non-essential cookies on your device and disclose information in accordance with our Privacy Policy and Cookie Policy.

Current Setting: Non-Essential Cookies REJECTED


By clicking "Include Third Party Content" you agree ACCU can forward your IP address to third-party sites (such as YouTube) to enhance the information presented on this site, and that third-party sites may store cookies on your device.

Current Setting: Third Party Content EXCLUDED



Settings can be changed at any time from the Cookie Policy page.