Polyglot
Languages C# reference types
C# § reference-types

Reference and value types

C# distinguishes reference types — instances live on the managed heap and are referred to through references — from value types — instances are stored inline in the variables, parameters, or fields that hold them. The distinction is foundational; it governs assignment semantics, equality, parameter passing, and the GC’s behaviour. Most newcomers from object-oriented languages with universal reference semantics (Java, Python, Ruby) and from value-only languages (C structs without a heap) need to understand the C# distinction explicitly.

The partition is exhaustive: every type in C# is either a reference type or a value type. The decision is made by the kind of declaration: class and record class declare reference types; struct and record struct declare value types. A small set of built-ins fall into each category by definition: arrays, string, delegates, and interfaces are reference types; the numeric types, bool, char, enum types, and tuples are value types.

The two categories

Examples:

Reference typesValue types
class C { … }struct S { … }
record class R(…)record struct V(…)
interface I { … } (when boxed or held as I)enum E { … }
delegate T D(…)int, long, double, decimal, …
stringbool, char
T[] (arrays)(int X, int Y) (tuples)
dynamicint?, T? for value-type T

Class instances live on the heap

When new constructs a class instance, the runtime allocates space on the managed heap, runs the constructor, and returns a reference — a value that designates the heap-allocated object:

public class Document {
    public string Title { get; set; } = "";
}

Document d1 = new Document { Title = "draft" };
Document d2 = d1;          // copies the reference; d1 and d2 designate the same object
d2.Title = "final";
Console.WriteLine(d1.Title);    // "final" — d1 and d2 are aliases

The variable d1 holds the reference (a CPU-word-sized address-like value); the heap object holds the data. Assigning d2 = d1 copies the reference, not the object: both variables now point to the same heap object, and modification through either is visible through the other.

The reference is implicit in the syntax; C# does not have a separate dereference operator. d2.Title looks the same as a member access on a struct, but the underlying mechanism is to follow the reference and access the field.

Struct instances live where they are declared

When a struct value is constructed, the instance is stored directly in the variable, parameter, or field:

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

Point p1 = new Point { X = 3, Y = 4 };
Point p2 = p1;         // copies the entire struct
p2.X = 0;
Console.WriteLine(p1.X);    // 3 — p1 unaffected

Assigning p2 = p1 copies all the fields. Modification through p2 does not affect p1; the two are independent.

When a struct is a field of a class, the struct lives inside the heap-allocated class instance:

public class Holder {
    public Point Origin = new Point { X = 0, Y = 0 };
}

Holder h = new Holder();    // class instance on the heap; struct field inside it

When a struct is a local variable, it lives on the stack (or in a register, at the JIT’s discretion).

Assignment semantics

The principal distinction is in assignment:

Type kindb = a
ReferenceBoth a and b designate the same heap object.
Valueb is an independent copy of a.

The same distinction applies to method arguments:

void modify_class(Document d) { d.Title = "modified"; }
void modify_struct(Point p)    { p.X = 999; }      // affects only the local copy

Document doc = new Document { Title = "original" };
modify_class(doc);
Console.WriteLine(doc.Title);   // "modified" — the call modified the heap object

Point pt = new Point { X = 0, Y = 0 };
modify_struct(pt);
Console.WriteLine(pt.X);        // 0 — the call modified its own copy

For struct parameters, the conventional way to admit modification is ref:

void modify_struct(ref Point p) { p.X = 999; }

Point pt = new Point { X = 0, Y = 0 };
modify_struct(ref pt);
Console.WriteLine(pt.X);        // 999

The ref modifier on the parameter and the ref qualifier at the call site let the method modify the caller’s variable.

Equality: reference vs value

Reference types and value types differ in their default equality:

// Reference equality (default for class):
Document d1 = new Document { Title = "x" };
Document d2 = new Document { Title = "x" };
bool r_eq = d1 == d2;              // false: different heap objects
bool r_eq2 = d1.Equals(d2);         // false (default Object.Equals)

// Value equality (default for struct):
Point p1 = new Point { X = 3, Y = 4 };
Point p2 = new Point { X = 3, Y = 4 };
bool v_eq = p1.Equals(p2);          // true: same field values
// p1 == p2  // requires the struct to overload ==

The default equality semantics:

  • For reference types, == and Equals test reference equality by default.
  • For value types, Equals tests structural (member-by-member) equality by default; == is undefined unless overloaded.
  • For string (a reference type with overridden Equals and ==), both compare contents.
  • For records (any kind), both == and Equals test structural equality automatically.

To get value-equality semantics on a class type, override Equals, GetHashCode, and overload == and !=. For records, the compiler synthesises all four.

Boxing and unboxing

A value-type instance assigned to a reference-typed variable is boxed: the runtime allocates a heap object that wraps the value, and the reference designates the wrapper:

int    n = 42;
object o = n;          // boxing: heap allocation
int    m = (int)o;     // unboxing: type-checked extraction

Boxing has non-trivial cost (allocation, GC pressure, an indirection on access). The conventional defences:

  • Use generics (List<int>) rather than non-generic collections (ArrayList).
  • Avoid passing value-type instances to methods that take object parameters.
  • Use generic constraints (where T : struct) or interfaces with care; calling an interface method on a struct boxes the struct unless the struct is constrained generically.

