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 return void. 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 return bool — essentially Func<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); // 10

In 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 reference

C# 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 OnDisable or OnDestroy.
  • 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, or Predicate already 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.