Shiny & New - C# Primary Constructors

C# v12.0, part of .NET 8, introduces a feature called Primary Constructors. Primary constructors are one of those features introduced to C# whose purpose is to simplify class and struct definitions by reducing the amount of code that needs to be written. However, they also have some potential for introducing confusion. In this post I'll look at the essentials of using primary constructors, but also investigate how they compare with other established C# features, and some recommendations for introducing them in C# code.

The Primary Constructor

The idea behind primary constructors is simple: many, perhaps most constructors written for classes and structs have one purpose, which is to initialize fields or properties of the class with values copied from constructor parameters. Primary constructors remove much of the syntax of doing so. The following shows a bare-bones example of a simple Address class defined using the primary constructor syntax.

  1. Primary constructor for an Address class

public sealed class Address(string property, string postCode)
{

   
public
string Property => property;
   
public
string PostCode => postCode;
}

The Address class doesn’t have a normal constructor method—a method with the same name as the type that optionally takes parameters. Instead, the Address type declaration has parameters named property and postCode. Beginning with C# v12.0, classes and structs can use the primary constructor syntax and the underlying mechanics are identical.

The Address class has two expression-bodied properties which use the parameter variables introduced in the primary constructor. The class in Listing 1 is almost equivalent to the class definition here.

  1. Classic constructor syntax

public sealed class Address
{

   
public
Address(string property, string postCode)
   
{

    
   
this.Property = property;
    
   
this.PostCode = postCode;
   
}

   
public string Property { get; }
   
public string PostCode { get; }
}

While the classes in both listings are functionally equivalent, there are some subtle differences. For instance, the constructor parameters of the class in Listing 2 are only visible within the constructor body. In the version of Address with a primary constructor, the property and postCode variables are in scope for all the member methods and properties of Address, and within any user-defined constructors we write.

If we add any of our own constructors, they must invoke the implicitly defined primary constructor using the this(…) syntax, showing in Listing 3. This ensures the parameter variables are always definitely assigned; failing to invoke the primary constructor results in a compiler error. Listing 3 shows an example where a user-defined constructor taking a value tuple forwards to the primary constructor.

  1. Constructor forwarding to the primary constructor

public sealed class Address(string property, string postCode)
{
    public Address((string property, string postCode) address)
        : this(address.property, address.postCode)
    {
    }

    public string Property => property;
    public string PostCode => postCode;
}

The other thing to note about a primary constructor is that it’s always public. The user-defined constructor in Listing 2 can be made private, but a primary constructor cannot.

The primary constructor syntax is superficially similar to positional records, introduced in C# v9.0, but records (as well as record structs in C# v10.0) differ from classes that have primary constructors in a number of important ways.

Positional Records and Record Structs

As it stands, the Address class could easily be implemented as a positional record, as in Listing 4.

  1. A record type

public sealed record Address(string Property, string PostCode);

The most obvious difference between the Address record and the class with a primary constructor is that the record version has no explicit methods or properties, resulting in a type definition that doesn’t have a body. The compiler generates read only properties for the record type, using the parameter names given in the positional parameters of the primary constructor. Those properties are initialized by the parameter values according to the arguments passed to the constructor when an Address record instance is created.

Listing 5 demonstrates how the parameters in a record’s primary constructor translate to both the property names and the constructor parameter names, emphasized here by using named arguments in the constructor call.

  1. Record properties

var sweeney = new Address(Property: "186", PostCode: "EC4A 2HR");

Assert.That(sweeney.Property, Is.EqualTo("186"));
Assert.That(sweeney.PostCode, Is.EqualTo("EC4A 2HR"));

Properties are not generated by the compiler for class or struct types using a primary constructor—we have to define them ourselves.

Equality Semantics

A much more important but less visible difference between a record and class with a primary constructor is that for the purposes of equality comparisons, a record type has value-like semantics as demonstrated in Listing 6.

  1. Value-like equality

var sweeney = new Address("186", "EC4A 2HR");
var newCafe = new Address("186", "EC4A 2HR");

Assert.That(sweeney.Equals(newCafe), Is.True);
Assert.That(sweeney == newCafe, Is.True);

Where Address is a record type, this test passes. Where Address is a class—with or without a primary constructor—this test fails unless the class overrides the Equals method and operator== to give the desired behaviour. Classes, by default, have reference semantics so two variables compare equal only when they refer to the same instance in memory. The compiler generates the implementation required for value equality in a record type, but not in a class.

