Delegates, Func and Action in C#: Passing Methods Around as Values
A delegate is a type that holds a method: a class deriving from MulticastDelegate, Method and Target, Func/Action/Predicate, the natural type of lambdas in C# 10 and CS8917, passing methods as parameters, the last result of a multicast delegate and the broken chain, converting between delegate types and variance, and the traps of null delegates and lambdas that -= won't remove.
Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.
Guess First
We define a discount rule, then add a second rule with +=. The price is 100. What gets printed: 90, 95, both, or 85.5 with both discounts stacked?
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Func<decimal, decimal> discount = price => price * 0.90m;
discount += price => price - 5m;
Console.WriteLine(discount(100m));
We'll get to the answer in the multicast delegate section. First, let's see what that Func really is.
Delegates: A Type That Holds a Method
If a variable can hold a number or an object, can it hold a method? In C# the answer is the delegate. A delegate is a type that can point to methods matching a specific signature (parameter types and return type). It resembles function pointers in the C family, but it is type-safe: if you try to assign a method with the wrong signature, the compiler refuses.
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
PriceRule rule = PriceRules.HalfPrice;
Console.WriteLine(rule(45.90m));
Console.WriteLine(rule.Invoke(45.90m));
Coupon coupon = new Coupon(7.50m);
rule = new PriceRule(coupon.Apply);
Console.WriteLine(rule(45.90m));
Console.WriteLine(typeof(PriceRule).BaseType);
Console.WriteLine(rule.Method.Name);
Console.WriteLine(rule.Target);
// any method that takes a decimal and returns a decimal fits this type
public delegate decimal PriceRule(decimal price);
public static class PriceRules
{
public static decimal HalfPrice(decimal price) => price / 2;
}
public class Coupon
{
private readonly decimal _amount;
public Coupon(decimal amount)
{
_amount = amount;
}
public decimal Apply(decimal price) => Math.Max(0, price - _amount);
public override string ToString() => $"Coupon({_amount})";
}
22.95
22.95
38.40
System.MulticastDelegate
Apply
Coupon(7.50)
The line you write with the delegate keyword is actually a class declaration. For PriceRule the compiler generates a sealed class deriving from System.MulticastDelegate and gives it an Invoke method matching your signature. Writing rule(45.90m) is shorthand for rule.Invoke(45.90m).
There are no parentheses in the assignment: PriceRules.HalfPrice doesn't call the method, it hands over the method itself. This is called a method group. new PriceRule(coupon.Apply) is the long form of the same thing. A delegate carries two things: the method to call (Method) and, if it's an instance method, the object to call it on (Target). For a static method Target is null; for coupon.Apply it's the coupon itself. Keep in mind that the delegate holds a reference to coupon; this detail will matter a lot when we talk about event leaks.
Func, Action and Predicate
Declaring a separate delegate type for every need would be tiring. .NET ships ready-made generic delegates for this, and nearly all everyday code is written with them:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Func<decimal, decimal> addTax = price => price * 1.20m;
Func<decimal, int, decimal> lineTotal = (price, quantity) => price * quantity;
Action<string> log = message => Console.WriteLine($"[log] {message}");
Predicate<int> isLowStock = stock => stock < 5;
Func<DateTime> clock = () => new DateTime(2026, 11, 7, 10, 0, 0);
log($"tax: {addTax(45.90m)}");
log($"line: {lineTotal(12.50m, 3)}");
log($"low stock: {isLowStock(3)}");
log($"clock: {clock():yyyy-MM-dd HH:mm}");
[log] tax: 55.0800
[log] line: 37.50
[log] low stock: True
[log] clock: 2026-11-07 10:00
The rule is simple: Func returns a value and its last generic parameter is the return type; Func<decimal, int, decimal> means "take a decimal and an int, return a decimal". Action returns nothing (void). Both come in versions with 0 to 16 parameters. Predicate<T> has the same signature as Func<T, bool> but is an older type; you'll meet it in .NET 2.0-era APIs such as List<T>.FindAll and RemoveAll. The Comparison<T> that List<T>.Sort accepts belongs to the same generation.
When do you write your own delegate type? When the signature has ref or out parameters (Func doesn't support them), or when the type's name carries meaning. PriceRule tells the reader more than Func<decimal, decimal>. Otherwise use the built-in types; everyone reading the code knows them.
C# 10: The Natural Type of Lambdas and Method Groups
Before C# 10 you couldn't declare a lambda with var, because a lambda had no type of its own. C# 10 gave lambdas, and method groups with a single overload, a natural type. Let's add these three lines to the end of the program above:
var applyCoupon = (decimal price, decimal coupon) => price - coupon;
Console.WriteLine(applyCoupon.GetType());
var write = Console.WriteLine;
Program.cs(19,13): error CS8917: The delegate type could not be inferred.
The error comes from the last line: Console.WriteLine has dozens of overloads and the compiler can't tell which one you mean. Delete that line and the first two run, printing System.Func`3[System.Decimal,System.Decimal,System.Decimal]. For a lambda with explicit parameter types, the compiler picked a suitable Func or Action type on its own. A line like var f = x => x * 2;, where you leave out the parameter type, still fails with the same CS8917; the compiler has no hint about what x is.
Passing a Method as a Parameter
The real power of delegates is passing behavior to a method as an argument. Let's write the filtering logic once and leave the criterion to the caller:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
List<Book> books = new List<Book>
{
new Book("Dune", "Frank Herbert", 45.90m, 3),
new Book("Clean Code", "Robert C. Martin", 62.00m, 0),
new Book("Refactoring", "Martin Fowler", 58.50m, 12),
new Book("The Hobbit", "J.R.R. Tolkien", 29.90m, 7)
};
Print("Cheap", Filter(books, book => book.Price < 50m));
Print("In stock", Filter(books, IsInStock));
books.Sort((a, b) => a.Price.CompareTo(b.Price));
Print("By price", books);
static bool IsInStock(Book book) => book.Stock > 0;
// the caller decides what to filter by
static List<Book> Filter(List<Book> source, Func<Book, bool> predicate)
{
List<Book> result = new List<Book>();
foreach (Book book in source)
{
if (predicate(book))
{
result.Add(book);
}
}
return result;
}
static void Print(string title, List<Book> items)
{
Console.WriteLine($"{title}: {string.Join(", ", items.Select(b => $"{b.Title} ({b.Price})"))}");
}
public record Book(string Title, string Author, decimal Price, int Stock);
Cheap: Dune (45.90), The Hobbit (29.90)
In stock: Dune (45.90), Refactoring (58.50), The Hobbit (29.90)
By price: The Hobbit (29.90), Dune (45.90), Refactoring (58.50), Clean Code (62.00)
Filter manages the loop and the list, and leaves the decision to the predicate passed in. The first call gets a lambda, the second a method group; both convert to Func<Book, bool>. If this Filter looks familiar, that's because LINQ's Where is exactly this idea in general form. Every operator you see in the LINQ series is just a method that takes a Func. Methods that take or return methods are called higher-order functions. In the design patterns post we built a strategy out of a single Func<decimal, decimal>; that was the same idea.
Multicast Delegates
Now the answer to the guess: it prints 95. A delegate can hold more than one method; += adds a method to the list, and calling it runs them all in order. But with a delegate that returns a value, you only get the result of the last method. The first rule ran too and computed 90, but nobody saw that result. That's why multicast feels at home with Action, that is, notifications with no result:
Action<string>? onOrderPlaced = SendEmail;
onOrderPlaced += UpdateStock;
onOrderPlaced += WriteAudit;
onOrderPlaced("ORD-1001");
Console.WriteLine($"subscribers: {onOrderPlaced.GetInvocationList().Length}");
// the last subscriber may be removed too, so the result can be null
onOrderPlaced -= UpdateStock;
onOrderPlaced?.Invoke("ORD-1002");
onOrderPlaced = SendEmail;
onOrderPlaced += FailingSms;
onOrderPlaced += WriteAudit;
try
{
onOrderPlaced("ORD-1003");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"caught: {ex.Message}");
}
static void SendEmail(string orderId) => Console.WriteLine($"email sent for {orderId}");
static void UpdateStock(string orderId) => Console.WriteLine($"stock updated for {orderId}");
static void WriteAudit(string orderId) => Console.WriteLine($"audit written for {orderId}");
static void FailingSms(string orderId) => throw new InvalidOperationException("SMS gateway is down");
email sent for ORD-1001
stock updated for ORD-1001
audit written for ORD-1001
subscribers: 3
email sent for ORD-1002
audit written for ORD-1002
email sent for ORD-1003
caught: SMS gateway is down
Notice three things. First, delegates are immutable: += doesn't change the existing delegate, it builds a new one combining both lists and assigns it to the variable (Delegate.Combine). Second, we declared the variable as Action<string>?; if -= empties the list the result is null, and the compiler knows it. On a non-nullable variable the -= line gives warning CS8601 and the next call CS8602. Third, when the SMS step blew up on ORD-1003, the chain stopped there: the audit was never written and the exception went all the way to the caller. If subscribers must run independently, take the list with GetInvocationList() and call each one inside its own try/catch; we did this in the observer example of the design patterns post.
Converting Between Delegate Types
Can two delegate types with exactly the same signature be assigned to each other?
PriceRule rule = price => price * 0.90m;
Func<decimal, decimal> func = rule;
public delegate decimal PriceRule(decimal price);
Program.cs(2,31): error CS0029: Cannot implicitly convert type 'PriceRule' to 'System.Func<decimal, decimal>'
They can't. Delegates are classes, so having the same shape doesn't make them the same type, just as two classes with the same properties can't be assigned to each other. The way to bridge them is to pass Invoke as a method group. On the other hand, the generic parameters of Func and Action carry variance, so some conversions just work:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
PriceRule rule = price => price * 0.90m;
Func<decimal, decimal> func = rule.Invoke;
Console.WriteLine(func(50m));
// Func<out TResult>: one that returns a Book can stand in for one that returns an object
Func<Book> createBook = () => new Book("Dune");
Func<object> createAnything = createBook;
Console.WriteLine(createAnything());
// Action<in T>: something that prints any object can print a Book too
Action<object> printAnything = item => Console.WriteLine($"item: {item}");
Action<Book> printBook = printAnything;
printBook(new Book("Clean Code"));
public delegate decimal PriceRule(decimal price);
public record Book(string Title);
45.00
Book { Title = Dune }
item: Book { Title = Clean Code }
A function that produces books can be used anywhere "produce an object" is expected; a function that can print any object can print a book. out and in decide these directions; we covered the reasoning in detail in the covariance and contravariance post of the Collections and Generics series.
Common Pitfalls
Calling a null delegate. A delegate variable with no subscribers is null, and calling it directly throws:
Action<string>? onSold = null;
onSold?.Invoke("Dune");
onSold += title => Console.WriteLine($"sold: {title}");
onSold += title => Console.WriteLine($"audit: {title}");
onSold -= title => Console.WriteLine($"audit: {title}");
onSold("Dune");
Console.WriteLine(onSold.GetInvocationList().Length);
Action<string>? onReturned = null;
onReturned("Dune");
Program.cs(8,1): warning CS8602: Dereference of a possibly null reference.
Program.cs(12,1): warning CS8602: Dereference of a possibly null reference.
sold: Dune
audit: Dune
2
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
The ?.Invoke on the first line did nothing in the null case. The last line, despite the compiler's warning, threw a NullReferenceException at runtime. Make ?.Invoke a habit when calling delegates.
Trying to remove a lambda with -=. The second notable spot in the same program: the audit lambda wasn't removed, there are still two methods in the list. Even with identical bodies, each lambda compiles to a separate method and produces a separate delegate. When -= tries to remove something that isn't in the list, it doesn't fail either; it silently does nothing. Keep a subscriber you'll need to remove later in a variable, or use a named method.
Relying on a multicast delegate with a return value. As in the guess example, you only get the last result. If you want to chain several rules, keep them in a list and apply them in order.
Solving everything with Func<Func<...>>. A Func signature nested three levels deep becomes unreadable. At that point either declare a delegate type with a meaningful name or move the job into a small interface.
A Small Experiment
★☆☆ Write a Describe method that takes a Func<Book, string> and print the same book list once with b => b.Title and once with b => $"{b.Author}: {b.Title}".
★★☆ Keep three price rules in a List<Func<decimal, decimal>> (10 percent off, a 5-unit coupon, 20 percent tax) and apply them to a price in order with a foreach. Compare the result with the multicast result from the guess example.
★★★ Replace the onOrderPlaced chain with a SafeInvoke(Action<string>? handlers, string orderId) method that calls each subscriber inside its own try/catch. Log the Method.Name of the failing subscriber and see that the audit line now gets written.
In this post we always used lambdas as small functions working on their own parameters. But a lambda can also capture variables from where it's defined. In the next post we move on to closures: where a captured variable lives, the captured loop variable trap, and the hidden cost of a lambda.