Boxing also occurs implicitly for value types implementing interfaces:

public struct Counter : IDisposable {
    public void Dispose() { /* ... */ }
}

Counter c = new Counter();
IDisposable d = c;     // boxing: c is wrapped
d.Dispose();           // call through the boxed wrapper

The case is subtle and has been a source of substantial confusion historically; modern C# code uses generics and in/ref to avoid the box where it matters.

readonly struct and ref struct

Two refinements of the struct type address specific concerns.

readonly struct

A readonly struct forbids mutation: all instance fields are implicitly readonly, and methods that would modify the instance are forbidden:

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

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

    public Point Translate(double dx, double dy)
        => new Point(X + dx, Y + dy);     // returns a new struct, doesn't mutate
}

The benefits:

  • The compiler knows the struct cannot be mutated and may produce more efficient code (avoiding defensive copies).
  • The conventions are clearer: every method is non-mutating by construction.
  • Multi-threaded use is safer: no member can change after construction.

The conventional advice is to mark structs readonly whenever possible; the cases that require mutability are typically better served by classes anyway.

ref struct

A ref struct is a value type that may live only on the stack:

public ref struct LineEnumerator {
    private ReadOnlySpan<char> source;
    // ...
}

The restrictions:

  • May not be a field of a non-ref type.
  • May not be boxed (cannot be assigned to object or to a non-ref interface variable).
  • May not be captured by lambdas, async methods, or iterators.

The principal use is types that wrap Span<T> or other stack-only references; the runtime uses ref struct extensively in the modern allocation-free APIs.

Records briefly

Records are reference types or value types with structural equality and concise syntax:

public record Point(double X, double Y);

Point p1 = new Point(3, 4);
Point p2 = new Point(3, 4);
bool   eq = p1 == p2;        // true: structural equality

Point p3 = p1 with { Y = 0 };    // (3, 0); copy with one field changed

A record (or record class) is a reference type; a record struct is a value type. Both get auto-generated Equals, GetHashCode, ToString, and a primary constructor. The full treatment is in Classes, structs, and records.

Parameter passing: ref, out, in

Three modifiers refine the default by-value parameter passing:

ModifierPurpose
refPass by reference; caller’s variable may be read and written.
outPass by reference, write-only; caller’s variable need not be initialised; method must assign before returning.
inPass by reference, read-only; admits efficient passing of large structs without copying.
void modify(ref int n)        { n = n + 1; }
void produce(out int result)  { result = 42; }
double dot(in Vector3 a, in Vector3 b)  { return a.X * b.X + a.Y * b.Y + a.Z * b.Z; }

The conventional uses:

  • ref for output-modification of struct values that need to be both read and written.
  • out for multi-value returns (less idiomatic in modern code; tuples are preferable).
  • in for large structs that the method only reads — eliminates the copy without admitting modification.

For reference-type parameters, ref admits rebinding the caller’s variable to a different object:

void replace(ref Document d) { d = new Document { Title = "new" }; }

Document doc = new Document { Title = "old" };
replace(ref doc);
Console.WriteLine(doc.Title);    // "new" — the variable was rebound

Without ref, the method could only modify the existing object (through its members); rebinding the caller’s variable requires the explicit ref.

Implications for performance

The reference-vs-value distinction has substantial performance implications:

OperationReference typeValue type
AllocationHeap (GC tracked)Stack or inline
AssignmentReference copy (one word)Field-by-field copy
EqualityReference comparison (default)Structural comparison (default)
Method callThrough the v-table or method tableDirect
Storage in a collectionReference (one word per element)Inline (sizeof(T) per element)

The conventional advice:

  • Use class for entities (objects with identity, large structures, polymorphism).
  • Use struct for small, value-like types (points, dates, colors, money values) where copying is cheap and structural equality is desired.
  • Use record class when you want a reference type with structural equality.
  • Use record struct when you want a small value type with structural equality.
  • Use readonly struct whenever possible; immutable structs interact best with the optimiser.
  • Use ref struct for stack-only types; Span<T> and ReadOnlySpan<T> are the principal examples.

Common defects

Recurring confusions:

DefectDescription
Mutating a struct in a collectionlist[0].X = 5 does not modify the struct in the list; it modifies a temporary copy. The compiler diagnoses for List<T> (the indexer returns by value), but generic helpers may not. Use the [...] indexer that returns by ref or replace the element.
Boxing in a foreachIterating a List<int> as IEnumerable boxes each element. Use the concrete List<int> enumerator.
Default equality on classesTwo new Document { Title = "x" } instances are not equal by default. Override Equals and GetHashCode or use a record.
Passing a large struct by valueA struct of 64 bytes passed to many methods incurs the copy each call. Use in for read-only access.
Mutable struct surprisesvar p = list[0]; p.X = 5; modifies the local p, not the struct in the list. Use readonly struct.

The combination of readonly struct, generic collections, record types, and in/ref parameter modifiers is the contemporary toolkit for getting the reference-vs-value semantics right; the conventional advice is to pick the right kind at declaration and to use the appropriate parameter modifiers.