C# 9 - Immutability - Records & Init-only Properties

Immutability has been getting popular these last years, especially with the rise of not only functional programming and but also JS frameworks such as React.

It’s an important concept for many reasons, but I won’t get into it in this blog post because it’s not the point. Although I would urge you to either read The Dao of Immutability or watch Jon Skeet’s The changing state of immutability in c# video, which explain it in details.

The true constant is change. Mutation hides change. Hidden change manifests chaos. Therefore, the wise embrace history.

C# 9 is trying to embrace Immutability more, by introducing a new type, record and init-only properties. I finally had the time to play with them, and I’m very excited for when C# 9 will be officially out!

How to try C# 9 features

I’m sure some of you would like to try the new C# 9 features, here’s how/where I do it:

  • LINQPad 6 Beta: by activating the experimental .NET 5 runtime.
  • SharpLab: by choosing the master Roslyn branch.

Init-only Properties

Imagine you have the following class:

public class Rectangle
{
  public double Width { get; set; }
  public double Height { get; set; }
}

Now you can create instances of it:

var item1 = new Rectangle
{
  Width = 10,
  Height = 5
};

Which is fine, but we can change Width and Height whenever we want. What if we don’t want that? What if we want our Rectangle to be immutable?

public class Rectangle
{
  public double Width { get; }
  public double Height { get; }

  public Rectangle(double width, double height)
  {
    Width = width;
    Height = height;
  }
}

As you can see, we’ll need to make the properties read-only and add a constructor that fills them. This just adds boilerplate (a parameterized constructor) and removes the possibility to use object initializers.

The new init keyword comes to the rescue:

public class Rectangle
{
public double Width { get; init; }
public double Height { get; init; }
public Rectangle()
{
// Of course you can still initialize the values in a constructor
Width = 10;
Height = 5;
}
}
var rect1 = new Rectangle(); // Works
var rect2 = new Rectangle
{
Width = 20,
Height = 10
}; // Works
rect1.Width = 30; // CS8852 Init-only property or indexer 'X.Rectangle.Width' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.

This means that we can only change (set) the values of Width and Height when instantiating Rectangle. After that, you can’t change the values of these properties, which essentially makes our class immutable!

Records

If you want to read the official proposal, which contains the full details, visit records.md.

Init-only properties are already doing a good job to promote immutability in C#, so you might be wondering why records are such an important addition to the language.

Records are here for two main reasons:

  1. A lot of our classes are “data-holders”, and creating them requires a lot of boilerplate.
  2. Immutability can be enhanced by things like value-based equality and deep cloning, which are automatically generated for you.

Deep dive

In order to make our Rectangle a record, all we need is to change the class keyword:

public record Rectangle
{
  public double Width { get; init; }
  public double Height { get; init; }
}

Which will be turned to this by the compiler:

public class Rectangle : IEquatable<Rectangle>
{
[CompilerGenerated]
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private double <Width>k__BackingField;
[CompilerGenerated]
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private double <Height>k__BackingField;
protected virtual Type EqualityContract
{
[return: System.Runtime.CompilerServices.Nullable(1)]
get
{
return typeof(Rectangle);
}
}
public double Width
{
[CompilerGenerated]
get
{
return <Width>k__BackingField;
}
[CompilerGenerated]
init
{
<Width>k__BackingField = value;
}
}
public double Height
{
[CompilerGenerated]
get
{
return <Height>k__BackingField;
}
[CompilerGenerated]
init
{
<Height>k__BackingField = value;
}
}
[return: System.Runtime.CompilerServices.Nullable(1)]
public virtual Rectangle <>Clone()
{
return new Rectangle(this);
}
public override int GetHashCode()
{
return (EqualityComparer<Type>.Default.GetHashCode(EqualityContract) * -1521134295 + EqualityComparer<double>.Default.GetHashCode(<Width>k__BackingField)) * -1521134295 + EqualityComparer<double>.Default.GetHashCode(<Height>k__BackingField);
}
[System.Runtime.CompilerServices.NullableContext(2)]
public override bool Equals(object obj)
{
return Equals(obj as Rectangle);
}
public virtual bool Equals([System.Runtime.CompilerServices.Nullable(2)] Rectangle P_0)
{
return P_0 != null && (object)EqualityContract == P_0.EqualityContract && EqualityComparer<double>.Default.Equals(<Width>k__BackingField, P_0.<Width>k__BackingField) && EqualityComparer<double>.Default.Equals(<Height>k__BackingField, P_0.<Height>k__BackingField);
}
protected Rectangle([System.Runtime.CompilerServices.Nullable(1)] Rectangle P_0)
{
<Width>k__BackingField = P_0.<Width>k__BackingField;
<Height>k__BackingField = P_0.<Height>k__BackingField;
}
public Rectangle()
{
}
bool IEquatable<Rectangle>.Equals(Rectangle other)
{
return Equals(other);
}
}

