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 types | Value 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, … |
string | bool, char |
T[] (arrays) | (int X, int Y) (tuples) |
dynamic | int?, 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 kind | b = a |
|---|---|
| Reference | Both a and b designate the same heap object. |
| Value | b 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,
==andEqualstest reference equality by default. - For value types,
Equalstests structural (member-by-member) equality by default;==is undefined unless overloaded. - For
string(a reference type with overriddenEqualsand==), both compare contents. - For records (any kind), both
==andEqualstest 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
objectparameters. - 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-
reftype. - May not be boxed (cannot be assigned to
objector to a non-refinterface variable). - May not be captured by lambdas,
asyncmethods, 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:
| Modifier | Purpose |
|---|---|
ref | Pass by reference; caller’s variable may be read and written. |
out | Pass by reference, write-only; caller’s variable need not be initialised; method must assign before returning. |
in | Pass 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:
reffor output-modification of struct values that need to be both read and written.outfor multi-value returns (less idiomatic in modern code; tuples are preferable).infor 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:
| Operation | Reference type | Value type |
|---|---|---|
| Allocation | Heap (GC tracked) | Stack or inline |
| Assignment | Reference copy (one word) | Field-by-field copy |
| Equality | Reference comparison (default) | Structural comparison (default) |
| Method call | Through the v-table or method table | Direct |
| Storage in a collection | Reference (one word per element) | Inline (sizeof(T) per element) |
The conventional advice:
- Use
classfor entities (objects with identity, large structures, polymorphism). - Use
structfor small, value-like types (points, dates, colors, money values) where copying is cheap and structural equality is desired. - Use
record classwhen you want a reference type with structural equality. - Use
record structwhen you want a small value type with structural equality. - Use
readonly structwhenever possible; immutable structs interact best with the optimiser. - Use
ref structfor stack-only types;Span<T>andReadOnlySpan<T>are the principal examples.
Common defects
Recurring confusions:
| Defect | Description |
|---|---|
| Mutating a struct in a collection | list[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 foreach | Iterating a List<int> as IEnumerable boxes each element. Use the concrete List<int> enumerator. |
| Default equality on classes | Two new Document { Title = "x" } instances are not equal by default. Override Equals and GetHashCode or use a record. |
| Passing a large struct by value | A struct of 64 bytes passed to many methods incurs the copy each call. Use in for read-only access. |
| Mutable struct surprises | var 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.