Delegates, Events, Lambdas, and Closures
Delegates
Overview
Delegates are type-safe function pointers - they're reference types that hold references to methods.
Basic Syntax
// Declare delegate type
public delegate int MathOperation(int a, int b);
// Methods that match the signature
public int Add(int a, int b) => a + b;
public int Multiply(int a, int b) => a * b;
// Usage
MathOperation operation = Add;
int result = operation(5, 3); // 8
operation = Multiply;
result = operation(5, 3); // 15
Built-in Delegates
// Action: returns void
Action<string> print = Console.WriteLine;
Action<int, int> logNumbers = (x, y) => Console.WriteLine($"{x}, {y}");
// Func: returns a value (last type parameter is return type)
Func<int, int, int> add = (x, y) => x + y;
Func<string, bool> isEmpty = str => string.IsNullOrEmpty(str);
// Predicate: returns bool (specialized Func<T, bool>)
Predicate<int> isEven = x => x % 2 == 0;
Multicast Delegates
public delegate void Notify(string message);
void SendEmail(string message) => Console.WriteLine($"Email: {message}");
void SendSMS(string message) => Console.WriteLine($"SMS: {message}");
void LogMessage(string message) => Console.WriteLine($"Log: {message}");
// Combine delegates
Notify notifier = SendEmail;
notifier += SendSMS; // Add another method
notifier += LogMessage; // Add another
notifier("System Alert"); // Calls all three methods
// Remove from chain
notifier -= SendSMS;
notifier("Second Alert"); // Calls only SendEmail and LogMessage
Covariance and Contravariance
// Covariance: return type can be more derived
public delegate object Factory();
string CreateString() => "Hello";
Factory factory = CreateString; // ✅ string is more derived than object
// Contravariance: parameter type can be less derived
public delegate void Processor(string text);
void ProcessObject(object obj) => Console.WriteLine(obj);
Processor processor = ProcessObject; // ✅ object is less derived than string
processor("Hello"); // Works!
Events
Overview
Events are a special kind of delegate that enforce publisher-subscriber pattern and encapsulation.
Basic Event Pattern
public class Button
{
// Declare event
public event EventHandler<ClickEventArgs> Clicked;
public void Click()
{
// Raise event (null-conditional)
Clicked?.Invoke(this, new ClickEventArgs { X = 100, Y = 50 });
}
}
public class ClickEventArgs : EventArgs
{
public int X { get; set; }
public int Y { get; set; }
}
// Usage
var button = new Button();
button.Clicked += (sender, args) =>
Console.WriteLine($"Clicked at {args.X}, {args.Y}");
button.Click();
Events vs Delegates
// ❌ Public delegate - dangerous!
public class Bad
{
public Action OnSomething;
// Problems:
// 1. External code can set OnSomething = null, clearing all subscribers
// 2. External code can invoke: OnSomething?.Invoke()
// 3. No encapsulation
}
// ✅ Event - safe!
public class Good
{
public event Action OnSomething;
// Benefits:
// 1. External code can only += or -=
// 2. Only class itself can invoke
// 3. Proper encapsulation
}
Custom Event Accessors
public class SmartButton
{
private EventHandler _clicked;
public event EventHandler Clicked
{
add
{
Console.WriteLine("Subscriber added");
_clicked += value;
}
remove
{
Console.WriteLine("Subscriber removed");
_clicked -= value;
}
}
public void Click() => _clicked?.Invoke(this, EventArgs.Empty);
}
Event Best Practices
public class Publisher
{
// 1. Use EventHandler\<T\> pattern
public event EventHandler<DataChangedEventArgs> DataChanged;
// 2. Provide protected virtual method for raising events
protected virtual void OnDataChanged(DataChangedEventArgs e)
{
DataChanged?.Invoke(this, e);
}
// 3. Check for null before invoking
public void UpdateData()
{
// Do work...
OnDataChanged(new DataChangedEventArgs { NewValue = "Updated" });
}
}
public class DataChangedEventArgs : EventArgs
{
public string NewValue { get; set; }
}
Lambdas
Lambda Syntax Evolution
// Full form
Func<int, int, int> add1 = (int x, int y) => { return x + y; };
// Type inference
Func<int, int, int> add2 = (x, y) => { return x + y; };
// Expression body
Func<int, int, int> add3 = (x, y) => x + y;
// Single parameter - no parentheses needed
Func<int, int> square = x => x * x;
// No parameters
Func<int> getRandom = () => Random.Shared.Next();
// Statement body
Action<int> log = x =>
{
Console.WriteLine($"Value: {x}");
Console.WriteLine($"Squared: {x * x}");
};
Lambda Expressions vs Local Functions
public class Comparison
{
public void ProcessData()
{
// Lambda - creates delegate, can be passed around
Func<int, int> double1 = x => x * 2;
var result1 = ProcessNumbers([1, 2, 3], double1);
// Local function - better performance, can be generic
int Double2(int x) => x * 2;
var result2 = ProcessNumbers([1, 2, 3], Double2);
// Local function with generics (lambdas can't do this!)
T Identity\<T\>(T value) => value;
var num = Identity(42);
var str = Identity("Hello");
}
private List<int> ProcessNumbers(List<int> numbers, Func<int, int> transform)
=> numbers.Select(transform).ToList();
}
Closures
What is a Closure?
A closure is when a lambda/local function "captures" variables from its enclosing scope.
public Func<int> CreateCounter()
{
int count = 0; // Captured variable
return () =>
{
count++; // Lambda captures and modifies 'count'
return count;
};
}
var counter = CreateCounter();
Console.WriteLine(counter()); // 1
Console.WriteLine(counter()); // 2
Console.WriteLine(counter()); // 3
How Closures Work (Behind the Scenes)
// What you write:
public Func<int> CreateCounter()
{
int count = 0;
return () => ++count;
}
// What compiler generates (simplified):
private class DisplayClass
{
public int count;
public int Method()
{
return ++count;
}
}
public Func<int> CreateCounter()
{
var displayClass = new DisplayClass();
displayClass.count = 0;
return displayClass.Method;
}
Common Closure Pitfalls
Pitfall 1: Loop Variable Capture
// ❌ WRONG - all lambdas capture same variable!
var actions = new List<Action>();
for (int i = 0; i < 5; i++)
{
actions.Add(() => Console.WriteLine(i));
}
foreach (var action in actions)
action(); // Prints: 5, 5, 5, 5, 5 (all same value!)
// ✅ CORRECT - capture copy of loop variable
var actions = new List<Action>();
for (int i = 0; i < 5; i++)
{
int copy = i; // Create copy for each iteration
actions.Add(() => Console.WriteLine(copy));
}
foreach (var action in actions)
action(); // Prints: 0, 1, 2, 3, 4
Pitfall 2: Async Closure Issues
// ❌ DANGEROUS - captured variable may change before async completion
public async Task ProcessItemsAsync(List<string> items)
{
foreach (var item in items)
{
// 'item' is captured - but may change before task runs!
await Task.Run(() => Console.WriteLine(item));
}
}
// ✅ SAFE - capture copy
public async Task ProcessItemsAsync(List<string> items)
{
foreach (var item in items)
{
var itemCopy = item;
await Task.Run(() => Console.WriteLine(itemCopy));
}
}
Pitfall 3: Unintended Object Lifetime Extension
public class ResourceManager
{
private byte[] _largeBuffer = new byte[1024 * 1024]; // 1 MB
// ❌ Closure captures 'this', keeping entire object alive!
public Action GetCallback()
{
return () => Console.WriteLine("Callback");
// Even though we don't use _largeBuffer, entire object is kept alive
}
}
// ✅ Better - don't capture if not needed
public class ResourceManager
{
private byte[] _largeBuffer = new byte[1024 * 1024];
public Action GetCallback()
{
// No closure - no capture
return static () => Console.WriteLine("Callback");
}
}
Static Lambdas (C# 9+)
// Static lambda - cannot capture variables (prevents accidents)
int x = 10;
// ❌ Compiler error - static lambda cannot capture
Func<int> bad = static () => x;
// ✅ OK - no capture
Func<int> good = static () => 42;
// ✅ OK - parameters are allowed
Func<int, int> square = static x => x * x;
Performance Considerations
Delegate Allocation
public class PerformanceExample
{
// ❌ Allocates new delegate each time
public void Bad()
{
for (int i = 0; i < 1000; i++)
{
Process(x => x * 2); // New delegate allocated each iteration!
}
}
// ✅ Reuse delegate
private readonly Func<int, int> _doubler = x => x * 2;
public void Good()
{
for (int i = 0; i < 1000; i++)
{
Process(_doubler); // Same delegate reused
}
}
void Process(Func<int, int> transform) { }
}
Interview Questions
Q: What's the difference between a delegate and an event?
A: Events are built on delegates but provide encapsulation - only the containing class can invoke an event, and external code can only subscribe (+=) or unsubscribe (-=). Delegates can be invoked and reassigned by anyone who has access.
Q: Explain what a closure is and give an example of a closure pitfall.
A: A closure occurs when a lambda captures variables from its enclosing scope. A common pitfall is capturing loop variables - all lambdas capture the same variable, so they all see its final value. Solution: capture a copy of the variable in each iteration.
Q: When would you use a local function instead of a lambda?
A: Use local functions when you need generics, when the function is only called within the method (better performance), or when you want better stack traces in debugging. Use lambdas when you need to pass the function as a delegate parameter.
Q: What is multicast delegate and how does it work?
A: A multicast delegate holds references to multiple methods. When invoked, it calls all methods in order. You add methods with += and remove with -=. Return values from earlier methods are discarded - only the last method's return value is returned.
Q: How do static lambdas help prevent bugs?
A: Static lambdas cannot capture any variables from the enclosing scope, preventing accidental closures that might cause memory leaks or capture issues. They can only use their parameters and static members.
Practice Exercises
- Implement an event-driven timer class with Start, Stop, and Tick events
- Demonstrate closure pitfalls with loop variables and show the fix
- Create a chainable fluent API using delegates
- Build a simple pub-sub system using multicast delegates
- Show performance difference between cached and non-cached delegates
Related Concepts
- LINQ Fundamentals
- Async Programming patterns
- Memory Management and GC