Nondestructive Mutation and Records in C#

by Zoran Horvat

In this article, we will discuss nondestructive mutation: What does it mean to mutate an object without destroying it, and how do we implement it using records or classes that are not records in C#. You can learn more about this topic from my video courses published at Pluralsight and Udemy , as well as from the Practical Design series on my YouTube channel.

Nondestructive Mutation with Records

In this article, we will start from a record, because records are implementing the nondestructive mutation at the level of syntax. If you instantiated a record, you could mutate it using the with expression.

record Person(string Name, int Age);
...
Person joe = new("Joe", 28);
Person jim = joe with { Name = "Jim" };

The with expression will copy all properties into the new instance, and then rewrite values specified in the initializer. We can improve this code a bit by allowing initializer syntax in the first instance as well. However, by default, despite all properties being declared with init-only setters, the following attempt will not work:

record Person(string Name, int Age);
...
// Does not compile:
Person joe = new() { Name="Joe", Age=28 };

The compiler is complaining in code above, because we cannot skip initialization through the constructor parameters. They are mandatory in the record. However, you can avoid this compile-time error by using defaults in the constructor.

record Person(string Name="", int Age=0);
...
// Compiles fine:
Person joe = new() { Name="Joe", Age=28 };
Person jim = joe with { Name = "Jim" };

If you placed a value both inside parentheses as a constructor parameter, and in the initializer, then the initializer comes next, overriding whatever was passed to the constructor. You don't have to use constructor parameters in case when initializers are already present.

Either way, when we apply the with expression to construct a new person, that would be the nondestructive mutation. The joe instance remains intact, with the jim instance constructed anew, with Age copied from joe, and the Name set to the new value from the initializer.

This is the principle of nondestructive mutation when constructing a new object without destroying the prior one. Both references will remain, and both references will be valid and useful in any context where you need them.

The next question is how do we accomplish the same goal with a common class?

Understanding Immutability in Common Classes

We might construct a class which is modeling a person the same way as a record would. This custom class is also exposing Name and Age properties, having a constructor with default parameter values. This class is in many respects the same as the record we used above. It differs in that it lacks value-typed equality, which is implemented in records.

class CommonPerson
{
  public CommonPerson(string name="", int age=0)
  {
    this.Name = name;
    this.Age = age;
  }

  public string Name { get; }
  public int Age { get; }
}

There are classes that cannot implement value-typed equality. A class which is modeling an entity, which has a lifetime, usually modeled by maintaining an identity value and then saving its instances into the database, such classes do cannot implement value-typed equality.

The reason for this constraint is simple. If we had two instances materialized from the database, then we can compare them for equality in at least two valid ways: One based on only comparing the identity, and the other based on comparing the entire state, including identity. In the first case, we are testing whether the two instances are representing the same object, possibly two different versions of it. The second comparison method is answering the question whether those are the two identical versions of the same object.

Both definitions of equivalence are correct, each in its own domain. Therefore, objects like that cannot have the Equals method implemented, because we don't know how to implement it to be right in every use case.

We might have one such class here, the CommonPerson class defined above, with no equivalence implemented, but which we want to make immutable and, on top of that, to support nondestructive mutation on its instances. Please note that entities can be either mutable or immutable. If we chose to model them with an immutable class, then the next reasonable request is to supply them with nondestructive mutation as a useful feature.

Implementing Nondestructive Mutation in a Common Class

The goal of the following experiment is to improve implementation of the custom class to support syntax as close as possible to the with expression. The reason for this is that with expressions are so simple and natural, so why not have something like that in our own classes?

The first step in that direction is to add init-only setters to properties.

class CommonPerson
{
  public CommonPerson(string name="", int age=0)
  {
    this.Name = name;
    this.Age = age;
  }

  public string Name { get; init; }
  public int Age { get; init; }
}

This will immediately allow us the use of initializer syntax, which I like very much, because it adds readability to construction. It will also allow us to create a new instance from the existing one, by copying values into the constructor, and then rewriting a few of them via the initializer.

CommonPerson joe = new() { Name = "Joe", Age = 28 };
CommonPerson jim = new(joe.Name, joe.Age) { Name = "Jim" };

We could call this a poor man’s nondestructive mutation. Still, I find it a good starting point for what follows.

The problem with code above is twofold. It is verbose, but also lacks support for future modifications. What if we added another parameter to the CommonPerson class’s constructor? We would have to visit all the places where we used nondestructive mutation in this way, to add another parameter to every constructor call.

There are at least two designs that are routinely used in design to address this issue, and we will now examine them.

Implementing the With- Methods

We had non-destructive mutation in classes decades before records appeared in C#, and the following design will demonstrate one common way in which we used to implement it. We would expose public methods with the names starting with With – that is a common naming convention.

