Module 3 · 3. Control Flow, Methods, Delegates, and Functional Building Blocks · Lesson 9 of 24
Delegates, Lambdas, and Events with Explicit Ownership
Learning outcomes
Pass a predicate and a logging callback as delegates, predict when an iterator invokes them, distinguish captured variables from explicit state, and give an event subscription a clear owner and end point.
Core ideas
- A delegate represents callable behavior with compatible parameters and return type. It can refer to one or more methods. Use Func for a returned result and Action for a callback without a result; a named delegate can communicate domain meaning. Microsoft: delegates
- A lambda supplies behavior inline. Capturing an outer variable means reading that variable, not freezing its current value. A static lambda cannot capture enclosing locals or instance state, although it can use static members and constants. Microsoft: lambda expressions
- An event lets outside code subscribe and unsubscribe rather than invoke or replace its delegate directly. In the sealed publisher below, Raise is the public operation that triggers the event. Microsoft: event
Each code block below is a separate complete Program.cs for a .NET 10 console project with nullable reference types enabled. Run one at a time. No packages, user input, files, network, or timers are required.
Worked example 1: pass behavior, then consume it
The original WhereLogged helper is retained. Func<T, bool> decides whether an item matches; Action<T> observes each match. The original sample only assigned paid: it produced no stdout because nothing enumerated the sequence. That is a baseline observation, not the repaired example's output.
An iterator's body advances when its enumerator requests elements. The method call creates the sequence; it does not run this filtering loop immediately. A foreach consumer drives it, and yield return suspends execution at each match. Microsoft: iterators
using System;
using System.Collections.Generic;
using System.Globalization;
static IEnumerable<T> WhereLogged<T>(
IEnumerable<T> source,
Func<T, bool> predicate,
Action<T> onMatch)
{
foreach (var item in source)
{
if (!predicate(item)) continue;
onMatch(item);
yield return item;
}
}
var paid = WhereLogged(
new[] { 25m, 0m, 70m },
static amount => amount > 0,
static amount => Console.WriteLine(
"Accepted " + amount.ToString("0", CultureInfo.InvariantCulture)));
Console.WriteLine("Created; no items requested");
foreach (decimal amount in paid)
Console.WriteLine("Consumed " + amount.ToString("0", CultureInfo.InvariantCulture));
Console.WriteLine("Enumerate again");
foreach (decimal amount in paid)
Console.WriteLine("Consumed " + amount.ToString("0", CultureInfo.InvariantCulture));Expected stdout:
Created; no items requested Accepted 25 Consumed 25 Accepted 70 Consumed 70 Enumerate again Accepted 25 Consumed 25 Accepted 70 Consumed 70
Trace this program: creation prints only the marker. The predicate rejects 0. For 25 and 70, the callback runs before the consumer's line. A second foreach starts another pass over this array-backed sequence, so the callback repeats. This helper does not cache results or promise one-time logging. The invariant integer-style decimal formatting makes these exact output lines independent of the machine's currency settings.
Worked example 2 and solved practice: capture versus explicit state
Practice: inject a predicate and logger, then replace a capturing predicate with a static lambda receiving its threshold as an argument. Predict both the direct calls and the deferred filtering result before running this solution.
using System;
using System.Collections.Generic;
int minimum = 20;
Func<int, bool> capturesMinimum = amount => amount >= minimum;
Func<int, int, bool> explicitMinimum = static (amount, floor) => amount >= floor;
Console.WriteLine($"Before change: {capturesMinimum(25)}");
minimum = 50;
Console.WriteLine($"After change: {capturesMinimum(25)}");
Console.WriteLine($"Explicit 20: {explicitMinimum(25, 20)}");
Console.WriteLine($"Explicit current: {explicitMinimum(25, minimum)}");
// Solved practice: supply both filtering behavior and logging behavior.
var matches = WhereLoggedWithState(
new[] { 25, 0, 70 }, minimum, explicitMinimum,
static amount => Console.WriteLine($"Logged {amount}"));
minimum = 100;
Console.WriteLine("Consume saved state");
foreach (int amount in matches)
Console.WriteLine($"Kept {amount}");
static IEnumerable<T> WhereLoggedWithState<T, TState>(
IEnumerable<T> source,
TState state,
Func<T, TState, bool> predicate,
Action<T> onMatch)
{
foreach (T item in source)
{
if (!predicate(item, state)) continue;
onMatch(item);
yield return item;
}
}Expected stdout:
Before change: True After change: False Explicit 20: True Explicit current: False Consume saved state Logged 70 Kept 70
The first two calls use the same captured minimum variable: changing it from 20 to 50 changes the answer for 25. explicitMinimum receives its threshold at each call. WhereLoggedWithState receives the integer 50 when matches is created; changing the caller's minimum to 100 later does not change that saved argument. Only 70 passes, and the injected logger runs before Kept 70.
This rewrite makes the threshold dependency visible in the signature. It is deliberately a call-time integer snapshot, so it is not equivalent to a capturing predicate when the outer variable changes before consumption. If state instead contained a reference to mutable data, that data could still change. Choose the intended semantics before calling the rewrite an improvement.
Closure state can extend lifetime and may require allocations; these outputs measure neither allocation counts nor retained memory. Do not infer that every closure allocates, or that static lambdas make an entire operation allocation-free. Here static is a compile-time capture restriction. Microsoft: capture rules
Worked example 3: own an event subscription
A reachable long-lived publisher can retain a subscriber through the delegate for its instance handler. Pair subscription with a defined unsubscription point. A named handler makes that pairing explicit; if using a lambda, retain its delegate for removal rather than writing a new lambda expression. Microsoft: subscribe and unsubscribe
using System;
var publisher = new SignalPublisher();
var listener = new CountingListener(publisher);
using (listener)
{
publisher.Raise();
publisher.Raise();
Console.WriteLine($"Inside scope: {listener.Calls}");
}
publisher.Raise();
Console.WriteLine($"After disposal: {listener.Calls}");
listener.Dispose();
publisher.Raise();
Console.WriteLine($"After repeated disposal: {listener.Calls}");
sealed class SignalPublisher
{
public event EventHandler? Signaled;
public void Raise() => Signaled?.Invoke(this, EventArgs.Empty);
}
sealed class CountingListener : IDisposable
{
private SignalPublisher? publisher;
public int Calls { get; private set; }
public CountingListener(SignalPublisher publisher)
{
this.publisher = publisher;
publisher.Signaled += OnSignal;
}
private void OnSignal(object? sender, EventArgs args) => Calls++;
public void Dispose()
{
if (publisher is null) return;
publisher.Signaled -= OnSignal;
publisher = null;
}
}Expected stdout:
Inside scope: 2 After disposal: 2 After repeated disposal: 2
CountingListener owns one subscription from construction until Dispose. The using scope calls Dispose on exit, including exceptional exit. Microsoft: using statement Its named OnSignal handler is removed, and the publisher field is cleared. A second Dispose is harmless. The publisher remains usable outside the scope, but further raises leave Calls at 2.
This single-threaded test proves callback removal, not garbage collection: the local listener variable is intentionally still referenced so its count can be inspected. No forced collection or timing assertion is needed. Concurrent raises and disposal would need an explicit synchronization contract; this example does not supply one.
Failure modes
- Creating paid and expecting logging before a consumer requests any items.
- Enumerating twice and mistaking repeated side effects for cached results.
- Capturing a changing variable when a snapshot was intended, or using shared mutable state without a concurrency plan.
- Subscribing repeatedly or omitting the subscription owner's cleanup path.
- Hiding complex rules inside dense lambda expressions instead of naming the behavior.
Practice checks with answers
- Remove both foreach loops from example 1. What remains? Only Created; no items requested and Enumerate again. Neither callback runs.
- Change the final minimum assignment in example 2 to 5. Does matches change? No. Its saved integer state is still 50, so it keeps only 70.
- Remove the using scope and never call Dispose. What happens after one additional Raise? Calls becomes 3. This demonstrates a still-active subscription, not a measured memory leak.
- Why is the second Dispose safe? The first clears the publisher field, and the guard returns immediately on later calls.
Interview check
Explain the three separate lifetimes in these examples: a deferred sequence waiting to be consumed, captured state that behavior can still access, and a subscription owned until disposal. Use the actual output to justify each answer; avoid turning these examples into universal allocation or thread-safety claims.
Analogy
A workshop sorts completed pieces by a minimum inspection score. The sorter can receive an instruction that says, "Accept scores meeting the number on this board," or a job ticket that records "minimum 50." If the board changes to 100 before sorting, the first instruction uses 100; the ticket still supplies 50. A piece scoring 70 therefore gets different answers even though both instructions initially used the same minimum.
Mapping. The acceptance instruction represents a predicate delegate. Referring to the board represents capturing minimum; putting its current number on the ticket represents passing the integer state to WhereLoggedWithState. The ticket changes where the threshold comes from, not how the comparison works.
Where it stops. The ticket represents this copied integer, not a promise that every argument is frozen. A reference to mutable state could still expose later changes. The analogy explains threshold choice, not allocation costs or subscription cleanup.