BrandGhost
Delegates in C#: Action, Func, and Multicast Call Contracts

Delegates in C#: Action, Func, and Multicast Call Contracts

Delegates in C# describe callable contracts and hold the methods that satisfy them. That makes a delegate useful for passing an operation into another operation, storing a callback, or representing several registered reactions. The important details are not limited to syntax: parameters, return values, target identity, and exception propagation determine what a caller can actually rely on.

A delegate is sometimes introduced as a method reference. That is a useful starting image, but incomplete. An instance can contain an invocation list, and an instance method brings its target object with it. Combining methods also does not invent a result-aggregation policy. Knowing those limits helps explain both callbacks and event handlers without treating them as interchangeable APIs.

Documentation was checked online on October 11, 2026, against the C# specification and .NET 10 reference. Each standard-library example is a separate complete console program with no package dependencies. The examples were reviewed as source; they were not compiled or executed.

Delegate types and delegate instances

The delegate specification defines a delegate declaration as a type declaration. Its parameters and return type describe compatible calls. Creating a delegate instance supplies callable entities for that type. These are two different acts, even when a short assignment expression makes them look closely connected.

For an instance method, a callable entity consists of an instance and a method on that instance. For a static method, it consists of the method. The caller does not need to know the implementation class to invoke a compatible delegate. It still needs to know the meaning of arguments and any result.

The specification also says delegate types are name equivalent rather than structurally equivalent. Two separately declared delegate types with identical parameter and return shapes are still distinct types. A compatible method can be converted to either type in the appropriate context, but identical signatures do not make the two type names universally interchangeable.

This is one reason to distinguish a signature description from a variable holding a callback. The variable has a type and a current value. A named delegate declaration defines the type. Combining delegate values constructs another instance with its own invocation list; it does not alter what the declared type means.

An event fits around this model rather than replacing it. An event is a delegate-typed member with subscription accessors, not a delegate type. The decoupling discussion explains why a publisher can expose a shared call contract without referencing every concrete consumer. Delegate fundamentals establish how those calls remain type-compatible.

Action and Func describe common call shapes

Action<T> describes a call that takes one argument and returns no value. Func<T, TResult> describes one argument and a result. Their generic parameters remove the need to declare a custom delegate for many everyday by-value parameter lists, while keeping compile-time information about the call.

The .NET 10 Action reference and Func reference show those declarations. They are delegate types, not separate execution engines. Choosing one does not move work to another thread, schedule retries, or change how a called method's ordinary synchronous exceptions behave.

using System;

Func<int, int> calculateTotal = AddPackingCost;
Action<string> describe = WriteDescription;

int total = calculateTotal(12);
describe($"Total units: {total}");

static int AddPackingCost(int units)
{
    return units + 2;
}

static void WriteDescription(string message)
{
    Console.WriteLine(message);
}

Here the first assignment associates a compatible integer-returning method with a Func<int, int>. The second associates a void-returning method with an Action<string>. Calling the variables invokes their methods. The example deliberately uses named methods so the parameter and return contracts can be compared without lambda syntax obscuring them.

A custom delegate can add vocabulary when the callable shape is part of a public domain contract. It is also useful for parameter modifiers that the common Action and Func shapes do not express. The tradeoff is another public type to understand. A familiar generic delegate keeps simple APIs concise; a meaningful named delegate can make specialized semantics easier to discover.

Call shape Typical representation Question the caller should ask
One input, no result Action<T> What effects may this call produce?
One input, one result Func<T, TResult> What does the result mean?
Specialized by-reference contract Named delegate Which variables can the callee change?
Multiple reactions Combined delegate What happens after a failure?
Asynchronous result Task-returning delegate Which task represents completion?

The table describes shapes, not mandatory architecture choices. A callback receiving a value can be observational or essential. A returned Boolean could mean validation, veto, or a successful lookup. Good names and documentation explain those meanings; generic parameters alone cannot supply them.

Method compatibility is more than matching names

The compiler checks whether the method and delegate have compatible parameters and return behavior. Method names and parameter names do not need to match the delegate declaration. Parameter count and by-reference modifiers matter, while permitted reference conversions can allow some parameter and return types to differ.

For example, a method accepting object can serve a suitable string-input delegate because any supplied string is an object. That observation should not be generalized to arbitrary numeric conversions or mismatched parameter modifiers. The specification's compatibility rules describe the actual permitted identity and implicit reference conversions.

