This is a beginner’s tutorial on Immutable Object Pattern with examples in C#. We discuss topics like “Internal immutability” vs “Observational Immutability”.
1. Immutable Object Definition
An Immutable Object (Internal Immutability) in C# is an object whose internal state cannot be changed after it is created. That is different from an ordinary object (Mutable Object) whose internal state typically can be changed after creation. The immutability of a C# object is enforced during compile time. Immutability is a compile-time constraint that signals what a programmer can do through the normal interface of the object.
There is a small confusion since sometimes under Immutable Object, the following definition is assumed:
An Immutable Object (Observational Immutability) ([2]) in C# is an object whose public state cannot be changed after it is created. In this case, we do not care if the internal state of an object changes over time if the public, the observable state is always the same. To the rest of the code, it always appears as the same object, because that is how it is being seen.
2. Utility for Finding Object Addresses
Since we are going on to show in our examples objects both on stack and heap, in order to better show differences in behavior, we developed a small utility that will give us the address of the objects in question, so by comparing addresses, it will be easily seen if we are talking about the same or different objects. The only problem is that our address-finding-utility has a limitation, that is, it works ONLY for objects on the heap that do not contain other objects on the heap (references). Therefore, we are forced to use only primitive values in our objects, and that is the reason why I needed to avoid using C# string
and am using only char
types.
Here is that address-finding-utility. We created two of them, one for class
-based objects and another for struct
-based objects. The problem is that we want to avoid boxing of struct
-based objects since that would give us an address on the heap of the boxed object, not on the stack of the original object. We use Generics to block incorrect usage of the utilities.
public static Tuple<string?, string?>
GetMemoryAddressOfClass<T1, T2>(T1 o1, T2 o2)
where T1 : class
where T2 : class
{
string? address1 = null;
string? address2 = null;
GCHandle? handleO1 = null;
GCHandle? handleO2 = null;
if (o1 != null)
{
handleO1 = GCHandle.Alloc(o1, GCHandleType.Pinned);
}
if (o2 != null)
{
handleO2 = GCHandle.Alloc(o2, GCHandleType.Pinned);
}
if (handleO1 != null)
{
IntPtr pointer1 = handleO1.Value.AddrOfPinnedObject();
address1 = "0x" + pointer1.ToString("X");
}
if (handleO2 != null)
{
IntPtr pointer2 = handleO2.Value.AddrOfPinnedObject();
address2 = "0x" + pointer2.ToString("X");
}
if (handleO1 != null)
{
handleO1.Value.Free();
}
if (handleO2 != null)
{
handleO2.Value.Free();
}
Tuple<string?, string?> result =
new Tuple<string?, string?>(address1, address2);
return result;
}
public static unsafe string?
GetMemoryAddressOfStruct<T1>(ref T1 o1)
where T1 : unmanaged
{
string? result = null;
fixed (void* pointer1 = (&o1))
{
result = $"0x{(long)pointer1:X}";
}
return result;
}
3. Example of a Mutable Object (Class-based)
Here is an example of a mutable object, class-based, meaning it is on the managed heap. And there is a sample execution and mutation. And then, there is the execution result:
public class CarClass
{
public CarClass(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
Year = year;
}
public Char? Brand { get; set; }
public Char? Model { get; set; }
public int? Year { get; set; }
public override string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
}
}
Console.WriteLine("-----");
Console.WriteLine("Mutation of mutable class object");
CarClass car1 = new CarClass('T', 'C', 2022);
Console.WriteLine($"Before mutation: car1={car1}");
car1.Model = 'A';
Console.WriteLine($"After mutation: car1={car1}");
Console.WriteLine();
Console.WriteLine("-----");
Console.WriteLine("Assignment of mutable class object");
Console.WriteLine("From addresses you can see that assignment created ");
Console.WriteLine("two references pointing to the same object on heap ");
CarClass car3 = new CarClass('T', 'C', 1991);
CarClass car4 = car3;
Tuple<string?, string?> addresses1 = Util.GetMemoryAddressOfClass(car3, car4);
Console.WriteLine($"Address car3={addresses1.Item1}, Address car4={addresses1.Item2}");
Console.WriteLine($"Before mutation: car3={car3}");
Console.WriteLine($"Before mutation: car4={car4}");
car4.Model = 'Y';
Console.WriteLine($"After mutation: car3={car3}");
Console.WriteLine($"After mutation: car4={car4}");
Console.WriteLine();
As we know very well, Class types have reference semantics ([3]), and an assignment is just an assignment of references, pointing to the same object. So, the assignment just copied a reference, and we have the case of two references pointed to the one object on the heap, and it doesn’t matter which reference we used, that one object was mutated.
4. Example of a Mutable Object (Struct-based)
Here is an example of a mutable object, struct
-based, meaning it is on the stack. And there is a sample execution and mutation. And then, there is the execution result:
public struct CarStruct
{
public CarStruct(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
Year = year;
}
public Char? Brand { get; set; }
public Char? Model { get; set; }
public int? Year { get; set; }
public override string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
}
}
Console.WriteLine("-----");
Console.WriteLine("Mutation of mutable struct object");
CarStruct car5 = new CarStruct('T', 'C', 2022);
Console.WriteLine($"Before mutation: car5={car5}");
car5.Model = 'Y';
Console.WriteLine($"After mutation: car5={car5}");
Console.WriteLine();
Console.WriteLine("-----");
Console.WriteLine("Assignment of mutable struct object");
Console.WriteLine("From addresses you can see that assignment created ");
Console.WriteLine("two different object on the stack ");
CarStruct car7 = new CarStruct('T', 'C', 1991);
CarStruct car8 = car7;
string? address7 = Util.GetMemoryAddressOfStruct(ref car7);
string? address8 = Util.GetMemoryAddressOfStruct(ref car8);
Console.WriteLine($"Address car7={address7}, Address car8={address8}");
Console.WriteLine($"Before mutation: car7={car7}");
Console.WriteLine($"Before mutation: car8={car8}");
car8.Model = 'M';
Console.WriteLine($"After mutation: car7={car7}");
Console.WriteLine($"After mutation: car8={car8}");
Console.WriteLine();
As we know very well, struct
s have value semantics ([3]), and on assignment, an instance of the type is copied. That is different behavior from class-based objects, that is reference types, that is shown above. As we can see, the assignment created a new instance of an object, so the mutation affected only the new instance.
5. Example of an Immutable Object (Struct-based)
5.1. Method 1 – Read-Only Properties
You can make an Immutable object of a struct
-based type by marking all public
properties with readonly
keyword. Such properties can be mutated ONLY during the construction phase of an object, after that are immutable. Setting properties during the initialization phase of the object is not possible in this case.
public struct CarStructI1
{
public CarStructI1(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
Year = year;
}
public readonly Char? Brand { get; }
public readonly Char? Model { get; }
public readonly int? Year { get; }
public override readonly string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
}
}
CarStructI1 car10 = new CarStructI1('T', 'C', 2022);
5.2. Method 2 – Init-Setter Properties
You can make an Immutable object of a struct
-based type by marking all public
properties with init
keyword for a setter. Such properties can be mutated ONLY during the construction phase of an object and during the initialization phase of the object, after that are immutable. Setting properties during the initialization phase of the object is possible in this case.
public struct CarStructI2
{
public CarStructI2(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
Year = year;
}
public Char? Brand { get; init; }
public Char? Model { get; init; }
public int? Year { get; init; }
public override readonly string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
}
}
CarStructI2 car20 = new CarStructI2('T', 'C', 2022);
CarStructI2 car21 = new CarStructI2() { Brand = 'A', Model = 'A', Year = 2000 };
5.3. Method 3 – Read-Only Struct
You can make an Immutable object of a struct
-based type by marking the struct
with readonly
keyword. In such a struct
, all properties must be marked as readonly
and can be mutated ONLY during the construction phase of an object, after that are immutable. Setting properties during the initialization phase of the object is not possible in this case. I see no difference in this case from Method 1 above when all properties/methods are marked as readonly
except it is easily seen on the struct
level definition what is the intent of that struct
, that is struct
creator planned it to be immutable from the start.
public readonly struct CarStructI3
{
public CarStructI3(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
Year = year;
}
public Char? Brand { get; }
public Char? Model { get; }
public int? Year { get; }
public override string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
}
}
CarStructI3 car30= new CarStructI3('T', 'C', 2022);
6. Example of an Immutable Object (Class-based)
6.1. Method 1 – Read-Only Properties
You can make an Immutable
object of a class-based type by making all public
properties as read-only by removing setters. Such properties can be mutated only by private
members of the class. Setting properties during the initialization phase of the object is not possible in this case.
public class CarClassI1
{
public CarClassI1(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
Year = year;
}
public CarClassI1()
{ }
public Char? Brand { get; }
public Char? Model { get; }
public int? Year { get; }
public override string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
}
}
CarClassI1 car50 = new CarClassI1('T', 'C', 2022);
6.2. Method 2 – Init-Setter Properties
You can make an Immutable object of a class
-based type by marking all public
properties with init
keyword for a setter. Such properties can be mutated ONLY during the construction phase of an object and during the initialization phase of the object, after that are immutable. Setting properties during the initialization phase of the object is possible in this case.
public class CarClassI2
{
public CarClassI2(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
Year = year;
}
public CarClassI2()
{ }
public Char? Brand { get; init; }
public Char? Model { get; init; }
public int? Year { get; init; }
public override string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
}
}
CarClassI2 car60 = new CarClassI2('T', 'C', 2022);
CarClassI2 car61 = new CarClassI2() { Brand = 'A', Model = 'A', Year = 2000 };
7. Internal Immutability vs Observational Immutability
The above cases were all cases of Internal Immutability Immutable objects. Let us give an example of one Observational Immutability Immutable object. The following is such an example. We basically cache the result of a long price calculation. The object always reports the same state, so it satisfies Observational Immutability, but its internal state changes, so it does not satisfy Internal Immutability.
public class CarClassI1
{
public CarClassI1(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
Year = year;
}
public Char? Brand { get; }
public Char? Model { get; }
public int? Year { get; }
public int? Price
{
get
{
if (_price== null)
{
LongPriceCalcualtion();
}
return _price;
}
}
private int? _price = null;
private void LongPriceCalcualtion()
{
_price = 0;
Thread.Sleep(1000);
_price += 10_000;
Thread.Sleep(1000);
_price += 10_000;
Thread.Sleep(1000);
_price += 10_000;
}
public override string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}, Price:{Price}";
}
}
CarClassI1 car50 = new CarClassI1('T', 'C', 2022);
Console.WriteLine($"The 1st object state: car50={car50}");
Console.WriteLine($"The 2nd object state: car50={car50}");
8. Thread Safety and Immutability
Internal Immutability Immutable objects are trivially thread-safe. That follows from the simple logic that all shared resources are read-only, so there is no chance of threads interfering with each other.
Observational Immutability Immutable objects are not necessarily thread-safe, and the above example shows that. Getting state invokes some private
thread-unsafe methods, and the final result is not thread-safe. If approached from two different threads, the above object might report different states.
9. Immutable Object (Struct-based) and Nondestructive Mutation
If you want to reuse an Immutable object, you are free to reference it as many times as you want, because it is guaranteed not to change. But what if you want to reuse some of the data of an Immutable object, but modify it a bit? That is why they invented Nondestructive Mutation. In C# language, now you can use with
keyword to do it. Typically, you would want to preserve most of the state of an Immutable object but change just some properties. Here is how it can be done in C#10 and after that.
public struct CarStructI2
{
public CarStructI2(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
Year = year;
}
public Char? Brand { get; init; }
public Char? Model { get; init; }
public int? Year { get; init; }
public override readonly string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
}
}
Console.WriteLine("-----");
Console.WriteLine("Nondestructive Mutation of immutable struct object");
CarStructI2 car7 = new CarStructI2('T', 'C', 1991);
CarStructI2 car8 = car7 with { Brand = 'A' };
string? address1 = Util.GetMemoryAddressOfStruct(ref car7);
string? address2 = Util.GetMemoryAddressOfStruct(ref car8);
Console.WriteLine($"Address car7={address1}, Address car8={address2}");
Console.WriteLine($"State: car7={car7}");
Console.WriteLine($"State: car8={car8}");
Console.WriteLine();
10. Immutable Object (Class-based) and Nondestructive Mutation
For class-based Immutable objects, they didn’t extend the C# language with the new with
keyword, but the same functionality can still be easily custom programmed. Here is an example:
public class CarClassI4
{
public CarClassI4(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
Year = year;
}
public Char? Brand { get; }
public Char? Model { get; }
public int? Year { get; }
public CarClassI4 NondestructiveMutation
( Char? Brand=null, Char? Model = null, int? Year=null)
{
return new CarClassI4(
Brand ?? this.Brand, Model ?? this.Model, Year ?? this.Year);
}
public override string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
}
}
Console.WriteLine("-----");
Console.WriteLine("Nondestructive Mutation of immutable class object");
CarClassI4 car1 = new CarClassI4('T', 'C', 1991);
CarClassI4 car2 = car1.NondestructiveMutation(Model:'M');
Tuple<string?, string?> addresses2 = Util.GetMemoryAddressOfClass(car1, car2);
Console.WriteLine($"Address car1={addresses2.Item1}, Address car2={addresses2.Item2}");
Console.WriteLine($"State: car1={car1}");
Console.WriteLine($"State: car2={car2}");
Console.WriteLine();
11. Conclusion
Immutable object pattern is very popular and is frequently used. Here, we gave an introduction to creating immutable struct
s and class
es in C# and some interesting examples.
We discussed Internal immutability vs Observational Immutability and talked about thread safety issues.
Related concepts of interest recommended to the reader are Value objects and Records in C#.
12. References
13. History
- 7th February, 2023: Initial version