Here are the key things that you need to know about:

  • Line 11: A generated property used in the Equals method to only compare two instances of the same type, so inheritance won’t matter.
  • Line 49: A generated Clone method that uses the copy constructor to clone the instance.
  • Line 60: A generated override of Object.Equals.
  • Line 65: A generated virtual Equals method that does a full value-based comparison.
  • Line 70: A generated copy constructor that copies the values of all the fields.
  • Line 80: A generated IEquatable<Rectangle>.Equals that uses the generated Equals method, since our generated class implements IEquatable<Rectangle>.
If your record already contains a method/constructor that is supposed to be generated, for example the copy constructor, then the compiler will use your implementation and not generate a new one.

Ways to define records

There are a couple of ways to define records:

// Defining a record like this
public record Rectangle1 { double Width; double Height; }
// Is equivalent to this
public record Rectangle1
{
public double Width { get; init; }
public double Height { get; init; }
}
// Defining a record like this
public record Rectangle2(double Width, double Height);
// Is equivalent to this
public record Rectangle2
{
public double Width { get; init; }
public double Height { get; init; }
public Rectangle2(double width, double height)
=> (Width, Height) = (width, height);
public void Deconstruct(out double width, out double height)
=> (width, height) = (Width, Height);
}

With-expressions

Since we’re working with immutable data, a core concept is to create new object from existing one, by changing one or more properties. C# 9 adds the with expression, which is just a syntax sugar:

void Main()
{
var rect = new Rectangle(10, 5);
var widerRect = rect with { Width = 20 };
var longerRect = widerRect with { Height = 10 };
Console.WriteLine(rect.Equals(widerRect)); // False
Console.WriteLine(widerRect.Equals(longerRect)); // False
}
public record Rectangle(double Width, double Height);

Here’s the actual generated code:

// Generated Rectangle class omitted
void main()
{
Rectangle rectangle = new Rectangle(10.0, 5.0);
Rectangle rectangle2 = rectangle.<>Clone();
rectangle2.Width = 20.0;
Rectangle rectangle3 = rectangle2;
Rectangle rectangle4 = rectangle3.<>Clone();
rectangle4.Height = 10.0;
Rectangle rectangle5 = rectangle4;
Console.WriteLine(rectangle.Equals(rectangle3));
Console.WriteLine(rectangle3.Equals(rectangle5));
}

As you can see, the with expressions turn into a Clone + manual set of the changed properties. Note that with works with init properties because it uses the object initializer syntax.

If you noticed, the generated code contains duplicate objects, for example rectangle3. If anyone knows why, please send me a message!

Conclusion

Init-only properties and Records will boost our productivity and help us embrace immutability into our software. Personally, I can’t wait to use this in Blazor to manage state.

C# 9 is without a doubt a great milestone for the language. I only presented two features, but the version comes with a lot more! Make sure to check them out here.

Hope you enjoyed the post, see you soon!

Zanid Haytam Written by:

Zanid Haytam is an enthusiastic programmer that enjoys coding, reading code, hunting bugs and writing blog posts.