Optional method parameters do not simply disappear when converting a method group to a delegate. A method with two parameters, one optional, does not satisfy a one-parameter delegate just because a direct method call could omit the second argument. A small adapter can explicitly supply that argument when that is the intended API.

This is also separate from generic variance. The fact that a compatible method can be converted to a delegate and the fact that one constructed delegate type can be converted to another are related type-system topics, not the same operation. When diagnosing a conversion problem, first identify whether the input is a method group or an already-created delegate value.

Keep the teaching question concrete: which arguments can this caller supply, and can the target method safely accept them? That framing makes compatibility easier to reason about than memorizing a rule based only on matching text. It also keeps this guide distinct from a full tutorial on lambdas or generic variance.

Multicast delegates in C# do not collect results

Combining non-null delegate values of the same runtime delegate type concatenates invocation lists. Assignment compatibility through generic variance is not enough: an Action<object> can be assigned to an Action<string> variable, but combining that value with a separately created Action<string> fails because their runtime delegate types differ. The Delegate.Combine reference documents this distinction.

Ordinary invocation calls the methods synchronously in list order. A void-returning combination can represent several reactions, but a result-returning combination does not automatically produce a collection of results. When normal invocation reaches the last entry, that entry supplies the returned value.

using System;
using System.Collections.Generic;

Func<int, int> calculate = AddTwo;
calculate += MultiplyByThree;

int finalValue = calculate(4);
Console.WriteLine($"Direct result: {finalValue}");

var separateValues = new List<int>();
foreach (Func<int, int> operation in calculate.GetInvocationList())
{
    separateValues.Add(operation(4));
}

Console.WriteLine(string.Join(", ", separateValues));

static int AddTwo(int value)
{
    return value + 2;
}

static int MultiplyByThree(int value)
{
    return value * 3;
}

Both methods receive the original argument, four. Direct multicast invocation is not function composition, so the output of AddTwo does not become the input of MultiplyByThree. The documentation-backed expected direct result is twelve, rather than eighteen. The separate loop explicitly gathers the independently computed values six and twelve.

GetInvocationList itself returns an array of delegates representing the invocation list. It does not run them. The cast used by the typed loop is appropriate for entries from this known combined Func<int, int> instance. This example does not claim that arbitrary delegate objects or unrelated event backing stores contain that same type.

Collecting results is an explicit policy choice. A validation process might require all checks, while a lookup process might stop at the first available result. Neither policy follows from ordinary multicast invocation. If callers expect one decision, making them decode several callback returns may conceal ownership rather than clarify it.

This limit helps explain getting a decision back through event arguments. Mutable shared arguments can communicate changes in a synchronous event, but then defaults and disagreement policies need documentation. Returning a single value is simpler when one collaborator owns the answer.

An uncaught synchronous exception stops later entries

The delegate specification states that an exception not caught within the invoked method continues the exception search in the delegate's caller. For a multicast invocation, methods further down the invocation list are not invoked. This is not automatic isolation between registered callbacks.

using System;
using System.Collections.Generic;

var trace = new List<string>();
Action inspect = () => trace.Add("first");
inspect += Fail;
inspect += () => trace.Add("third");

try
{
    inspect();
}
catch (InvalidOperationException exception)
{
    trace.Add(exception.Message);
}

Console.WriteLine(string.Join(", ", trace));

static void Fail()
{
    throw new InvalidOperationException("second failed");
}

The catch is deliberately narrow because the failure under discussion is known. If the middle callback throws as written, the final callback is skipped and the caller records the exception message. No sample output is presented as observed. The example exists to connect the rule to a visible call sequence.

A publisher that individually enumerates handlers can choose another failure policy, but that requires actual implementation. Continuing after failures also creates questions about how failures are reported and whether subsequent handlers see partially changed state. Catching everything and doing nothing would not establish a useful success contract.

The flexibility of adding reactions does not remove these failure relationships. Readers should distinguish being able to register another handler from being guaranteed to receive every notification. With direct invocation, an earlier failing reaction can prevent a later one from running.

Targets and captures affect ownership

An instance-method delegate identifies its target object. Keeping that delegate reachable can therefore keep the target reachable. This is useful when the callback intentionally owns access to the object, and problematic when a longer-lived publisher keeps a registration beyond the consumer's useful lifetime.

