Dev.to · 23 min read

Delegates in C#

Delegates in C#

Delegates in C A deep-dive walkthrough of delegates in C# — covering what a type-safe method reference actually is, declaring and invoking delegates, multicast delegates and invocation lists, built-in Func/Action/Predicate delegates, anonymous methods and lambda expressions as delegate shorthand, how events build on delegates, and the callback and passing-methods-as-parameters use cases that make delegates one of C#'s most quietly load-bearing features. Table of Contents Introduction What a Delegate Actually Is Declaring, Instantiating, and Invoking a Delegate Why "Type-Safe" Matters Passing Methods as Parameters Multicast Delegates Built-In Generic Delegates: Func, Action, Predicate Anonymous Methods and Lambda Expressions Closures: Capturing Variables in a Delegate Callbacks: The Core Use Case Events: Delegates with Guardrails Delegates vs. Interfaces: A Direct Comparison Asynchronous Invocation and Delegates in Modern C# Common Pitfalls Quick Reference Table Conclusion Introduction A delegate in C# is a type-safe reference to a method — a variable that doesn't hold data, but holds a pointer to a method with a specific signature, which can then be invoked exactly as if you were calling that method directly, without the calling code needing to know at compile time which specific method it will actually run. That single idea is what makes two of the most common needs in real C# code possible: callbacks (telling some code "when you're done, call this method of mine") and passing behavior as data (handing a method to another method as a parameter, the same way you'd pass an int or a string). This guide walks through the language mechanics in depth, then covers the built-in generic delegate types, lambda expressions as delegate shorthand, and how events refine delegates specifically for the publish/subscribe pattern. delegate int MathOperation(int a, int b); → the CONTRACT: "any method matching this signature can be referenced by this delegate type" MathOperation op = Add; → op now POINTS TO the Add method int result = op(3, 4); → invoking op actually calls Add(3, 4) op = Subtract; → op can be REASSIGNED to point elsewhere entirely 1. What a Delegate Actually Is A type that describes a method signature, not a class that describes data public delegate int MathOperation(int a, int b); This single line declares a new type — MathOperation — but unlike a class or struct, this type doesn't describe fields and properties; it describes a method signature: a method taking two int parameters and returning an int. Any method matching that exact signature (same parameter types, same return type) can be assigned to a variable of this delegate type, regardless of what the method is actually named or which class it lives in. A delegate instance is a reference to a specific method public class Calculator { public static int Add(int a, int b) => a + b; public static int Subtract(int a, int b) => a - b; } MathOperation op = Calculator.Add; // op now references the Add method specifically op isn't a copy of Add's code, and it isn't the result of calling Add — it's a reference to the method itself, the same conceptual relationship a normal variable has to an object, just pointed at executable code instead of data. This is genuinely analogous to a function pointer in C, but with the type safety guarantees Section 3 covers, which C's raw function pointers don't provide. 2. Declaring, Instantiating, and Invoking a Delegate The three steps, in order // Step 1: DECLARE the delegate type — usually once, at namespace or class scope public delegate int MathOperation(int a, int b); public class Program { public static int Add(int a, int b) => a + b; public static void Main() { // Step 2: INSTANTIATE — create a delegate instance pointing at a specific method MathOperation op = Add; // shorthand for: MathOperation op = new MathOperation(Add); // Step 3: INVOKE — call it exactly like calling a method directly int result = op(3, 4); // result = 7 Console.WriteLine(result); } } Modern C# lets you skip the explicit new MathOperation(Add) construction and just assign the method group directly (MathOperation op = Add;) — the compiler infers the delegate construction — but understanding that a new object is genuinely being created underneath is worth knowing, since a delegate instance is a real object on the heap, not just syntactic sugar with no runtime cost. The signature must match exactly — return type and parameter types, not names public delegate int MathOperation(int a, int b); public static int Add(int x, int y) => x + y; // ✅ parameter NAMES don't need to match public static double AddDouble(int a, int b) => a + b; // ❌ return type mismatch — won't compile public static int AddThree(int a, int b, int c) => a + b + c; // ❌ parameter count mismatch — won't compile Only the shape of the signature matters for compatibility — parameter names are irrelevant to the compiler (they're just for readability), but the return type and every parameter's type, in order, must match exactly, or the assignment simply won't compile. 3. Why "Type-Safe" Matters Contrast with C's raw function pointers, which offer no such guarantee In C, a function pointer is just an address in memory — the compiler has limited ability to verify at compile time that you're calling it with the right argument types or that it returns what you expect; a mismatch can produce undefined behavior at runtime rather than a compile error. A C# delegate, by contrast, is checked by the compiler exactly like any other typed reference — assigning a method with the wrong signature to a delegate variable is a compile-time error, not a runtime crash or silent misbehavior. The compiler catches signature mismatches immediately public delegate bool Validator(string input); public static bool IsNotEmpty(string s) => !string.IsNullOrEmpty(s); public static int GetLength(string s) => s.Length; // wrong return type Validator v1 = IsNotEmpty; // ✅ compiles — matches (string) -> bool // Validator v2 = GetLength; // ❌ compile error — GetLength returns int, not bool This is what "type-safe" concretely buys you: the entire class of bugs where a callback is invoked with the wrong number or type of arguments, or its return value is used incorrectly, is caught before the program ever runs, the same guarantee C# gives you for ordinary method calls. 4. Passing Methods as Parameters The core mechanic: a delegate-typed parameter accepts any matching method public delegate int MathOperation(int a, int b); public static int Compute(int a, int b, MathOperation operation) { return operation(a, b); // the ACTUAL method executed here depends on what was passed in } public static int Add(int a, int b) => a + b; public static int Multiply(int a, int b) => a * b; Console.WriteLine(Compute(3, 4, Add)); // 7 Console.WriteLine(Compute(3, 4, Multiply)); // 12 Compute doesn't know or care, at the point it's written, whether it will end up running Add, Multiply, or some other method entirely — the caller decides, at the call site, which behavior actually runs. This is the second of the two headline use cases this guide opened with: methods, treated as values, passed around exactly like any other argument. Why this is genuinely useful: generic algorithms parameterized by behavior public delegate bool Predicate(T item); // (this exact shape exists built-in, per Section 6) public static List Filter(List items, Predicate predicate) { var result = new List(); foreach (var item in items) if (predicate(item)) result.Add(item); return result; } var numbers = new List { 1, 2, 3, 4, 5, 6 }; var evens = Filter(numbers, n => n % 2 == 0); // the FILTERING LOGIC is passed in, not hardcoded Filter is written once and works for any filtering condition, because the condition itself — the thing that actually varies between calls — is passed in as a delegate rather than being hardcoded inside Filter. This is precisely the mechanism underneath LINQ's Where, Select, OrderBy, and most of the rest of the standard query operators, which is why understanding delegates directly demystifies a large fraction of how LINQ actually works underneath its more convenient lambda syntax (Section 7). 5. Multicast Delegates A single delegate variable can reference more than one method at once public delegate void Notify(string message); public static void LogToConsole(string message) => Console.WriteLine($"Console: {message}"); public static void LogToFile(string message) => File.AppendAllText("log.txt", message); Notify notify = LogToConsole; notify += LogToFile; // `+=` ADDS another method to the same delegate instance notify("System started"); // BOTH LogToConsole and LogToFile run, in the order they were added Unlike a typical variable assignment, += on a delegate doesn't replace what it points to — it appends another method reference to an internal invocation list. Calling notify(...) now invokes every method in that list, in the order they were added — this is what "multicast" means: one delegate instance broadcasting to several methods. Removing a method with -= notify -= LogToFile; // removes LogToFile from the invocation list notify("Shutting down"); // only LogToConsole runs now -= removes a specific method reference from the invocation list — this symmetric add/remove behavior is exactly what makes delegates suitable as the foundation for events (Section 10), where many independent subscribers need to be able to attach and detach their own handlers over an object's lifetime. Return values from a multicast delegate: only the last one survives public delegate int Transform(int x); Transform t = x => x + 1; t += x => x * 2; int result = t(5); // only the LAST delegate's return value (5 * 2 = 10) is what `result` receives — // the FIRST delegate's return value (5 + 1 = 6) is silently discarded This is a genuinely important, easy-to-miss detail: when a multicast delegate has a non-void return type, every method in the invocation list still runs, but only the return value of the last one invoked is what the caller actually receives — every earlier return value is computed and then discarded. This is precisely why multicast delegates are used almost exclusively with void-returning signatures in practice (as events, per Section 10, always are) — a non-void multicast delegate is a common source of subtle bugs when a developer assumes every invocation's result is somehow being combined or made available. 6. Built-In Generic Delegates: Func, Action, Predicate Why you rarely need to declare a custom delegate type in modern C Every custom delegate declared in Sections 1-5 above (MathOperation, Validator, Notify, Transform) is really just a specific case of a small number of general SHAPES: "takes some inputs, returns a value" or "takes some inputs, returns nothing" — .NET provides generic delegate types for exactly these shapes, so a custom delegate declaration is rarely necessary anymore. Func, Action, and Predicate (and their many-parameter generic overloads, up to 16 parameters) cover the overwhelming majority of real-world delegate needs — reaching for a custom delegate declaration today is mostly reserved for cases needing ref/out parameters or a more self-documenting, domain-specific type name. Func: takes parameters, returns a value Func add = (a, b) => a + b; // takes two ints, returns an int Func isEmpty = s => string.IsNullOrEmpty(s); // takes a string, returns a bool Func getRandomNumber = () => new Random().Next(); // takes NOTHING, returns an int Console.WriteLine(add(3, 4)); // 7 The type parameters of Func are read left-to-right as "parameter types, then the return type last" — Func takes two ints and returns an int; Func takes a string and returns a bool. This exactly replaces this guide's earlier MathOperation and Validator custom delegate declarations with no loss of type safety. Action: takes parameters, returns nothing Action print = message => Console.WriteLine(message); Action printRepeated = (message, times) => { for (int i = 0; i < times; i++) Console.WriteLine(message); }; Action doNothing = () => { }; // no parameters, no return value Action is Func's void-returning counterpart — every type parameter is a parameter type, and there's no return type slot at all, exactly replacing this guide's earlier Notify custom delegate. Predicate: a specialized, more self-documenting Func Predicate isEven = n => n % 2 == 0; // functionally identical to: Func isEven = n => n % 2 == 0; var numbers = new List { 1, 2, 3, 4 }; var evens = numbers.FindAll(isEven); // List.FindAll specifically expects a Predicate Predicate is functionally redundant with Func — the two are interchangeable in terms of what they can represent — but it exists as its own named type because certain older .NET APIs (like List.FindAll, List.Find) were designed around it specifically, before Func and Action existed in the framework; new code today typically reaches for Func unless calling into one of those specific legacy APIs. 7. Anonymous Methods and Lambda Expressions The problem: a named method is often unnecessary ceremony for a one-off, small piece of logic // Writing a whole separate method just to use it once, right here, is often overkill public static bool IsEven(int n) => n % 2 == 0; var evens = numbers.Where(IsEven); For small, single-use logic, declaring an entirely separate named method purely to pass it as a delegate is often more ceremony than the logic warrants — C# offers two increasingly concise ways to write the method's body directly at the point it's needed. Anonymous methods: the older, more verbose syntax Func isEven = delegate (int n) { return n % 2 == 0; }; The delegate keyword used this way (introduced in C# 2.0, before lambdas existed) lets you write a method body inline, without naming it — functionally equivalent to declaring IsEven separately, just written at the point of use. This syntax is rarely written in new code today, but it's worth recognizing when reading older C# codebases, since lambda expressions (below) almost entirely superseded it. Lambda expressions: the modern, concise standard Func isEven = n => n % 2 == 0; // expression-bodied lambda Func add = (a, b) => a + b; // multiple parameters Action log = message => { Console.WriteLine(message); Console.WriteLine("Done"); }; // block-bodied lambda Action noParams = () => Console.WriteLine("Hi"); // zero parameters — empty parens required The => ("goes to") operator separates the parameter list from the method body — a lambda can be a single expression (implicitly returned, no return keyword or braces needed) or a full block body with { } and explicit return when needed. This is the syntax used in almost all modern C# code wherever a delegate needs a small, one-off implementation, and it's exactly what powers LINQ's typical usage: var evenSquares = numbers.Where(n => n % 2 == 0).Select(n => n * n); Both Where and Select accept delegate parameters (Func and Func respectively) — the lambdas here are, underneath, ordinary delegate instances, constructed and passed exactly per Section 4's "passing methods as parameters" mechanism, just with far less ceremony than declaring named methods for each one would require. 8. Closures: Capturing Variables in a Delegate A lambda (or anonymous method) can reference variables from its enclosing scope public static Func MakeAdder(int amountToAdd) { return x => x + amountToAdd; // captures `amountToAdd` from the ENCLOSING method } var add5 = MakeAdder(5); var add10 = MakeAdder(10); Console.WriteLine(add5(3)); // 8 (3 + 5) Console.WriteLine(add10(3)); // 13 (3 + 10) amountToAdd is a parameter of MakeAdder, which has already returned by the time add5(3) is actually called — and yet the lambda still has access to it, with its own independent, remembered value. This is a closure: the lambda "closes over" the variable, keeping it alive and accessible for as long as the delegate itself exists, even after the method that originally declared the variable has finished executing. Why this matters, and a genuinely common gotcha it creates var actions = new List(); for (int i = 0; i < 3; i++) { actions.Add(() => Console.WriteLine(i)); // captures the VARIABLE i, not its value AT THIS POINT } foreach (var action in actions) action(); // In older C# (pre-5.0 loop variable semantics): prints "3, 3, 3" — surprising to most developers! // In modern C# (foreach always, and for since C# 5): each lambda captures its OWN i, so it prints "0, 1, 2" This is one of the most commonly cited "gotcha" behaviors around closures historically — a lambda captures the variable itself, not a snapshot of its value at the moment the lambda was created, which used to produce genuinely surprising results in loops before the language's loop-variable-capture semantics were revised. Worth knowing about even though modern C# (foreach always, and for loops since C# 5.0) now gives each iteration its own captured variable, matching what most developers intuitively expect. 9. Callbacks: The Core Use Case The pattern: "call me back when you're done" or "call me back for each item" public class FileDownloader { public void Download(string url, Action onComplete, Action onError) { try { var data = PerformDownload(url); // imagine real network I/O here onComplete(data); // CALL BACK into the caller's code once done } catch (Exception ex) { onError(ex.Message); // CALL BACK with the error instead } } } var downloader = new FileDownloader(); downloader.Download( "https://example.com/file.zip", onComplete: data => Console.WriteLine($"Downloaded {data.Length} bytes"), onError: error => Console.WriteLine($"Failed: {error}") ); This is the callback pattern in its most direct form: FileDownloader doesn't know or need to know what the caller actually wants to do with a completed download or a failure — it just knows it will call some method matching Action or Action at the appropriate moment, and the caller supplies exactly what that method should do. This is precisely the same loose-coupling benefit interfaces provide (per this series' Interfaces guide), just expressed through a single method reference instead of a full interface contract — a lighter-weight tool for cases where the "contract" really is just one method, not a family of related ones. Progress reporting: a callback invoked multiple times public void ProcessItems(List items, Action onProgress) { for (int i = 0; i < items.Count; i++) { // ... process items[i] ... onProgress(i + 1, items.Count); // called REPEATEDLY, once per item } } ProcessItems(myItems, (completed, total) => Console.WriteLine($"{completed}/{total} done")); Unlike the download example's single completion callback, this shows a callback invoked repeatedly over the course of an operation — the same delegate mechanism, just used for ongoing progress updates rather than a single terminal outcome, which is a genuinely common real-world pattern for any long-running batch operation. 10. Events: Delegates with Guardrails The problem plain multicast delegates have as a public API: too much access for subscribers public class Button { public Action Clicked; // a PUBLIC field — any external code can do more than just subscribe } var button = new Button(); button.Clicked += () => Console.WriteLine("Handler A"); button.Clicked = () => Console.WriteLine("Handler B"); // ❌ this ACCIDENTALLY REPLACES the whole // invocation list instead of adding to it! button.Clicked?.Invoke(); // any external code can also just INVOKE it directly, bypassing Button entirely A raw, public delegate field is dangerous as a public API surface: external code can accidentally (or maliciously) overwrite the entire invocation list with = instead of appending with +=, wiping out every other subscriber's handler, and external code can invoke the delegate directly, triggering the "event" from entirely the wrong place. event exists specifically to close both of these gaps. The event keyword: same underlying delegate, restricted public surface public class Button { public event Action Clicked; // now an EVENT, not a plain field public void SimulateClick() => Clicked?.Invoke(); // only Button ITSELF can invoke it } var button = new Button(); button.Clicked += () => Console.WriteLine("Handler A"); button.Clicked += () => Console.WriteLine("Handler B"); // button.Clicked = () => Console.WriteLine("Oops"); // ❌ compile error — external code cannot use `=` on an event // button.Clicked.Invoke(); // ❌ compile error — external code cannot invoke it directly button.SimulateClick(); // "Handler A" then "Handler B" — only the declaring class can trigger it event restricts external code to only += and -= — the assignment operator and direct invocation are only available from inside the declaring class. This is the entire relationship between delegates and events in one sentence: an event is a delegate field with a restricted public API, specifically designed for the publish/subscribe pattern, where the class raising the event needs to guarantee its subscriber list can't be tampered with or triggered from outside. The standard .NET event pattern, for context public class OrderPlacedEventArgs : EventArgs { public int OrderId { get; } public OrderPlacedEventArgs(int orderId) => OrderId = orderId; } public class OrderService { public event EventHandler OrderPlaced; public void PlaceOrder(int orderId) { // ... place the order ... OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(orderId)); } } EventHandler is itself just another built-in generic delegate (void EventHandler(object sender, TEventArgs e)), and this (sender, args) shape is the conventional pattern most of .NET's own events follow — worth recognizing as convention rather than a hard language requirement, since Section 10's simpler event Action Clicked example is equally valid C# for cases that don't need the sender reference or custom event data. 11. Delegates vs. Interfaces: A Direct Comparison Both provide loose coupling and polymorphic behavior — the real question is scope Interface (per this series' Interfaces guide): a CONTRACT covering potentially SEVERAL related members — use when the abstraction genuinely needs more than one coordinated method, or when the "capability" is naturally expressed as a family of operations (IRepository's GetById, Add, GetAll). Delegate: a reference to exactly ONE method's signature — use when the varying behavior really is just a single operation (a comparison function, a callback, a single transformation step). Both mechanisms let calling code depend on "some behavior" without knowing the concrete implementation ahead of time — the deciding factor is almost always how many coordinated members the abstraction actually needs; a single-method interface (sometimes informally called a "functional interface") and a delegate are nearly interchangeable in capability, and C# itself supports converting between the two in many contexts (a method group can satisfy a single-method interface via an adapter, and vice versa in some LINQ-adjacent designs). A concrete comparison: IComparer vs. Comparison // Interface version — a full contract public interface IComparer { int Compare(T x, T y); } list.Sort(myComparerInstance); // requires an OBJECT implementing IComparer // Delegate version — just the one method's signature public delegate int Comparison(T x, T y); list.Sort((a, b) => a.Name.CompareTo(b.Name)); // just a LAMBDA, no class or object needed .NET itself provides both shapes for sorting, and this is a genuinely instructive real-world example of the trade-off: IComparer is the right choice when the comparison logic is substantial, reusable, and worth naming as its own type (ByLastNameComparer, ByDateComparer); Comparison (a delegate) is the right choice for a one-off, inline comparison that doesn't warrant a whole class — exactly the same "how much ceremony does this specific piece of behavior deserve" judgment call that governs choosing between a named method and a lambda within delegates themselves. 12. Asynchronous Invocation and Delegates in Modern C Historical context: BeginInvoke/EndInvoke, largely superseded Older .NET (pre-async/await) supported asynchronous delegate invocation via BeginInvoke() and EndInvoke(), part of the Asynchronous Programming Model (APM) — this pattern is now largely legacy; modern C# code almost always reaches for async/await and Task-returning methods instead. Worth knowing this history exists if you encounter it in an older codebase, but new C# code essentially never uses delegate-based asynchronous invocation directly anymore — Func and Func (a delegate returning a Task) are how asynchronous "callback-shaped" code is expressed in modern C#, combining delegates with async/await rather than using the older APM pattern. Modern equivalent: delegates returning Task public async Task ProcessWithCallbackAsync(Func onComplete) { await DoWorkAsync(); await onComplete(); // the "callback" is itself awaited, integrating cleanly with async/await } await ProcessWithCallbackAsync(async () => { await NotifyUserAsync(); }); This is the natural, current way to combine Section 9's callback pattern with asynchronous code — the delegate type itself doesn't need anything special; Func is just an ordinary generic delegate whose return type happens to be awaitable, and the calling code awaits the callback exactly like it would await any other task-returning method. 13. Common Pitfalls Pitfall Why it hurts Better approach Assuming a non-void multicast delegate combines every subscriber's return value Only the LAST invoked method's return value survives; every earlier one is silently discarded Use multicast delegates almost exclusively with void returns, or invoke the invocation list manually via GetInvocationList() if every result is genuinely needed Using = instead of += on a public delegate field meant to support multiple subscribers Silently wipes out every previously registered handler instead of adding to them Use event (Section 10) to make this a compile error for external code, or be deliberate about += everywhere internally Exposing a plain delegate field publicly instead of using event External code can invoke it directly or overwrite the whole subscriber list, bypassing the declaring class's intended control Use event for any publish/subscribe-style delegate meant to be triggered only by its declaring class Declaring a custom delegate type when Func/Action/Predicate already cover the shape Unnecessary type proliferation; a custom named delegate rarely adds real clarity over the built-in generics Reach for Func/Action by default; declare a custom delegate only for ref/out parameters or genuine domain-specific clarity Forgetting a closure captures the variable, not a snapshot of its value Can produce stale or unexpectedly-shared state when a lambda is created inside a loop or reused across calls Understand closures capture variables by reference to their storage location; assign to a fresh local variable inside the loop body if a per-iteration snapshot is genuinely needed Subscribing to an event and never unsubscribing, on a long-lived publisher A common cause of memory leaks — the publisher's invocation list keeps a live reference to the subscriber, keeping it alive indefinitely Explicitly -= the handler when the subscriber's own lifetime ends, especially in UI or long-running service code Choosing an interface where a single-method delegate would be simpler, or vice versa Adds either unnecessary type ceremony (a whole interface for one method) or forces an ill-fitting delegate onto behavior that's genuinely multi-member Match the tool to the shape of the abstraction (Section 11) — one operation vs. several coordinated ones Writing dense, unreadable lambda chains instead of a named method for genuinely complex logic A multi-line, heavily-nested lambda is harder to read, test, and debug than an equivalently-behaving named method Reach for a lambda for small, self-evident logic; extract a real named method once a lambda's body grows non-trivial Quick Reference Table Concept C# Syntax Purpose Declaring a delegate type public delegate int MathOperation(int a, int b); Defines a type-safe reference to a matching method signature Instantiating / assigning MathOperation op = Add; Points the delegate variable at a specific method Invoking int result = op(3, 4); Calls the referenced method through the delegate Multicast notify += LogToFile; / notify -= LogToFile; Attaches or detaches methods from a shared invocation list Func Func add = (a, b) => a + b; Built-in delegate for "takes parameters, returns a value" Action Action log = msg => Console.WriteLine(msg); Built-in delegate for "takes parameters, returns nothing" Lambda expression n => n % 2 == 0 Concise, inline delegate implementation without a named method Closure A lambda referencing an enclosing-scope variable Keeps captured variables alive for as long as the delegate exists event public event Action Clicked; A delegate field restricted to +=/-= from outside the declaring class Conclusion A delegate's entire value comes from treating a method the way C# treats any other typed value — something that can be stored in a variable, passed as a parameter, returned from another method, and invoked without the calling code needing to know at compile time exactly which implementation it will run. That single capability is what makes callbacks and passing-behavior-as-data possible in a strongly, statically typed language, closing the gap between "flexible, swappable behavior" and "the compiler still catches every signature mismatch before the program runs." Func, Action, and Predicate cover nearly every real-world need today, and lambda expressions have made writing one-off delegate implementations concise enough that most C# developers use delegates constantly, often without consciously thinking of LINQ queries or event handlers as "delegates" at all — but understanding what's actually happening underneath (a type-safe method reference, an invocation list for multicast cases, a captured closure, an event's restricted public surface) is what turns "lambdas just work" into a genuine, transferable understanding of one of C#'s most foundational and quietly pervasive features. Found this useful? Feel free to star the repo, open an issue with corrections, or share the closure-captured-the-loop-variable-and-everything-printed-the-same-number debugging session that made closures click far better than any explanation ever could.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News