C#: Records, Classes and Structures
What Are They?
C# provides various ways to define the structure and behavior of data in your programs, including record classes and structures. These concepts help you manage and manipulate data efficiently.
What Are Types, Classes, and Structs?
Types
In programming, a program contains both code and data. Variables hold this data, and the kind of data a variable holds is known as its type or data type.
Types can be categorized into:
Key Concepts
Built-in Types
Built-in types, also known as primitive types, come predefined in the C# language. These types include:
User-defined Types
User-defined types are customized structures created by programmers to meet specific needs. These include:
As mentioned above classes and structs are way of defining user defined types. A class is reference type where a struct is a value type.
So, what are reference types and value types?
It is about what the variable holds.
Reference type: Here the variable hold the reference to the object (not the object itself). A reference is the address of the data being referenced by the variable.
Value type: Here the variable holds the data itself. Not the address of the data.
When to Use Structure / Class?
Structs are a good choice for smaller, lightweight data types. Here’s why:
Why Smaller?
Although it is technically possible to create large data types with structs, doing so is generally a bad choice. Here’s why:
Why Classes for Larger Data Types?
Structs avoid this overhead for small, lightweight data types, making them a more efficient choice in these cases.
So basically structs are value types for light weight data type. Classes are for more complex data types
Few interesting facts
stucts do not support inheritance compared to classes beacuse it is supposed to simple.
Garbage collection is for reference types so there will be no garbage collection for structs.
No parameterless constuctor is allowed for structs.
Let us look at something interesting
let us say we had defined two types PointS and PointC as below
public struct PointS
{
public int X, Y;
}
public class PointC
{
public int X, Y;
}
now let us look at the instruction below
PointS ps;
PointC pc;
ps.X =100; // works
pc.Y =100; // Should not compile. Why?
what is interesting here is that as struct is value type. The variable ps holds the data itself means PointS struct has to be created at this line and the variable ps holds it.
On the other hand pc is a reference types and refers no PointC object(null). So the compiler is smart enough to know that there will be an error. Hope it is clear.
Another strange code you may see in struct definition is that you can assign this pointer (current instance of the struct).
void Reset()
{
this = new Point();
}
Record
introduced in C# 9, are a modern approach to creating concise and immutable data structures, but they go beyond mere syntactic sugar. Let’s break it down.
Types of Equality
In .NET, we have two primary types of equality:
Records Overview
Records in C# streamline the process of creating data-holding classes. They are marked by the record keyword and offer several advantages:
Let us look at each points
Look at record class definition
public record Person(string FirstName, string LastName, int Age);
This translates to:
public class Person : IEquatable<Person>
{
public string FirstName { get; init; }
public string LastName { get; init; }
public int Age { get; init; }
public Person(string firstName, string lastName, int age)
{
FirstName = firstName;
LastName = lastName;
Age = age;
}
public override bool Equals(object obj)
{
if (!(obj is Person))
return false;
Person p = (Person)obj;
return Equals(p);
}
public override int GetHashCode()
{
return HashCode.Combine(FirstName, LastName, Age);
}
public bool Equals(Person other)
{
return FirstName == other.FirstName &&
LastName == other.LastName &&
Age == other.Age;
}
public static bool operator ==(Person lhs, Person rhs)
{
return lhs.Equals(rhs);
}
public static bool operator !=(Person lhs, Person rhs)
{
return !(lhs == rhs);
}
public void Deconstruct(out string firstName, out string lastName, out int age)
{
firstName = FirstName;
lastName = LastName;
age = Age;
}
}
A record class can also be written as just record like below.
Recommended by LinkedIn
public record Person(string FirstName, string LastName, int Age);
Now, we may look record struct definition which was introduced in C# 10.
public record struct Person(string FirstName, string LastName, int Age);
and translates to:
public struct Person : IEquatable<Person>
{
public string FirstName { get; set; }
public string LastName { get; set; }
public int Age { get; set; }
public Person(string firstName, string lastName, int age)
{
FirstName = firstName;
LastName = lastName;
Age = age;
}
public override bool Equals(object obj)
{
if (!(obj is Person))
return false;
Person p = (Person)obj;
return Equals(p);
}
public override int GetHashCode()
{
return HashCode.Combine(FirstName, LastName, Age);
}
public bool Equals(Person other)
{
return FirstName == other.FirstName &&
LastName == other.LastName &&
Age == other.Age;
}
public static bool operator ==(Person lhs, Person rhs)
{
return lhs.Equals(rhs);
}
public static bool operator !=(Person lhs, Person rhs)
{
return !(lhs == rhs);
}
public void Deconstruct(out string firstName, out string lastName, out int age)
{
firstName = FirstName;
lastName = LastName;
age = Age;
}
}
What is interesting here is that default behavior of record struct is mutable. To make it immutable we have to use readonly modifier like below
public readonly record struct Person(string FirstName, string LastName, int Age);
this translates to:
public readonly struct Person : IEquatable<Person>
{
public string FirstName { get; }
public string LastName { get; }
public int Age { get; }
public Person(string firstName, string lastName, int age)
{
FirstName = firstName;
LastName = lastName;
Age = age;
}
public override bool Equals(object obj)
{
if (!(obj is Person))
return false;
Person p = (Person)obj;
return Equals(p);
}
public override int GetHashCode()
{
return HashCode.Combine(FirstName, LastName, Age);
}
public bool Equals(Person other)
{
return FirstName == other.FirstName &&
LastName == other.LastName &&
Age == other.Age;
}
public static bool operator ==(Person lhs, Person rhs)
{
return lhs.Equals(rhs);
}
public static bool operator !=(Person lhs, Person rhs)
{
return !(lhs == rhs);
}
public void Deconstruct(out string firstName, out string lastName, out int age)
{
firstName = FirstName;
lastName = LastName;
age = Age;
}
}
Note: C# 12 introduced primary constructors for structs and classes.
But there is a difference, parameters are not converted to public
properties in the constructor. So it is recommended to use cammelCase
naming convention in primary constructors for classes and structs and
PascalCase convention in records.
2. Value equality
Compiler synthesizes several methods (like Equals, GetHashCode, ==, !=, etc) to implement value equality as shown above. We can not override Equals, operator ==, operator !=. This ensures that value equality will be forced. Two objects are equal if they are of same type and all properties have same values.
We can add additional properties other than positional parameters in the constructor like below
public record Person(string FirstName, string LastName, int Age)
{
public int Height{ get; init; } // Immutable property.
public int Weight{ get; set; } // Mutable property.
}
These additional properties can be mutable as well. Compiler synthesis Equals method based on all properties defined like below.
public override bool Equals(Personobj)
{
return FirstName == other.FirstName &&
LastName == other.LastName &&
Age == other.Age &&
Height == other.Height &&
Weight == other.Weight;
}
3. Immutability
Immutable objects are type of objects whose state can not be changed once created. So, immutable objects has many advantages like it is inherently thread safe. An immutable object can be shared with multiple threads without synchronization. Immutable objects are easy to cache and reuse. Immutable objects offers consistency and reliability which is a very useful property in distributed systems where different nodes and replicas do not need to worry about the stale data or modified state.
Note: Positional properties of record struct is mutable.
Use readonly record struct to make positional properties immutable.
4. Concise syntax for non destructive mutation
By using with expression we can achieve non destructive mutation as given below.
var person1 = new Person("John", "Doe", 30);
var person2 = person1 with { Age = 31 };
Here, person2 has same values of person1 for all properties except Age which is set to 31.
Note: With expression can also be used with struct and anonymous type.
Additional Points
ToString method
Compiler synthesizes a ToString method which provides a formatted string of the record which will be very useful in debugging.
Deconstruct method
Compiler synthesizes a Deconstruct method that helps to deconstruct the record as below. Implementation of Deconstruct method is based on positional parameters present in the primary constructor. Any explicitly added properties are not included in the Deconstruct method.
var person1 = new Person("John", "Doe", 30);
var (firstName, lastName, age) = p1;
Inheritance
A record class (or record) can be inherited like below.
public record Employee (string FirstName, string LastName, int Age, decimal Salary): Person(FirstName, LastName, Age);
Note: A record struct can not inherit from another record struct because
struct do not support inheritance.
A recordcan not inherit from a class and class can not inherit from a record class (or record).
If the underlying type of a record class (or record) is class. Why is not supported? Because with record modifier, compiler synthesizes many methods. You can not override Equals, you can not implement operator ==, etc..
When to use records?
DTOs (Data Transfer Objects) :
Record has a Concise syntax for creating records consider the below line of code
public record Person(FirstName, LastName, Age);
compared to
public class Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
public int Age { get; init; }
}
The first one is concise and also offers immutability and consistency which makes it a better choice for DTOs.
2. Value Objects.
For creating value objects like Money where the value equality makes sense. Records are a good choice because it enforces value equality
3. Immutable Objects
Record class (or record) and readonly record struct are immutable. They are good choices for immutable objects.
4. Read-Only Data
Because of the immutability they are also good options for readonly data like configuration.
5. Deconstruction
As mentioned above records inherently support deconstruction.
Entity Data Models ?
What about entity data models? Are they a good choice? The answer is NO.
Each entity is unique. At least they differ by key or Id. Value based equality does not make much sense here.
Properties of entities should be mutable because the state of entity may change. Think about updating an entity. So immutability makes record a bad choice here.
Some documentation considers records as a separate data type. That is not entirely true. It is a modifier applied to class or struct. With concise syntax, the compiler synthesizes many methods and properties as described.
Very informative