Immutability through Value Types

Marcus • April 30, 2020

A hand holding up a slide against the light; a happy elderly couple can be seen.

Just imagine for a moment that you want to represent a simple, straight-forward custom data type that has meaning within the domain you’re working in.

For a preliminary use case, let’s look at the following: Given a rectangle, we want a data type that can express the width & height of the area covered. Here’s the code for a very simple implementation.

struct Size {
  public int Width { get; set; }
  public int Height { get; set; }
  public Size(int width, int height) {
    Width = width;
    Height = height;
  }
}

The code for a new class is just the same: replace the keyword struct with the keyword class. But the semantics that come along with these two keywords are very different.

Semi-Immutability

C# allows you to compose your own composite types through two main ways: reference types (classes) and value types (structs).

Value types are stack-allocated, whereas normal classes are heap-allocated. Passing a struct as a function parameter copies the contents: While you can modify any member within the method you’re calling, you cannot modify the struct used outside the method.

There are certainly cases where this can simplify code: You don’t need a copy-constructor, or implement a cloning mechanism. Instead, you always get a valid state that cannot (simply) be modified.

If you don’t want other code to change the internal state you’re working with, value types help greatly. And if you do later decide that, for very specific cases modifications are allowed, specifying them as ref or out parameters at declaration & call site makes the intent clear.

void Main() {
  Size a = new Size(20, 30);
  Console.WriteLine(a);
  ModifySize(a);
  Console.WriteLine(a);
}

void ModifySize(Size s) {
  s.Width = 3;
  Console.WriteLine(s);
}

The output here is 20,30, followed by 3,30 and lastly 20,30.

Immutability

We can remove the setters for width and height entirely if we want to have our constructor to have a bit more control of what is allowed – for example, we could throw if either argument is negative, or if strings were involved, to enforce a maximum length in the constructor.

struct Size {
  public int Width { get; }
  public int Height { get; }
  public Size(int width, int height) {
    Width = width >= 0 ? width :
      throw new ArgumentOutOfRangeException(nameof(width));
    Height = height >= 0 ? height :
      throw new ArgumentOutOfRangeException(nameof(height));
  }
}

Removing the setter makes it rather impossible to set any property using Reflection either. Writing custom Intermediate Language (IL) code might allow you to invoke the setter — but even if you could, you’re only modifying your local copy.

There’s always default values

A value type in C# can never actually be null, since that concept is reserved for reference types. Instead, default(Size) will return a zero-initialized Size: References to objects are null, all other value types within are 0 (or false).

Structs have a default constructor that cannot be overridden or removed. For our Size struct, we can create new instances with a width and height of 0, even if we have no constructor without arguments defined. But that also ensures that you’ll never have to worry the Size itself will cause a NullPointerException.

But I want null…

It’s of course true that you cannot distinguish between an absent value and a zero-initialized value, since they’re the same. If you need to distinguish between the the two cases, that’s possible – but ask yourself if you really need it. It is impossible for you to pass null as a parameter to methods accepting value types, and can reduce the corresponding checks vastly.

There’s a way to pass null around, in a way, declaring a field, parameter or variable as Nullable<Size> — which can be expressed equivalently as Size?, the question mark at the end indicating nullability.

A Nullable has a boolean flag HasValue to represent this concept – and can be checked against null through implicit conversion and comparison.

Size? s = new Size(7, 13);
// The following two conditions are identical:
if (s.HasValue) {...}
if (s != null) {...}

This is vaguely similar to how an Optional in Java can be worked with: Assigning null to a Nullable in C# is equivalent to creating an Optional.ofNullable in Java.

}