(C# 9.0) Record types vs class vs struct type, positional records and non-destructive mutation

THE CRUX: record types are meant for storing read-only, init-once data. They serve the purpose of ValueTypes, but in reality they are reference types; they have best of both the worlds. They, being reference types, can be efficiently passed as function arguments(like class objects), and yet they carry data like structs [though read-only] but the data doesn't get copied during function calls - this promotes efficiency. They are 2-in-1: they are RefTypes but they perform the task of ValueTypes (though read-only, init-once). So with C# 9.0 we have a triad - class, struct and record. Two different instances of records are equal if they contain the same data (like structs, and un-like the classes!) The "positional record" syntax makes it easier to create their objects, and the newly introduced "with" expressions make it even easier to create their exact copies, possibly with just-in-time alterations to some of their properties.
(Rev. 19-Mar-2024)

Categories | About |     |  

Parveen,

First and foremost: the keyword, record, is of the same category as the keywords struct and class. It has been introduced with C# 9.0. Let's study it threadbare!

class vs struct vs record

This is a summary chart of the status of class, struct and record as of now -

ref/value type classification

class: reference type

struct: value type

record: reference type

Member Properties:

class: no restrictions: the properties and data members can be mutable (i.e, alterable) as well as immutable.

struct: no restrictions: the properties and data members can be mutable (i.e, alterable) as well as immutable.

record: they are designed to be immutable, and they indeed are, if positional syntax [explained later] is used for defining a record, or if the setters are explicitly marked non-public. But if the properties have public setters, then they are mutable i.e., they can be changed anytime.

Equality Criterion:

class: two instances are equal if their references are equal.

struct: equality is determined by the equality of values of the data members - but run-time overheads can be there, such as due to reflection. However, a software developer can provide his own over-rides to make the comparison efficient.

record: equality is determined by the equality of values of the data members - the methods and operators for comparison are generated by the compiler, and, therefore, efficient - basically it saves the software developer from writing his own over-rides.

Copy Constructor:

class: there is no compiler provided copy constructor; a software developer has to write his own.

struct: struct values are by default shallow-copied on assignment, passing an arg, or returning from a function call. A software developer can write his own copy constructor, if he needs one, say, for example, for deep copying.

record: complier provides the ability for explicit copy through with expressions. For example: var dest = src with {} creates "dest" as a [shallow, i.e., member-wise] copy of a pre-existing record called "src". Copy constructors are synthesized by the compiler - but they are protected.

Function Arguments and Returns:

class: passed as references, and no copies are created.

struct: passed as a value, shallow copies are created, boxing/ unboxing can occur, unless ref/ out/ in/"ref returns" are specified

record: passed as references, and no copies are created.

Is inheritance supported?:

class: yes

struct: no

record: yes! And the copy through with expressions takes care of copying the whole chain of inherited properties.

ToString () Method

class: available

struct: available

record: available, but PrintMembers is also available, and provides a better formatted, comma-separated, display

Use Case:

class: general

struct: to hold small, related data. Why "small"? because there is an overhead of copying/ boxing/ unboxing during function calls. The best example is the Point structure that holds int x and y co-ordinates

record: concurrent programs with shared data; the record instances are immutable, and safely passed around as references without the overhead of copying, boxing/ unboxing. Can be used to hold large data that can be passed around efficiently.

The Methods of a record that the Compiler Synthesizes

Following is a quick summary of the most important ones:

The operators == and !=
Comparison operators == and != compare the respective values of the properties with regard to their data-types.
GetHashCode
The compiler produces an over-ride of GetHashCode by using the GetHashCode from all the properties of the record, including those from the parent classes! This ensures that a comparison is with regard to not just the values, but also to the types of the records being compared. This method is not directly used by application developers, but the compiler probably uses it for the equality operators.
Copy Constructor
The compiler synthesizes a protected copy constructor. It also creates an internal clone method. The clone method, too, is not directly used by application developers. But the compiler probably uses it for providing the ability to copy records through with expressions.

How to Create a Copy?

Suppose we have a record defined such:

public record PinPoint 
{

  // properties with setter 
  // marked as init - means 
  // init-once 
  public int x { get; init;}

  // property for y 
  public int y { get; init;}

  // ctor 
  public PinPoint(int _x, int _y) => (x, y) = (_x, _y);

}

Below are two examples - the first one creates an exact same copy, and the second one modifies a property during copy -

// a new object 
PinPoint pp1 = new PinPoint(2, 4);

// exact same copy 
// pp2.x will be 2 and pp2.y will be 4 
PinPoint pp2 = pp1 with { };

// copy with modification 
// pp2.x will be 8 and pp2.y will be 4 
PinPoint pp3 = pp1 with { x = 8 };

What is Non-Destructive Mutation?

This term has started getting attention recently after Microsoft C# 9.0 introduced record. There's no big deal about it as you can see below:

In the example discussed above, we have created pp2 and pp3 as mutations [i.e., copies] of pp1. The data of pp1 remains same at x = 2, y = 4 after the copy. There is no impact on pp1 during the copy process - its data remains same at x = 2, and y = 4. It has NOT GIVEN its data - it has created mutations of itself, without affecting itself. This is termed as Non-Destructive Mutation.

What are Positional Records?

Positional records is a syntax of defining a record. The setters on the records are marked as init, to make them init-once.

// a record with just the properties x and y 
// defined with the positional syntax 
public record PinPoint (int x, int y);

What is the Deconstruct Method?

IMPORTANT: Deconstruct method is generated by the compiler - BUT it is done only if the record is defined with the positional records syntax. Otherwise you can create your own Deconstruct method with "out" parameters. Visual Studio 16.9+ has a quick-fix for this - you can check that out.

"Deconstruct" method is used to quickly extract the data out of a record as shown here -

// suppose we have a PinPoint record 
PinPoint p1 = new PinPoint(2, 4);

// extract both the props into px and py 
// the deconstruct method works behind the scenes 
(int px, int py) = p1;

// px is 2, and py is 4 

Inheritance with Records

The following derives a record called BallPoint, with three properties, from a PinPoint. We have used the positional syntax.

public record BallPoint (int x, int y, int z) : PinPoint (x, y);

// create a ballpoint 
BallPoint bp = new BallPoint(2, 3, 4);

What is PrintMembers? What is its use?

PrintMembers is a method similar to the classic ToString method. It is available on a record and displays its alternate string representation. It accepts a StringBuilder as an argument.

PrintMembers can be found useful to dump the contents of a record including the members from the inheritance chain.

// suppose we have a PinPoint record 
BallPoint p1 = new BallPoint(2, 4, 8);

StringBuilder sb = new StringBuilder();

p1.PrintMembers(sb);

Console.WriteLine(sb.ToString());

// dumps the data like thus 
// BallPoint {x = 2, y = 4, z = 8} 

This Blog Post/Article "(C# 9.0) Record types vs class vs struct type, positional records and non-destructive mutation" by Parveen is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.