In keeping with the good practices for defining value-like equality behaviour for a type, the compiler also generates an implementation of GetHashCode for a record to ensure that two instances that compare equal according to Equals will always have the same hash code. Record types can usually be safely used as keys in collections like Dictionary and HashSet provided the instances are immutable, and caveats regarding floating-point equality are carefully considered where they’re appropriate.

The compiler doesn’t generate a custom implementation for either of the Equals or GetHashCode methods for a class. In the absence of an explicit override for these methods, classes inherit the default reference-based behaviour from the object base class. This doesn’t preclude instances being used as keys in hashing collections, but does require a bit of extra care.

Primary Constructors and Structs

Struct types can have a primary constructor in exactly the same way as classes, as shown in Listing 7.

  1. Struct with a primary constructor

public readonly struct Address(string property, string postCode)
{
    public string Property => property;
    public string PostCode => postCode;
}

This Address struct is identical to the Address class in Listing 1, aside from the struct keyword and the language-defined differences between structs and classes. As with a class, the compiler generates a constructor method with parameters matching the primary constructor, and the parameter values are in scope for the whole struct definition.

The differences between structs and classes do play a part here because all struct types inherit the Equals and GetHashCode methods from the System.ValueType class, giving them value semantics for equality. However, that inherited implementation may not be optimal, relying as it does on reflection in most cases (certainly for Address).

As with a class, the presence of a primary constructor on a struct definition doesn’t mean the compiler provides any special implementation of equality comparisons or property definitions as it does for a record or record struct. In particular, the compiler provides both operator== and operator!= for records and record structs, but does not do so for a struct, whether or not it has a primary constructor. Therefore, the test shown in Listing 6 using the Address struct in Listing 7 won’t compile, owing to the use of == to compare the sweeney and newCafe variables.

In any case, since C# v10.0, in most cases where a value type is needed in a program, a record struct is a better choice than a plain struct.

Properties vs. Parameters

One final difference between class or struct primary constructors and the positional type arguments for records or record structs is how the identifiers are used within the body of the type. To demonstrate, consider the record in Listing 8 which has an instance method that uses the positional parameters as arguments to call an imaginary AddressLookupService.Resolve method.

  1. Record type with member method

public sealed record Address(string Property, string PostCode)
{
    public string GetFullAddress()
    {
        return AddressLookupService.Resolve(Property, PostCode);
    }
}

Recall that the compiler uses the positional parameters of a record type to generate properties with the names of the parameters; when the Resolve method is called, the get-accessor for each property is invoked to obtain the value.

The parameters of primary constructors in classes and structs are different: the compiler does not generate properties, but we can still use the parameters in a similar member method, as in Listing 9.

  1. Record type with member method

public sealed class Address(string property, string postCode)
{
    public string GetFullAddress()
    {
        return AddressLookupService.Resolve(property, postCode);
    }
}

Because the property and postCode parameter variables are used in the body of the class, the compiler stores them in hidden fields, and directly accesses the field values to call the Resolve method. This may represent a very small performance gain over the record equivalent: accessing a property involves a method call. Accessing a field value directly is always optimally efficient.

This might matter if you’re especially sensitive to performance. In practice, at run time the method call will very likely be inlined, but there’s no guarantee that it will be.

The observant reader will have noticed the difference in casing between the record positional parameters and the class primary constructor parameters. The compiler generates property names from the positional parameters of a record, and C# property names—by convention—use PascalCase. The identifiers in a primary constructor are parameter variables which by convention use camelCase.

Conclusion

In short, primary constructors for classes and structs offer a concise and convenient way to define how instances of those types will usually be created. Although the benefits are hardly dramatic, a primary constructor is undoubtedly more compact than the equivalent full constructor definition. However, the primary constructor syntax is similar enough to positional records that some care is probably needed, especially when reading code using either syntax. Mistaking a primary constructor on a class for a positional record could easily lead to code being misunderstood, or even the introduction of hard-to-find errors. Records are semantically different from classes, whether or not those classes have primary constructors.

When a class does not require value-like behaviour for equality, and needs a custom constructor only to initialize fields or properties using the parameter variables, then a primary constructor may provide some, albeit minor, benefit. If the constructor needs to do more than simply assigning values to fields and properties, then a fully-defined constructor is required in any case.

If a class needs an overridden Equals method to compare fields or properties for value-like equality behaviour, then a record or record struct is almost always preferable, although using the positional syntax for them isn’t always the best approach.

First published in Overload, 31(177):14-16, October 2023 

Photo by Chris Yates on Unsplash 

Comments

Popular Posts