Chuyển tới nội dung chính

Structs and Records

Definition

  • Structs are lightweight value types suitable for small data structures that have value semantics. They are allocated on the stack (in most cases) and copied by value.
  • Records (C# 9+) are reference or value types that provide value-based equality, immutability-friendly syntax, and non-destructive mutation via with-expressions.

Core Concepts

Structs

Structs are value types. When you assign a struct, the entire contents are copied.

Primitive Types Are Structs

Many C# primitive types are actually structs under the hood:

C# KeywordActual TypeCategory
intSystem.Int32Struct
longSystem.Int64Struct
floatSystem.SingleStruct
doubleSystem.DoubleStruct
decimalSystem.DecimalStruct
boolSystem.BooleanStruct
charSystem.CharStruct
byteSystem.ByteStruct
stringSystem.StringClass
objectSystem.ObjectClass

This is why these types have methods and properties (e.g., int.MaxValue, "hello".Length).

public struct Point
{
public double X { get; }
public double Y { get; }

public Point(double x, double y) => (X, Y) = (x, y);

public double DistanceTo(Point other) =>
Math.Sqrt(Math.Pow(X - other.X, 2) + Math.Pow(Y - other.Y, 2));
}

Readonly Structs (C# 7.2)

Marking a struct readonly guarantees the compiler that no member modifies state.

public readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }

public Money(decimal amount, string currency) => (Amount, Currency) = (amount, currency);

public Money Add(Money other) =>
Currency == other.Currency
? new Money(Amount + other.Amount, Currency)
: throw new InvalidOperationException("Currency mismatch");
}

