Reference Semantics Explained
It's tempting to think of variables as boxes that hold data. It works for integers and booleans where assigning x to y creates an independent copy. It works until you assign one object to another and mysteriously both change when you modify one.
Reference types don't store object data directly. Instead, they hold addresses pointing to objects on the managed heap. When you assign one reference variable to another, you're copying the address, not the object. Both variables now point to the same object, so changing that object through either variable affects what the other sees.
You'll learn how reference types allocate memory, why null references exist, how reference equality differs from value equality, when reference sharing helps versus hurts, and how to control object lifetime and mutability.
Classes as Reference Types
Classes are the primary reference types in C#. When you create a class instance with new, the CLR allocates memory on the managed heap and returns a reference to that memory. The variable stores this reference, not the object itself. This reference semantics enables polymorphism, inheritance, and shared mutable state.
Multiple variables can reference the same object, which lets different parts of your code work with shared data. However, this sharing creates responsibilities around mutation and ownership. When one method changes the object, all holders of that reference see the change immediately.
Here's how reference semantics work with classes, showing that assignment creates shared references to the same underlying object.
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
}
// Reference semantics in action
var person1 = new Person("Alice", 30);
var person2 = person1; // Copies the reference, not the object
person2.Age = 31;
Console.WriteLine($"person1.Age: {person1.Age}"); // 31
Console.WriteLine($"person2.Age: {person2.Age}"); // 31
Console.WriteLine($"Same object: {ReferenceEquals(person1, person2)}"); // True
// Reassignment changes where the reference points
person2 = new Person("Bob", 25);
Console.WriteLine($"person1.Name: {person1.Name}"); // Alice
Console.WriteLine($"person2.Name: {person2.Name}"); // Bob
Console.WriteLine($"Same object: {ReferenceEquals(person1, person2)}"); // False
After person2.Age = 31, both variables show age 31 because they reference the same Person object. When person2 gets reassigned to a new Person, it points to a different object while person1 still points to the original. ReferenceEquals confirms whether two variables point to the exact same object.
Delegates as Method References
Delegates are reference types that hold references to methods instead of data. They enable callbacks, event handling, and functional programming patterns in C#. A delegate type defines a method signature, and delegate instances can reference any method matching that signature.
Because delegates are reference types, they can be null, stored in collections, and passed around like any other object. This makes them perfect for scenarios where you need to pass behavior as a parameter or store operations for later execution.
public delegate int MathOperation(int a, int b);
public class Calculator
{
public static int Add(int a, int b) => a + b;
public static int Multiply(int a, int b) => a * b;
public int Execute(int x, int y, MathOperation operation)
{
return operation(x, y);
}
}
// Using delegates
var calc = new Calculator();
MathOperation addDelegate = Calculator.Add;
MathOperation multiplyDelegate = Calculator.Multiply;
int sum = calc.Execute(5, 3, addDelegate);
int product = calc.Execute(5, 3, multiplyDelegate);
Console.WriteLine($"Sum: {sum}"); // 8
Console.WriteLine($"Product: {product}"); // 15
// Delegates can be null
MathOperation nullDelegate = null;
if (nullDelegate != null)
{
nullDelegate(1, 2); // Safe because of null check
}
// Lambda expressions create delegate instances
MathOperation subtract = (a, b) => a - b;
Console.WriteLine($"Difference: {calc.Execute(10, 4, subtract)}"); // 6
The Execute method accepts any MathOperation delegate, making it flexible for different calculations. Lambda expressions provide a concise syntax for creating delegate instances inline. Always check delegates for null before invoking unless you're certain they're assigned.
Interfaces and Reference Semantics
Interfaces are reference types that define contracts without implementation. When you cast a value type to an interface, boxing occurs because interfaces are reference types. Classes and structs can implement interfaces, but only classes naturally align with reference semantics.
Interface variables hold references to objects that implement the interface. This enables polymorphism where you write code against abstractions rather than concrete types. The CLR resolves interface calls at runtime through virtual dispatch.
public interface ILogger
{
void Log(string message);
}
public class ConsoleLogger : ILogger
{
public void Log(string message)
{
Console.WriteLine($"[Console] {message}");
}
}
public class FileLogger : ILogger
{
private readonly string _path;
public FileLogger(string path)
{
_path = path;
}
public void Log(string message)
{
// Write to file
Console.WriteLine($"[File:{_path}] {message}");
}
}
// Polymorphism through interfaces
ILogger logger = new ConsoleLogger();
logger.Log("Application started");
logger = new FileLogger("app.log");
logger.Log("User logged in");
// Collection of different implementations
List<ILogger> loggers = new()
{
new ConsoleLogger(),
new FileLogger("debug.log")
};
foreach (var log in loggers)
{
log.Log("Processing batch");
}
The logger variable holds references to different ILogger implementations. Changing which implementation it references changes behavior without changing the calling code. This polymorphism is fundamental to dependency injection and clean architecture patterns.
Null References and Nullable Reference Types
Reference types can naturally be null because the variable holds an address that can point nowhere. Dereferencing a null reference throws NullReferenceException, one of the most common runtime errors in .NET applications. Nullable reference types in C# 8+ help catch these issues at compile time.
With nullable reference types enabled, the compiler warns when you might dereference null or assign null to a non-nullable variable. This feature doesn't change runtime behavior but adds static analysis to prevent null reference bugs before they ship.
// Enable nullable reference types in .csproj:
// enable
public class User
{
public string Name { get; set; } // Non-nullable
public string? Email { get; set; } // Nullable
public User(string name)
{
Name = name; // Must initialize non-nullable properties
}
public int GetEmailLength()
{
// Compiler warns: possible null reference
// return Email.Length;
// Safe patterns
if (Email != null)
{
return Email.Length;
}
return Email?.Length ?? 0; // Null-conditional with null-coalescing
}
}
// Usage
User user1 = new User("Alice");
user1.Email = "alice@example.com";
User? user2 = null; // Explicitly nullable
if (user2 != null)
{
Console.WriteLine(user2.Name);
}
// Null-forgiving operator when you know it's safe
User user3 = GetUserFromCache()!; // ! suppresses warning
User GetUserFromCache() => new User("Bob");
The ? annotation marks Email as nullable while Name remains non-nullable. The compiler enforces null checks before accessing nullable properties. Use null-conditional operators and null-coalescing for safe navigation. The null-forgiving operator ! tells the compiler you guarantee a value isn't null.
Reference Equality vs Value Equality
By default, reference types use reference equality where two variables are equal only if they point to the exact same object. Value equality compares the actual data in objects. Understanding this distinction prevents bugs where you expect value comparison but get reference comparison.
Override Equals and GetHashCode to implement value equality for custom classes. Records provide value equality automatically, making them ideal for data transfer objects and immutable models.
public class PersonClass
{
public string Name { get; init; }
public int Age { get; init; }
}
public record PersonRecord(string Name, int Age);
// Reference equality with classes
var person1 = new PersonClass { Name = "Alice", Age = 30 };
var person2 = new PersonClass { Name = "Alice", Age = 30 };
Console.WriteLine(person1 == person2); // False (different references)
Console.WriteLine(person1.Equals(person2)); // False
Console.WriteLine(ReferenceEquals(person1, person2)); // False
// Value equality with records
var record1 = new PersonRecord("Alice", 30);
var record2 = new PersonRecord("Alice", 30);
Console.WriteLine(record1 == record2); // True (value equality)
Console.WriteLine(record1.Equals(record2)); // True
Console.WriteLine(ReferenceEquals(record1, record2)); // False
// Same reference
var person3 = person1;
Console.WriteLine(person3 == person1); // True (same reference)
Console.WriteLine(ReferenceEquals(person3, person1)); // True
Two PersonClass instances with identical data aren't equal because == checks references by default. PersonRecord instances with the same data are equal because records implement value equality. Use records when you need value semantics without writing equality boilerplate.
See It in Action
This example demonstrates reference semantics, delegates, and equality comparisons working together.
Steps
- dotnet new console -n ReferenceTypes
- cd ReferenceTypes
- Update Program.cs with the code shown
- dotnet run
// Reference sharing
var account = new Account { Balance = 100m };
var aliasAccount = account;
aliasAccount.Balance += 50m;
Console.WriteLine($"Original balance: {account.Balance}");
// Delegates
Func<decimal, decimal> tax = amount => amount * 0.08m;
Console.WriteLine($"Tax on $100: ${tax(100m):F2}");
// Value equality with records
var addr1 = new Address("123 Main St", "Seattle");
var addr2 = new Address("123 Main St", "Seattle");
Console.WriteLine($"Addresses equal: {addr1 == addr2}");
class Account
{
public decimal Balance { get; set; }
}
record Address(string Street, string City);
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Console
The output shows both accounts reflecting the same balance because they reference the same object, calculated tax using a delegate, and record-based value equality confirmation.
Managing Reference Lifecycles
Reference types live on the heap until the garbage collector determines they're no longer reachable. An object becomes eligible for collection when no live references point to it. Understanding this helps you avoid memory leaks and manage resources properly.
Use weak references when you want to hold a reference without preventing garbage collection. This is useful for caching where you'll recreate objects if needed but allow collection under memory pressure. The WeakReference class enables this pattern.
Implement IDisposable for types that hold unmanaged resources like file handles or database connections. The using statement ensures Dispose gets called even when exceptions occur. Don't rely on finalizers for critical cleanup because garbage collection timing is non-deterministic.
Avoid capturing references in long-lived closures or event handlers without cleanup. These create unintended object lifetime extensions that prevent garbage collection. Always unsubscribe from events and clear delegate references when you're done with them.