A lambda can capture surrounding state, including an instance or a local variable. The compiler's representation should not be treated as a universal guarantee about Delegate.Target being the logical subscriber. A captured helper object can stand between the delegate and the application object that the developer thinks of as the listener.

The practical ownership question is whether the callback can still access something that should have ended its useful lifetime. The event retention explanation discusses that problem. A reachable publisher retaining an instance handler is a specific reference path, not proof that every object cycle is permanently uncollectible.

When removal is needed, retaining the actual delegate is a straightforward strategy for anonymous callbacks and adapters. Named instance methods can also match through the same target and method. Rebuilding a wrapper that calls the same operation does not necessarily reconstruct the registration that was added.

Weak references change retention semantics and have additional delivery tradeoffs. They do not change what a delegate type is or make callback ownership disappear. This guide intentionally leaves weak-event implementations to existing material rather than introducing a second lifetime helper under a delegate-fundamentals heading.

Task-returning delegates need a completion contract

A Func<Task> is a delegate returning a task. An individual caller can await that task. Combining several such delegates and invoking the combination still follows multicast return rules: only the final returned task is exposed by that invocation when the methods are all invoked normally.

Earlier tasks are not merged into the final task. A non-async method returning Task can also throw before it returns a task, stopping later invocation. An async Task method communicates failures through its returned task, which is a different boundary from a direct synchronous throw out of a non-async method.

The task-returning event discussion explores explicit dispatch because a type-name change alone cannot establish all-handler completion. This guide does not reproduce that dispatcher. Its narrower job is to explain why the ordinary combined delegate does not represent aggregated asynchronous work.

An async void method converted to a compatible void-returning delegate has another boundary: its caller has no returned task to await. The async-event risk overview is useful when that conventional event signature cannot be changed. Avoid interpreting async syntax as an automatic background-thread or timing guarantee.

Frequently asked questions

Are Action and Func delegate types?

Yes. Action represents void-returning call shapes, while Func represents value-returning shapes. They provide common generic declarations, but invoking their instances still follows delegate rules. They do not independently provide scheduling, retries, or result aggregation.

Does a delegate have to reference a static method?

No. A delegate can represent an instance method, including its target instance, or a static method. Lambdas can also be converted to suitable delegate types. The method must satisfy the applicable compatibility rules for the intended delegate type.

Are two delegate declarations with identical signatures the same type?

No. The specification defines delegate types as name equivalent, not structurally equivalent. A compatible method may be converted to either named type, but that does not make independently declared delegate types interchangeable merely because their signatures look identical.

Does multicast invocation pass each result to the next method?

No. Ordinary multicast invocation passes the supplied arguments to each entry. It does not build a transformation pipeline. If the delegate returns a value and reaches its final entry normally, that final invoked entry supplies the returned result.

What does GetInvocationList actually return?

It returns an array of delegates representing the current delegate's invocation list. It neither invokes the entries nor collects their results. Code that enumerates the array chooses how to call entries and what to do with results or failures.

Can a combined Func be awaited to wait for every callback?

Not with that guarantee. Direct invocation returns only the final entry's task when invocation reaches it. Waiting for all registered operations requires explicit handling of the individual tasks and policies for synchronous throws, faults, cancellation, and completion order.

A useful mental model for delegates in C#

Think of a delegate type as the allowed call and a delegate instance as the calls currently represented. Combination changes the invocation list, not the parameter contract. Ordinary invocation defines order, final return behavior, and exception propagation; application code must add any stronger aggregation policy.

Those fundamentals make callbacks and events easier to understand without inventing guarantees. A callable reference is powerful precisely because it carries a clear contract. Keeping that contract separate from lifetime and completion policy makes the surrounding API easier to reason about.

ASP.NET Core Controllers: A Practical Guide to Building REST Endpoints

Learn how to build production-ready REST endpoints with ASP.NET Core controllers -- action results, DI, model binding, and .NET 10 best practices explained.

Reflection Performance in .NET 10: Benchmarks, Caching, and Delegates

Understand C# reflection performance in .NET 10 -- learn caching strategies, compiled delegates, FrozenDictionary, and when reflection is fast enough.

Events and Delegates in C#: Contracts, Subscriptions, and Notification

Understand events and delegates in C# through practical examples covering notification contracts, subscriptions, multicast calls, failures, and lifetime.

An error has occurred. This application may no longer respond until reloaded. Reload