Codementor Events

Records, Classes, and Structures

Published Apr 15, 2025

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:

  • Built-in Types (Primitive Types): These are predefined in the programming language. Common examples include int, double, char, bool, and string.
  • User-defined Types: These are created by programmers and include classes, structs, enums, interfaces, and delegates.

Key Concepts

Built-in Types

Built-in types, also known as primitive types, come predefined in the C# language. These types include:

  • int: An integer type.
  • double: A double-precision floating point type.
  • char: A character type.
  • bool: A boolean (true/false) type.
  • string: A sequence of characters type.

User-defined Types

User-defined types are customized structures created by programmers to meet specific needs. These include:

  • Classes: Define blueprints for objects with properties, methods, and events.
  • Structs: Similar to classes but typically used for smaller, lightweight objects.
  • Enums: A distinct type consisting of a set of named constants.
  • Interfaces: Define a contract that other types can implement.
  • Delegates: Type-safe function pointers, allowing methods to be passed as parameters.

Classes are reference types, whereas structs are value types.

Reference vs. Value Types

Reference Type

A reference type variable holds the reference to the object (not the object itself). A reference is the address of the data being referenced by the variable.

Value Type

A value type variable holds the data itself, not just the address of the data.

When to Use Structure vs. Class?

Structs are a good choice for smaller, lightweight data types.

Why Smaller?

Although it is technically possible to create large data types with structs, doing so is generally a bad choice. Here’s why:

Stack Overflow Risk

A local variable holding a struct points to a location in the stack. Since structs are value types, the data itself is stored in the stack. If the data type is very large, it may result in a stack overflow.

Efficiency

Structs are more efficient for smaller data types because value type variables are automatically cleaned up when the stack unwinds, unlike reference types, which require garbage collection.

Why Classes for Larger Data Types?

Heap Storage

When a local variable holds a class, the reference to the data is stored in the stack, while the actual data is stored in the heap.

Overhead Management

Reference types have an overhead because the actual data is stored in the heap. Accessing data involves:

  1. Getting the address from the stack.
  2. Changing/reading the data in the heap.

Structs avoid this overhead for small, lightweight data types.

Summary

  • Structs: Value types for lightweight data.
  • Classes: Reference types for more complex data.

Interesting Facts

  • Structs do not support inheritance, as they are meant to be simple.
  • Garbage collection applies only to reference types, so there is no garbage collection for structs.
  • Structs do not allow parameterless constructors.

Example: Struct vs. Class Behavior

public struct PointS
{
    public int X, Y;
}

public class PointC
{
    public int X, Y;
}

PointS ps;
PointC pc;

ps.X = 100; // ✅ Works: structs are value types and allocated on the stack
pc.Y = 100; // ❌ Compile-time error

Since PointS is a value type, the variable ps holds the data itself, meaning a PointS struct is created at this line and ps directly holds its values.

On the other hand, pc is a reference type and does not initially reference any PointC object (it is null). The compiler is smart enough to detect this issue in advance.

Assigning this in a Struct

You might encounter an unusual syntax in struct definitions where this (the current instance) is reassigned:

void Reset()
{
    this = new Point();
}

Records in C

Introduced in C# 9, records offer a modern approach to creating concise and immutable data structures.

Types of Equality in .NET

  • Value Equality: Two variables hold identical values. Common for value types.
  • Reference Equality: Two variables reference the same object. Default for reference types.

Records Overview

Records in C# simplify the process of defining data-holding classes using the record keyword. They offer several advantages:

  • Concise Syntax: The compiler automatically generates properties and methods.
  • Value Equality: Records support value equality by default, meaning that two records are considered equal if their properties match.
  • Immutability: Records are typically immutable unless explicitly modified.
  • Non-Destructive Mutation: The with expression allows you to create modified copies of records without changing the original.

Example: Creating a Record

A record class can be created using a compact syntax:

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 using the compact syntax:

public record Person(string FirstName, string LastName, int Age);

Now, let's look at the record struct definition, which was introduced in C# 10:

public record struct Person(string FirstName, string LastName, int Age);

This 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;
    }
}

By default, record structs are mutable. To make them immutable, use the readonly modifier:

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

C# 12 introduced primary constructors for structs and classes. However, there is a key difference—parameters in primary constructors are not converted into public properties automatically.

Naming Convention Recommendation

To align with best practices:

  • Use camelCase naming for primary constructor parameters in classes and structs.
  • Use PascalCase naming in records for consistency.

Value Equality

The compiler synthesizes several methods (Equals, GetHashCode, ==, !=, etc.) to enforce value-based equality. This ensures that two objects are considered equal if they have the same type and identical property values.

Example: Adding Extra Properties
You can extend a record by adding additional properties beyond the primary constructor parameters:

public record Person(string FirstName, string LastName, int Age)
{
    public int Height { get; init; } // Immutable property.
    public int Weight { get; set; }  // Mutable property.
}

The Equals method will automatically account for all properties:

public override bool Equals(object obj)
{
    return obj is Person other &&
           FirstName == other.FirstName &&
           LastName == other.LastName &&
           Age == other.Age &&
           Height == other.Height &&
           Weight == other.Weight;
}

Immutability

Immutable objects cannot be modified once created, providing several advantages:

  • Thread Safety: Can be shared across multiple threads without synchronization.
  • Efficient Caching: Ideal for data storage and reuse.
  • Consistency & Reliability: Helps maintain data integrity in distributed systems.

Note: Positional properties in a record struct are mutable by default. To enforce immutability, use readonly record struct:

public readonly record struct Person(string FirstName, string LastName, int Age);

Non-Destructive Mutation

The with expression allows modification of a record without changing the original instance:

var person1 = new Person("John", "Doe", 30);
var person2 = person1 with { Age = 31 };

Here, person2 retains all values from person1, except Age, which is updated to 31.

Note: The with expression also applies to structs and anonymous types.


Additional Features

ToString Method

Records automatically synthesize a ToString method, providing a formatted string representation of the record:

var person = new Person("Alice", "Smith", 25);
Console.WriteLine(person.ToString()); 
// Output: Person { FirstName = Alice, LastName = Smith, Age = 25 }

Deconstruct Method

The compiler automatically synthesizes a Deconstruct method, enabling structured decomposition of a record’s properties. The generated Deconstruct method is based solely on the positional parameters in the primary constructor, meaning any explicitly added properties will not be included in the deconstruction.

Example: Using Deconstruct Method

var person1 = new Person("John", "Doe", 30);
var (firstName, lastName, age) = person1;

Here, firstName, lastName, and age will extract the values from person1.


Inheritance

Records support inheritance, allowing record classes to extend other record classes:

public record Employee(string FirstName, string LastName, int Age, decimal Salary) 
    : Person(FirstName, LastName, Age);

Note: Record structs cannot inherit from other record structs because structs inherently do not support inheritance.

Additionally:

  • A record class cannot inherit from a regular class, and vice versa.
  • Records introduce compiler-synthesized methods, including Equals, preventing custom overrides for comparison.

When to Use Records?

1. DTOs (Data Transfer Objects)

Records are well-suited for DTOs due to concise syntax and immutability:

public record Person(string FirstName, string LastName, int Age);

Compared to a traditional class:

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 value equality makes sense, records are a good choice because they enforce 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 their immutability, records are also good options for read-only data, such as 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 a minimum, 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 an entity may change (e.g., updating an entity).

Thus, immutability makes records a bad choice for entity models.


Records: A Modifier, Not a Data Type

Some documentation considers records as a separate data type, but this is not entirely true.
Records are a modifier applied to classes or structs.
With concise syntax, the compiler synthesizes many methods and properties as described.

Discover and read more posts from Adil CP
get started