Constructor Field Initialization (Pre-C# 11 vs C# 11+)

In a custom struct constructor (before C# 11), all fields must be explicitly assigned — leaving even one unassigned causes a compile error:

public struct Time
{
private int _hours, _minutes, _seconds;

// Pre-C# 11: must assign ALL fields, or compile error
public Time(int hours, int minutes, int seconds)
{
_hours = hours;
_minutes = minutes;
_seconds = seconds; // omitting this would cause CS0171
}
}

From C# 11, the compiler automatically initializes any field not explicitly assigned to its default value (0, null, etc.) within a constructor. This eliminates the need to manually zero-initialize every field.

Default Constructor

Structs always have an implicit parameterless constructor (even if you define other constructors) that zero-initializes all fields. You cannot define your own parameterless constructor before C# 10 — and even in C# 10+, it must initialize all fields.

Value-Type Copy Behavior

Because structs are value types, passing one to a method copies the entire struct. Modifications inside the method do not affect the original:

public struct Counter
{
public int Value { get; set; }
public Counter(int value) => Value = value;
}

var c = new Counter(10);
Console.WriteLine(c.Value); // 10

ChangeValue(c);
Console.WriteLine(c.Value); // Still 10 — struct was copied

static void ChangeValue(Counter counter) => counter.Value = 99;

Use ref or out parameters if you need the method to modify the original struct.

Records

Records combine the best of classes and value semantics — they use value-based equality (two records are equal if all their data is equal).

Record Classes (C# 9+)

// Positional record — concise syntax
public record Person(string FirstName, string LastName, int Age);

// Usage
var p1 = new Person("Alice", "Smith", 30);
var p2 = new Person("Alice", "Smith", 30);

Console.WriteLine(p1 == p2); // True — value-based equality
Console.WriteLine(ReferenceEquals(p1, p2)); // False — different references

With-Expressions (Non-Destructive Mutation)

var original = new Person("Alice", "Smith", 30);
var updated = original with { Age = 31 }; // creates a new copy with one field changed

Deconstruction

var person = new Person("Alice", "Smith", 30);
var (first, last, age) = person; // deconstructs positional parameters

Record Structs (C# 10+)

public record struct Point(double X, double Y);

// Readonly record struct
public readonly record struct Money(decimal Amount, string Currency);

Code Examples

Mutable Struct Pitfall

public struct BadCounter
{
public int Value;

public void Increment() => Value++; // mutates in-place
}

BadCounter c = new BadCounter();
c.Increment();
Console.WriteLine(c.Value); // 1

List<BadCounter> list = new() { new BadCounter() };
list[0].Increment(); // MODIFIES A COPY — Value stays 0!
Console.WriteLine(list[0].Value); // 0 — bug!
Mutable Structs

Mutable structs cause subtle bugs when used in collections, behind interfaces, or in using blocks. Always design structs as immutable. Use readonly struct to enforce this at compile time.

Record with Additional Members

public record Student(string FirstName, string LastName, double Gpa)
{
public string FullName => $"{FirstName} {LastName}";
public virtual bool Equals(Student? other) =>
other is not null && FirstName == other.FirstName && LastName == other.LastName;
}

Primary Constructors for Classes (C# 12)

C# 12 introduced primary constructors for regular classes. Unlike records, these classes do not get value-based equality or with-expressions.

// Class with primary constructor — NOT a record
public class Product(string name, decimal price)
{
public string Name { get; } = name;
public decimal Price { get; } = price;

public override string ToString() => $"{Name}: {price:C}";
}

var p1 = new Product("Widget", 9.99m);
var p2 = new Product("Widget", 9.99m);
Console.WriteLine(p1 == p2); // False — reference equality, not value-based
Primary Constructors vs Records

A class with a primary constructor is still a regular class — it uses reference equality and has no with-expression support. Use record when you need value-based semantics.

Comparison Table

Featureclassstructrecord classrecord struct
TypeReferenceValueReferenceValue
EqualityReferenceValue (bitwise)Value-basedValue-based
InheritanceYes (single)NoYes (single)No
Can be mutableYesYes (but avoid)Yes (but avoid)Yes (but avoid)
with expressionNoNoYesYes
Primary use caseComplex behaviorSmall data (< 16 bytes)DTOs, value objectsSmall value objects

When to Use Which

Decision Guidance

  1. Need inheritance and complex behavior? Use a class.
  2. Small (< 16 bytes), short-lived, no boxing expected? Use a struct (preferably readonly struct).
  3. Immutable data, value-based equality, DTOs, or DDD value objects? Use a record (record class or record struct).
  4. Small immutable value object that needs with? Use a readonly record struct.

When to Use a Class

  • The type represents an entity with identity (e.g., User, Order).
  • You need inheritance hierarchies.
  • The object is large or long-lived.
  • Reference semantics are desirable.

When to Use a Struct

  • The type is small (Microsoft recommends under 16 bytes).
  • It represents a single value (e.g., Point, Color, Money).
  • It is short-lived and commonly allocated in method scope.
  • You want to avoid heap allocation and GC pressure.

When to Use a Record

  • DTOs, API request/response models.
  • Domain value objects (DDD).
  • Immutable data that you need to copy with minor changes (with).
  • You want structural equality out of the box.

Common Pitfalls

  • Boxing structs — casting a struct to an interface or object allocates on the heap. In hot paths, this causes GC pressure.
  • Mutable structs in collections — modifying a struct through a collection indexer modifies a copy, not the original. Always use readonly struct.
  • Records are not always immutable — you can add set accessors or mutable fields to a record. Immutability is a design choice, not enforced.
  • Structs cannot inherit from other structs — they can only implement interfaces.
  • Default constructor of a struct — always initializes all fields to zero/null. You cannot prevent this.
public struct Temperature
{
public decimal Value { get; } // default(Temperature).Value == 0, even if 0 is invalid
}

Key Takeaways

  • Structs are value types — use for small, immutable data where copy semantics are desired.
  • Records provide value-based equality and with-expressions — ideal for DTOs and value objects.
  • Prefer readonly struct to guarantee struct immutability at compile time.
  • C# 12 primary constructors for classes do not provide record features (equality, with).
  • Keep structs under 16 bytes to avoid performance penalties from copying.

Interview Questions

Q: When would you use a struct over a class? A: When the type is small (under ~16 bytes), represents a single value, is short-lived, and you want value semantics (copy on assignment). Examples: Point, Color, Guid.

Q: What is a record? A: A record is a reference type (or value type with record struct) that provides value-based equality, non-destructive mutation via with-expressions, and concise positional syntax for immutable data models.

Q: What is value-based equality? A: Two record instances are considered equal if all their fields and properties have the same values, regardless of whether they are the same object in memory. This differs from classes, which use reference equality by default.

Q: What does the with expression do? A: It creates a shallow copy of a record with one or more properties changed, leaving the original unchanged. It is a form of non-destructive mutation.

Q: What is boxing and how does it relate to structs? A: Boxing is the process of converting a value type to a reference type (e.g., casting a struct to object or an interface). This allocates a new object on the heap and copies the struct's data into it. Frequent boxing in hot paths degrades performance through increased GC pressure.

References