class CommonPerson
{
  public CommonPerson(string name="", int age=0)
  {
    this.Name = name;
    this.Age = age;
  }

  public string Name { get; init; }
  public int Age { get; init; }

  public CommonPerson WithName(string name) =>
    new(name, this.Age);

  public CommonPerson WithAge(int age) =>
    new(this.Name, age);
}

The WithName method will construct a new instance, setting new value to the Name property, while copying all other properties. The WithAge method does the same, only modifying the Age property in the new instance.

These public methods are responsible to supply all the constructor parameters. They are effectively encapsulating the call to the constructor. If more properties were added later, then only the class's own body should change to complete the modification. No calling place would have to change.

Here is the sample that demonstrates the use of the WithName method to exercise nondestructive mutation on the CommonPerson instance.

CommonPerson joe = new() { Name = "Joe", Age = 28 };
CommonPerson jim = joe.WithName("Jim");

We have started from the joe instance, and then created a new instance, jim, which is copying everything from the existing instance and only mutating the name. This design based on With- methods is still common in practice when designing classes that are not records. If you feel this design will be useful in your case, just apply it. But be warned that it comes with a couple of drawbacks, and that C# now supports a much more powerful implementation.

The first drawback is that we are calling the constructor in many places inside the class itself. If a new constructor parameter were added later, then we would have to modify all existing With- methods to support the change.

The second drawback is even worse: We can only change one property in one call. To modify several properties at once, we would have to change as many separate method calls and sustain construction of equally many new objects – all but one subject to garbage collection right away.

We will address these shortcomings in the next design.

Implementing the Copy Constructor

Now that we have init-only setters, we can use a so-called copy constructor. It is a constructor which can either be public or private, depending on who's going to use it, which receives another instance of this class. Its responsibility is to copy the entire state from the existing instance into the newly instantiated object.

class CommonPerson
{
  public CommonPerson(string name="", int age=0)
  {
    this.Name = name;
    this.Age = age;
  }

  public CommonPerson(CommonPerson other)
    : this(other.Name, other.Age) { }

  public string Name { get; init; }
  public int Age { get; init; }
}

That is all the copy constructor ever does. Usually, it will create a shallow copy. But it can also be a deep copy – you will decide, because you will be writing the copy constructor. That will be the opportunity to achieve any goals you have in the class, as you will know precisely what it means to copy this object into another one.

But now we can combine the public copy constructor with init-only setters, to achieve syntax that looks very close to the with expression on a record.

CommonPerson joe = new("Joe", 28);
CommonPerson jim = new(joe) { Name = "Jim" };

To create a new instance, copy the existing instance into it and change the name or whatever property you wanted to change using the initializer syntax.

This is as close to a with expression as one can come. It is funny that you cannot do the same thing with records.

record Person(string Name="", int Age);
...
Person joe = new() { Name = "Joe", Age = 28 };
// Does not compile:
Person jim = new(joe) { Name = "Jim" };

If we tried to create a new record using the copy constructor and setting name to a different value, the compiler would complain. It is not because records do not have the copy constructor – they do – but because copy constructor in them is private, and thus beyond our reach.

The reason behind this decision probably relates to performance. The with expression is more explicit, giving more options to the code rewriter and compiler to generate optimal code which will not waste resources.

This completes the design based on copy constructor and initializer. At the expense of writing the same property twice, but still without creation of needless objects, we were able to construct a new instance and preserve the existing one intact – a.k.a. to apply nondestructive mutation.

Summary

In this article, we have investigated three designs that are applying nondestructive mutation to immutable objects.

In the first experiment, you have seen how nondestructive mutation is supported by C# records out-of-the-box, via the with expressions. The with expression is internally backed by a copy constructor, but that constructor is private and inaccessible to outer callers.

In the second experiment, we have attempted to support nondestructive mutation in a common class, which is not a record. With no help from the compiler, we have exposed multiple methods, each constructing a new instance with precisely one instance mutated. That approach was wasteful, and so in the last experiment, we have attempted to overcome the drawbacks.

The final attempt was to support nondestructive mutation in a class which is not a record by combining the copy constructor and init-only setters. By sustaining the cost of each mutated property being set two times, we have succeeded to construct a new instance from the existing one.


If you wish to learn more, please watch my latest video courses

About

Zoran Horvat

Zoran Horvat is the Principal Consultant at Coding Helmet, speaker and author of 100+ articles, and independent trainer on .NET technology stack. He can often be found speaking at conferences and user groups, promoting object-oriented and functional development style and clean coding practices and techniques that improve longevity of complex business applications.

  1. Pluralsight
  2. Udemy
  3. Twitter
  4. YouTube
  5. LinkedIn
  6. GitHub