C# Delegates, Events, and Lambdas
Summary
A delegate in C# is a type that holds a reference to a method with a specific signature. Delegates are the foundation of callbacks, event systems, and functional-style programming in C#. They allow methods to be passed as parameters, stored in variables, and invoked later — making it possible to write flexible, decoupled code without knowing at compile time exactly which method will run.
C# provides three built-in delegate types that cover most practical needs: Action<T> for void-returning methods, Func<T,TResult> for value-returning methods, and Predicate<T> for boolean tests. Lambda expressions provide concise shorthand for creating delegate instances inline. C# events build on delegates to implement the publisher/subscriber pattern in a controlled way.
Key ideas
- A delegate declaration defines a method signature contract — any method that matches the return type and parameter list can be stored in that delegate.
Action<T>is a built-in generic delegate for methods that take parameters and returnvoid.Action(no type parameter) covers parameterless void methods.Func<T, TResult>is a built-in generic delegate for methods that return a value. The last type parameter is always the return type.Predicate<T>is a built-in delegate for methods that take one argument and returnbool— essentiallyFunc<T, bool>.- Multicast delegates combine multiple method references into one delegate. When invoked, all methods in the invocation list fire in order.
- Lambda expressions (
=>) are anonymous method bodies assigned directly to a delegate variable. They can capture variables from the enclosing scope (closures). - Events are delegate fields restricted so that only the declaring class can invoke them, but any class can subscribe (
+=) or unsubscribe (-=).
In practice
Declaring a custom delegate type
public delegate int NumberTransform(int input);
// Usage:
NumberTransform doubler = x => x * 2;
int result = doubler(5); // 10In Unity/C# you rarely need a custom delegate declaration — prefer Action or Func instead, which are already defined in the framework.
Action — void methods
// Action for a void, parameterless method:
Action greet = () => Debug.Log("Hello");
greet();
// Action<T> for a void method with one parameter:
Action<string> log = message => Debug.Log(message);
log("Player died");
// Action<T1, T2> for two parameters:
Action<int, string> report = (count, label) => Debug.Log($"{count} {label}");
report(3, "lives left");Func — value-returning methods
// Func<TResult> — no params, returns a value:
Func<float> getTime = () => Time.time;
// Func<T, TResult> — one param, returns a value:
Func<int, int> square = n => n * n;
int s = square(4); // 16
// Func<T1, T2, TResult> — two params:
Func<float, float, float> clamp = (val, max) => Mathf.Min(val, max);Lambda expressions
A lambda is shorthand for creating a delegate instance. The => operator separates parameter list from body (GoalKicker.com, C# Notes for Professionals, see source-csharp-notes-for-professionals).
// Expression lambda (single expression):
ModifyInt triple = x => x * 3;
// Statement lambda (multiple statements):
Action<int> logAndSquare = x => {
Debug.Log(x);
Debug.Log(x * x);
};Lambdas capture variables from their enclosing scope (closures). Be careful with loop variables captured in lambdas — capture the variable by copying it to a local first if you intend to reuse the lambda later.
Multicast delegates
Delegates support + and - to combine or remove method references. All registered methods fire when the delegate is invoked (GoalKicker.com, C# Notes for Professionals, see source-csharp-notes-for-professionals).
Action announce = () => Debug.Log("First");
announce += () => Debug.Log("Second");
announce(); // prints "First" then "Second"
// Removing a specific handler requires a reference to the original:
Action handler = MyMethod;
announce += handler;
announce -= handler; // removes only that specific referenceC# Events
An event is a multicast delegate field wrapped with access restrictions. External classes can only subscribe (+=) or unsubscribe (-=) — they cannot directly invoke the event or replace it entirely. The declaring class alone can raise it (GoalKicker.com, C# Notes for Professionals, see source-csharp-notes-for-professionals).
public class GameTimer : MonoBehaviour
{
public event Action OnTimerExpired;
public event Action<int> OnScoreChanged;
private void ExpireTimer()
{
OnTimerExpired?.Invoke(); // safe invoke — null-check + call in one expression
}
private void AddScore(int points)
{
OnScoreChanged?.Invoke(points);
}
}
// Subscribing from another class:
public class ScoreDisplay : MonoBehaviour
{
[SerializeField] private GameTimer timer;
private void OnEnable()
{
timer.OnScoreChanged += UpdateDisplay;
}
private void OnDisable()
{
timer.OnScoreChanged -= UpdateDisplay; // always unsubscribe to avoid memory leaks
}
private void UpdateDisplay(int newScore) { /* ... */ }
}The ?.Invoke() pattern (null-conditional invoke) is the safe way to raise events — it checks for null subscribers in a single thread-safe expression and does nothing if nobody is subscribed.
Storing lambdas for later unsubscription
When subscribing with a lambda, you must hold a reference to unsubscribe later. Lambdas are anonymous — two syntactically identical lambdas are not the same delegate instance (GoalKicker.com, C# Notes for Professionals, see source-csharp-notes-for-professionals).
// WRONG — cannot unsubscribe an anonymous lambda:
timer.OnTimerExpired += () => StopGame();
timer.OnTimerExpired -= () => StopGame(); // does nothing
// CORRECT — save the reference:
private Action _onExpired;
private void OnEnable()
{
_onExpired = () => StopGame();
timer.OnTimerExpired += _onExpired;
}
private void OnDisable()
{
timer.OnTimerExpired -= _onExpired;
}Passing delegates as parameters
Delegates make it possible to write methods that accept behaviour as an argument — this is the basis of callbacks and functional-style utilities.
public void ProcessEnemies(List<Enemy> enemies, Action<Enemy> process)
{
foreach (var enemy in enemies)
process(enemy);
}
// Calling site:
ProcessEnemies(allEnemies, e => e.TakeDamage(10));Trade-offs and gotchas
- Memory leaks from lingering subscriptions: If an observer subscribes to an event but never unsubscribes, the publisher keeps a reference to the observer’s method. The observer object cannot be garbage-collected even after it “dies.” Always unsubscribe in
OnDisableorOnDestroy. - Invocation order is not guaranteed: While multicast delegates call in addition order by default, do not write logic that depends on the order in which observers are notified.
- Closures capture by reference: A captured variable continues to be shared between the lambda and the surrounding scope. Changes in one are visible in the other, which can produce surprising behaviour in loops.
- Custom delegate types are rarely necessary when
Action,Func, orPredicatealready cover the signature. Prefer built-in types for readability.
Evidence
GoalKicker.com’s C# Notes for Professionals (Chapter 58, Chapter 29, Chapter 35) provides the foundational reference for delegate declaration, the built-in delegate types, multicast behaviour, lambda syntax, and event declaration patterns (see source-csharp-notes-for-professionals).
Implications
Understanding delegates is prerequisite knowledge for working with Unity’s event systems, C# LINQ, coroutines, and any pattern that involves decoupled communication between objects. The observer-pattern in particular relies directly on delegates — the C# event keyword is the language-level tool that implements the subscriber side of that pattern.
Related
- unity-object-communication — practical Unity event system guide; covers C# events in a worked game example
- observer-pattern — the design pattern that C# events implement at the object communication level
- csharp-interfaces — the other primary tool for decoupling in C#; often paired with delegates
- csharp-oop-fundamentals — class and method fundamentals that underpin delegates
- source-csharp-notes-for-professionals — source for most examples on this page