Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close

    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

    C#
    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:

    Code
    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.

    C#
    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:

    Code
    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

    C#
    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:

    Code
    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

    1. Remove both foreach loops from example 1. What remains? Only Created; no items requested and Enumerate again. Neither callback runs.
    2. 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.
    3. 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.
    4. 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

    Everyday picture

    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.

    Cheat sheet (PDF)

    csharp-delegates-lambdas-events-companion.pdf6 pages · 73 KB
    Every page, in this page.

    Practice

    Sign in to mark lessons done and keep your place in the course.Sign in