This is a beginner’s tutorial on Records in C# with examples. C# Records are nothing more than C# language integrated support for the “Value Object” pattern and “Immutable Object” pattern. They are compiled into regular “classes” and “structs” in assembly/IL.
1. Prerequisites
Please read up on the “Value Object” pattern and “Immutable Object” pattern since they are the motivation for introducing “records” into C# language. Here are some of my articles that are prerequisites for this tutorial:
2. Records in C# - Summary
I am assuming that the reader is an intermediate-level C# programmer or higher and will go straight to the point, trying to demystify “Records”.
- What are C# records similar to? They are similar to regular classes or
struct
s in C# language. Actually, when compiled into IL, they are compiled into regular classes or structs in IL. Records exist only in the imagination of the C# compiler and are not a new concept to IL. - How are C# records different from regular classes or structs? Well, C# records are kind of “C# language integrated” support for the “Value Object” pattern and “Immutable Object” pattern. That means, for example, that the keyword “record” results in the automatic generation of several methods overloads (related to object equality) to give a class object “value semantics” ([5]).
- What do C# records bring to the table, if we already have classes and structs in C#? The answer is, you can live without records in your code and just use custom classes or
struct
s to implement the “Value Object” pattern and “Immutable Object” pattern, as before. But, since that is a new language feature, so it is fancy to start using it. Also, it is advertised as helping programmers to achieve the same functionality in fewer lines of code. - How do you define “records”? In the same place where you use
class
or struct
keywords, you would use record class or record struct. The usage of just record
is a synonym for “record
class” since they first invented records for C#9 for classes, then they figured out they can make structs records in C#10. - Are “records” immutable? In regular basic definition, as with regular classes and structs, records are mutable. But the typical use case of records might require making them immutable, and you can make them immutable just as any
class
or struct
. - Any other special syntax to mention? Yes, they invented a Positional Syntax, which is a one-liner constructor look-alike type definition for records, but in the background, a regular full-blown
class
or struct
that implements their interpretation of the “Value (and sometime Immutable) Object” pattern is generated by the C# compiler. - What about the new contextual
with
keyword? That keyword actually works with regular struct
s, too. When assigning struct
s, that keyword does “shallow clone” and mutates a small number of properties of your choice. The industry name for that is Nondestructive mutation.
Of course, there are technical details on how to implement “records’ in code, but I thought the concept of what we are achieving and what problem we are trying to solve with records, needs to be addressed first.
3. Utility for Finding Object Addresses
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. The code is in the attached projects.
4. Record Class Mutable - Example
Here, we will give an example of a Record
Class Mutable
object and demo its behavior in typical situations, like mutation, assignment, and equality comparison.
public record class CarRecordClassMutable
{
public CarRecordClassMutable(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 record class object");
CarRecordClassMutable car1 = new CarRecordClassMutable('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 record class object");
Console.WriteLine("From addresses you can see that assignment created ");
Console.WriteLine("two references pointing to the same object on heap ");
CarRecordClassMutable car3 = new CarRecordClassMutable('T', 'C', 1991);
CarRecordClassMutable 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();
Console.WriteLine("Equality of record class object");
CarRecordClassMutable car31 = new CarRecordClassMutable('T', 'C', 1991);
CarRecordClassMutable car41 = new CarRecordClassMutable('T', 'C', 1991);
Console.WriteLine($"record class object, car31={car31}");
Console.WriteLine($"record class object, car41={car41}");
bool equal3141 = car31 == car41;
Console.WriteLine($"car31 == car41:{equal3141}");
Console.WriteLine();
4.1. Record Class Mutable- Decompiled
In order to see what the compiler is really doing with the record, I used the decompiler tool dotPeek to decompile the assembly. It has the option to then create what they call “low-level-C#” from IL. So, what I did is C# source->assembly->IL->low-level-C#. Then I reduced a bit all the trash info that was in that file and removed method contents for brevity. The result is the brief outline of the equivalent C# code, generated by the C# compiler. That gives us a pretty good idea of what is happening behind the scene and what are records all about.
public class CarRecordClassMutable :
IEquatable<CarRecordClassMutable>
{
private char? \u003CBrand\u003Ek__BackingField;
private char? \u003CModel\u003Ek__BackingField;
private int? \u003CYear\u003Ek__BackingField;
protected virtual Type EqualityContract
{ }
public CarRecordClassMutable(char? brand, char? model, int? year)
{ }
public char? Brand
{ }
public char? Model
{ }
public int? Year
{ }
public override string ToString()
{ }
protected virtual bool PrintMembers(StringBuilder builder)
{ }
public static bool op_Inequality(CarRecordClassMutable left,
CarRecordClassMutable right)
{ }
public static bool op_Equality(CarRecordClassMutable left,
CarRecordClassMutable right)
{ }
public override int GetHashCode()
{ }
public override bool Equals(object obj)
{ }
public virtual bool Equals(CarRecordClassMutable other)
{ }
public virtual CarRecordClassMutable \u003CClone\u003E\u0024()
{ }
protected CarRecordClassMutable(CarRecordClassMutable original)
{ }
}
5. Record Struct Mutable - Example
Here, we will give an example of a Record Struct
Mutable
object and demo its behavior in typical situations, like mutation, assignment, and equality comparison.
public record struct CarRecordStructMutable
{
public CarRecordStructMutable(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 record struct object");
CarRecordStructMutable car5 = new CarRecordStructMutable('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 record struct object");
Console.WriteLine("From addresses you can see that assignment created ");
Console.WriteLine("two different objects on the stack ");
CarRecordStructMutable car7 = new CarRecordStructMutable('T', 'C', 1991);
CarRecordStructMutable 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();
Console.WriteLine("Equality of record struct object");
CarRecordStructMutable car71 = new CarRecordStructMutable('T', 'C', 1991);
CarRecordStructMutable car81 = new CarRecordStructMutable('T', 'C', 1991);
Console.WriteLine($"record struct object, car71={car71}");
Console.WriteLine($"record struct object, car81={car81}");
bool equal7181 = car71 == car81;
Console.WriteLine($"car71 == car81:{equal7181}");
Console.WriteLine();
5.1. Record Struct Mutable- Decompiled
In order to see what the compiler is really doing with the record, I used the decompiler tool dotPeek to decompile the assembly. It has the option to then create what they call “low-level-C#” from IL. So, what I did is C# source->assembly->IL->low-level-C#. Then I reduced a bit all the trash info that was in that file and removed method contents for brevity. The result is the brief outline of the equivalent C# code, generated by the C# compiler. That gives us a pretty good idea of what is happening behind the scenes and what records are all about.
public struct CarRecordStructMutable : IEquatable<CarRecordStructMutable>
{
private char? \u003CBrand\u003Ek__BackingField;
private char? \u003CModel\u003Ek__BackingField;
private int? \u003CYear\u003Ek__BackingField;
public CarRecordStructMutable(char? brand, char? model, int? year)
{ }
public char? Brand
{ }
public char? Model
{ }
public int? Year
{ }
public override string ToString()
{ }
private readonly bool PrintMembers(StringBuilder builder)
{ }
public static bool op_Inequality(CarRecordStructMutable left,
CarRecordStructMutable right)
{ }
public static bool op_Equality(CarRecordStructMutable left,
CarRecordStructMutable right)
{ }
public override readonly int GetHashCode()
{ }
public override readonly bool Equals(object obj)
{ }
public readonly bool Equals(CarRecordStructMutable other)
{ }
}
6. Record Class Immutable - Example
Here, we will give an example of a Record
Class
Immutable
object and demo its behavior in typical situations, like mutation, assignment, and equality comparison.
public record class CarRecordClassImmutable
{
public CarRecordClassImmutable(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 string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
}
}
Console.WriteLine("-----");
Console.WriteLine("Mutation of immutable record class object");
CarRecordClassImmutable car1 = new CarRecordClassImmutable('T', 'C', 2022);
Console.WriteLine();
Console.WriteLine("-----");
Console.WriteLine("Assignment of immutable record class object");
Console.WriteLine("From addresses you can see that assignment created ");
Console.WriteLine("two references pointing to the same object on heap ");
CarRecordClassImmutable car3 = new CarRecordClassImmutable('T', 'C', 1991);
CarRecordClassImmutable car4 = car3;
Tuple<string?, string?> addresses1 = Util.GetMemoryAddressOfClass(car3, car4);
Console.WriteLine($"Address car3={addresses1.Item1}, Address car4={addresses1.Item2}");
Console.WriteLine();
Console.WriteLine("Equality of record class object");
CarRecordClassImmutable car31 = new CarRecordClassImmutable('T', 'C', 1991);
CarRecordClassImmutable car41 = new CarRecordClassImmutable('T', 'C', 1991);
Console.WriteLine($"record class object, car31={car31}");
Console.WriteLine($"record class object, car41={car41}");
bool equal3141 = car31 == car41;
Console.WriteLine($"car31 == car41:{equal3141}");
Console.WriteLine();
7. Record Struct Immutable - Example
Here, we will give an example of a Record Struct
Immutable object and demo its behavior in typical situations, like mutation, assignment, and equality comparison.
public readonly record struct CarRecordStructImmutable
{
public CarRecordStructImmutable(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 string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
}
}
Console.WriteLine("-----");
Console.WriteLine("Mutation of immutable record struct object");
CarRecordStructImmutable car5 = new CarRecordStructImmutable('T', 'C', 2022);
Console.WriteLine();
Console.WriteLine("-----");
Console.WriteLine("Assignment of immutable record struct object");
Console.WriteLine("From addresses you can see that assignment created ");
Console.WriteLine("two different objects on the stack ");
CarRecordStructImmutable car7 = new CarRecordStructImmutable('T', 'C', 1991);
CarRecordStructImmutable car8 = car7;
string? address7 = Util.GetMemoryAddressOfStruct(ref car7);
string? address8 = Util.GetMemoryAddressOfStruct(ref car8);
Console.WriteLine($"Address car7={address7}, Address car8={address8}");
Console.WriteLine();
Console.WriteLine("Equality of record struct object");
CarRecordStructImmutable car71 = new CarRecordStructImmutable('T', 'C', 1991);
CarRecordStructImmutable car81 = new CarRecordStructImmutable('T', 'C', 1991);
Console.WriteLine($"record struct object, car71={car71}");
Console.WriteLine($"record struct object, car81={car81}");
bool equal7181 = car71 == car81;
Console.WriteLine($"car71 == car81:{equal7181}");
Console.WriteLine();
8 “Positional Syntax” for defining Records
They invented a Positional Syntax, which is a one-liner constructor look-alike type definition for records, but in the background, a regular full-blown class
or struct
that implements their interpretation of the “Value (and sometime Immutable) Object” pattern is generated by the C# compiler. The code generated is similar to the examples above, just an extra method Decontruct()
is added. Here is what this short notation looks like:
public record struct CarRecordStructMutable(Char? Brand, Char? Model, int? Year);
public readonly record struct CarRecordStructImmutable
(Char? Brand, Char? Model, int? Year);
public record class CarRecordClassImmutable(Char? Brand, Char? Model, int? Year);
8.1. “Positional Syntax” for Defining Records-Decompiled
In order to see what the compiler is really doing with the record, I used the decompiler tool dotPeek to decompile the assembly. It has the option to then create what they call low-level-C# from IL. So, what I did is C# source->assembly->IL->low-level-C#. Then I reduced a bit all the trash info that was in that file and removed method contents for brevity. The result is the brief outline of the equivalent C# code, generated by the C# compiler. That gives us a pretty good idea of what is happening behind the scenes and what the records are all about.
Here is what the above three Positional Syntax records generated code looks like:
public struct CarRecordStructMutable2 :
IEquatable<PositionalSyntax.CarRecordStructMutable2>
{
private char? \u003CBrand\u003Ek__BackingField;
private char? \u003CModel\u003Ek__BackingField;
private int? \u003CYear\u003Ek__BackingField;
public CarRecordStructMutable2(char? Brand, char? Model, int? Year)
{ }
public char? Brand
{
readonly get { return this.\u003CBrand\u003Ek__BackingField; }
set { this.\u003CBrand\u003Ek__BackingField = value; }
}
public char? Model
{
readonly get { return this.\u003CModel\u003Ek__BackingField; }
set { this.\u003CModel\u003Ek__BackingField = value; }
}
public int? Year
{
readonly get { return this.\u003CYear\u003Ek__BackingField; }
set { this.\u003CYear\u003Ek__BackingField = value; }
}
public override readonly string ToString()
{ }
private readonly bool PrintMembers(StringBuilder builder)
{ }
public static bool op_Inequality(
PositionalSyntax.CarRecordStructMutable2 left,
PositionalSyntax.CarRecordStructMutable2 right)
{ }
public static bool op_Equality(
PositionalSyntax.CarRecordStructMutable2 left,
PositionalSyntax.CarRecordStructMutable2 right)
{ }
public override readonly int GetHashCode()
{ }
public override readonly bool Equals(object obj)
{ }
public readonly bool Equals(PositionalSyntax.CarRecordStructMutable2 other)
{ }
public readonly void Deconstruct(out char? Brand, out char? Model, out int? Year)
{ }
}
public readonly struct CarRecordStructImmutable2 :
IEquatable<PositionalSyntax.CarRecordStructImmutable2>
{
private readonly char? \u003CBrand\u003Ek__BackingField;
private readonly char? \u003CModel\u003Ek__BackingField;
private readonly int? \u003CYear\u003Ek__BackingField;
public CarRecordStructImmutable2(char? Brand, char? Model, int? Year)
{ }
public char? Brand
{
get { return this.\u003CBrand\u003Ek__BackingField; }
init { this.\u003CBrand\u003Ek__BackingField = value; }
}
public char? Model
{
get { return this.\u003CModel\u003Ek__BackingField; }
init { this.\u003CModel\u003Ek__BackingField = value; }
}
public int? Year
{
get { return this.\u003CYear\u003Ek__BackingField; }
init { this.\u003CYear\u003Ek__BackingField = value; }
}
public override string ToString()
{ }
private bool PrintMembers(StringBuilder builder)
{ }
public static bool op_Inequality(
PositionalSyntax.CarRecordStructImmutable2 left,
PositionalSyntax.CarRecordStructImmutable2 right)
{ }
public static bool op_Equality(
PositionalSyntax.CarRecordStructImmutable2 left,
PositionalSyntax.CarRecordStructImmutable2 right)
{ }
public override int GetHashCode()
{ }
public override bool Equals(object obj)
{ }
public bool Equals(PositionalSyntax.CarRecordStructImmutable2 other)
{ }
public void Deconstruct(out char? Brand, out char? Model, out int? Year)
{ }
}
public class CarRecordClassImmutable2 :
IEquatable<PositionalSyntax.CarRecordClassImmutable2>
{
private readonly char? \u003CBrand\u003Ek__BackingField;
private readonly char? \u003CModel\u003Ek__BackingField;
private readonly int? \u003CYear\u003Ek__BackingField;
public CarRecordClassImmutable2(char? Brand, char? Model, int? Year)
{ }
protected virtual Type EqualityContract
{ }
public char? Brand
{
get { return this.\u003CBrand\u003Ek__BackingField; }
init { this.\u003CBrand\u003Ek__BackingField = value; }
}
public char? Model
{
get { return this.\u003CModel\u003Ek__BackingField; }
init { this.\u003CModel\u003Ek__BackingField = value; }
}
public int? Year
{
get { return this.\u003CYear\u003Ek__BackingField; }
init { this.\u003CYear\u003Ek__BackingField = value; }
}
public override string ToString()
{ }
protected virtual bool PrintMembers(StringBuilder builder)
{ }
public static bool op_Inequality(
PositionalSyntax.CarRecordClassImmutable2 left,
PositionalSyntax.CarRecordClassImmutable2 right)
{ }
public static bool op_Equality(
PositionalSyntax.CarRecordClassImmutable2 left,
PositionalSyntax.CarRecordClassImmutable2 right)
{ }
public override int GetHashCode()
{ }
public override bool Equals(object obj)
{ }
public virtual bool Equals(PositionalSyntax.CarRecordClassImmutable2 other)
{ }
public virtual PositionalSyntax.CarRecordClassImmutable2 \u003CClone\u003E\u0024()
{ }
protected CarRecordClassImmutable2(PositionalSyntax.CarRecordClassImmutable2 original)
{ }
public void Deconstruct(out char? Brand, out char? Model, out int? Year)
{ }
}
9. Nondestructive Mutation
If you want to reuse an Immutable record, you are free to reference it as many times as you want, because is it guaranteed not to change. But what if you want to reuse some of the data of an Immutable record, but modify it a bit? That is why they invented Nondestructive Mutation. In C# language, now you can use the with
keyword to do it. Typically, you would want to preserve most of the state of an Immutable record, but change just some properties.
public readonly record struct CarRecordStructImmutable2
(Char? Brand, Char? Model, int? Year);
public record class CarRecordClassImmutable2(Char? Brand, Char? Model, int? Year);
Console.WriteLine("-----");
Console.WriteLine("Nondestructive Mutation of immutable record struct object");
CarRecordStructImmutable2 car7 = new CarRecordStructImmutable2('T', 'C', 1991);
CarRecordStructImmutable2 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();
Console.WriteLine("-----");
Console.WriteLine("Nondestructive Mutation of immutable record class object");
CarRecordClassImmutable2 car1 = new CarRecordClassImmutable2('T', 'C', 1991);
CarRecordClassImmutable2 car2 = car1 with { Brand = 'p' };
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();
Console.ReadLine();
10. Deconstructing Records
As can be seen above, Records defined with Positional Syntax also have an automatically generated Deconstruct()
method. It behaves as one would expect, similar to many other cases of “deconstruction” in C#.
Records that are not defined with “Positional Syntax”, but with “record class” or “record struct” prefixes, do not have the Deconstruct()
method unless the programmer explicitly defines one.
Here are some examples:
public record struct CarRecordStructMutable2(Char? Brand, Char? Model, int? Year);
public readonly record struct CarRecordStructImmutable2
(Char? Brand, Char? Model, int? Year);
public record class CarRecordClassImmutable2(Char? Brand, Char? Model, int? Year);
Console.WriteLine("-----");
CarRecordStructMutable2 car6 = new CarRecordStructMutable2('A', '1', 2000);
CarRecordStructImmutable2 car7 = new CarRecordStructImmutable2('B', '2', 2001);
CarRecordClassImmutable2 car8 = new CarRecordClassImmutable2('C', '3', 2002);
var (a1, b1, c1) = car6;
Console.WriteLine($"State: car6={car6}");
Console.WriteLine($"a1:{a1}, b1:{b1}, c1:{c1}");
car7.Deconstruct(out char? a2, out char? b2, out int? c2);
Console.WriteLine($"State: car7={car7}");
Console.WriteLine($"a2:{a2}, b2:{b2}, c2:{c2}");
(char? a3, char? b3, int? c3)= car8;
Console.WriteLine($"State: car8={car8}");
Console.WriteLine($"a3:{a3}, b3:{b3}, c3:{c3}");
11. Topics Related to Records Not Covered
This is just a basic introduction to Records, and to keep this article of manageable size, some topics are not covered. They are:
12. Conclusion
C# Records are an interesting new feature of C#11, but all the concepts behind them have already been seen. C# Records are nothing more than C# language integrated support for the “Value Object” pattern ([8]) and the “Immutable Object” pattern ([7]). Records exist only in the imagination of the C# compiler and are compiled into regular “classes” and “struct
s”.
The idea to integrate into C# language support for certain patterns is not new. For example, the “Observer pattern” ([6]) is integrated into C# via the usage of the Event mechanism.
Like any other new feature, their usage will propagate over time, and every modern C# programmer needs to know them well if nothing else because over time he/she will stumble upon code where they are used. And the last reason, you need to learn and use Records or otherwise, you will be considered a “dinosaur C# programmer” that doesn’t know modern C#.
13. References
History
- 8th February, 2023: Initial version