C# Func and Action Delegates: Pitfalls and Call Cost
Func vs Action, why out parameters and Predicate<T> do not fit, three multicast traps, and measured call cost vs interfaces.
Revised November 2026. This tutorial first appeared in Korean in 2021. We recompiled it on .NET 10.0.14 (C# 14). The Func example's code block had
0.1fsplit into0.1 f, which produced four compile errors (error CS1002). The original also called Func and Action "pre-declared delegate variables"; they are types, not variables. Below are the fixed code, what Func cannot do, three multicast traps, and a measurement of call cost.
You use Func<T, bool> every day in LINQ and callbacks, but when should you declare your own delegate instead, and what really happens when you chain them with +=? In short: Func and Action are generic delegate types that .NET ships for you, and most code never needs a custom delegate. They fall short in three places: out/ref parameters, public APIs where a descriptive name helps the reader, and multicast return values. If lambdas themselves are new to you, start with C# lambda expressions.
Func and Action are types, not variables
To use a lambda or anonymous method you need a delegate variable to hold it, and before that you need a delegate type. Declaring a new delegate type for every signature gets tedious, so .NET declares two families of generic delegate types in the System namespace. In Func<int, float> func1, the variable is func1; Func<int, float> is the type.
- Func refers to a method that returns a value. Its last type argument is the return type.
- Action refers to a method that returns
void.
We counted them with reflection:
var core = typeof(Func<>).Assembly; // System.Private.CoreLib
Console.WriteLine(core.GetExportedTypes().Count(t => t.Namespace == "System" && t.Name.StartsWith("Func`")));
Console.WriteLine(core.GetExportedTypes().Count(t => t.Namespace == "System" && (t.Name == "Action" || t.Name.StartsWith("Action`"))));
17
17
That is 0 to 16 parameters for each. If you ever need 17, the method signature is the problem.
The Func example: one space breaks the build
Parameter types come first, the return type last:
Func<float> func0 = () => 0.1f; // no parameters, returns float
Func<int, float> func1 = (a) => a * 0.1f; // one int
Func<int, int, float> func2 = (a, b) => (a + b) * 0.1f; // two ints
Func<int, int, int, float> func3 = (a, b, c) => (a + b + c) * 0.1f;
In the 2021 article's full example, 0.1f had become 0.1 f and Func<int, float> was spread over several lines as Func < int, / float >. Whitespace inside generic brackets is harmless, but a numeric suffix must touch its number:
Program.cs(4,37): error CS1002: ; expected
Program.cs(7,46): error CS1002: ; expected
Program.cs(9,44): error CS1002: ; expected
Program.cs(12,53): error CS1002: ; expected
The fixed and tidied version. To reference an ordinary method, you can drop new Func<…>(temp) and just write the method name (method group conversion):
static float Temp(int a, int b, int c) => (a + b + c) * 0.1f;
Func<float> func0 = () => 0.1f;
Func<int, float> func1 = a => a * 0.1f;
Func<int, int, float> func2 = (a, b) => (a + b) * 0.1f;
Func<int, int, int, float> func3 = Temp; // ordinary method
Console.WriteLine("func0 returned: {0}", func0());
Console.WriteLine("func1 returned: {0}", func1(10));
Console.WriteLine("func2 returned: {0}", func2(10, 10));
Console.WriteLine("func3 returned: {0}", func3(10, 10, 10));
func0 returned: 0.1
func1 returned: 1
func2 returned: 2
func3 returned: 3
A side note: switch the same expression to double and the output changes. With Func<int, double> d = a => a * 0.1;, d(3) printed 0.30000000000000004, while the float version func1(3) printed 0.3. That is not because float is more precise; since .NET Core 3.0, ToString() prints the shortest string that round-trips for each type. For money, use decimal, not either of them.
The Action example
Action is Func without a return value. The 2021 Action example worked apart from the missing using System; (which .NET 6+ projects supply through implicit usings), and printed the same output:
static void Temp(string name) => Console.WriteLine("name: {0}", name);
int sum = 0;
Action act0 = () => Console.WriteLine("name: act0");
Action<string> act1 = Temp; // ordinary method
Action<string, string> act2 = (name, age) =>
{
Console.WriteLine("name: {0}", name);
Console.WriteLine("age: {0}", age);
};
Action<int, int, int> act3 = (a, b, c) => sum = a + b + c; // captures the outer sum
act0();
act1("act1");
act2("act2", "35");
act3(1, 2, 3);
Console.WriteLine("sum: {0}", sum);
name: act0
name: act1
name: act2
age: 35
sum: 6
act3 has no return value, so it hands its result back by writing to the captured variable sum. What that capture costs is measured in C# lambda expressions.
What Func cannot do: out/ref, and Predicate
out parameters
Func and Action type arguments cannot be ref or out, so storing int.TryParse fails:
Func<string, int, bool> tp = int.TryParse;
error CS0123: No overload for 'TryParse' matches delegate 'Func<string, int, bool>'
This is when you declare your own delegate:
delegate bool TryParser<T>(string s, out T value);
TryParser<int> tp = int.TryParse;
Console.WriteLine(tp("123", out var v) + " " + v); // True 123
Same signature, different type
Predicate<int> and Func<int, bool> have identical signatures but are different types, so one does not convert to the other. You meet this constantly because List<T>.FindAll takes a Predicate<T> while LINQ's Where takes a Func<T, bool>:
Predicate<int> isEven = n => n % 2 == 0;
Func<int, bool> f = isEven;
error CS0029: Cannot implicitly convert type 'System.Predicate<int>' to 'System.Func<int, bool>'
Wrap it with the Invoke method group or new Func<int, bool>(isEven):
Func<int, bool> f = isEven.Invoke;
var list = new List<int> { 1, 2, 3, 4 };
Console.WriteLine(string.Join(",", list.FindAll(isEven)) + " / " + string.Join(",", list.Where(f)));
// 2,4 / 2,4
Three multicast traps
Delegates can chain several methods with +=, which is handy for event-like Actions. Running it shows some surprising behaviour:
// 1. A chained Func returns only the last value
Func<int> multi = () => { Console.Write("A "); return 1; };
multi += () => { Console.Write("B "); return 2; };
Console.WriteLine("=> " + multi());
// 2. An exception stops the rest of the chain
Action chain = () => Console.Write("first ");
chain += () => throw new InvalidOperationException("boom");
chain += () => Console.Write("third ");
try { chain(); } catch (Exception e) { Console.WriteLine("| caught: " + e.Message); }
// 3. -= with a lambda removes nothing
Action log = () => Console.Write("log ");
log += () => Console.Write("tick ");
log -= () => Console.Write("tick ");
log(); Console.WriteLine($"| count={log.GetInvocationList().Length}");
A B => 2
first | caught: boom
log tick | count=2
- Both methods ran, but the call returned only
2. To collect each value, call them one by one:foreach (Func<int> d in multi.GetInvocationList()) d();printedA 1 B 2. - The second method threw, so
thirdnever ran. If one subscriber must not block the others, iterateGetInvocationList()and wrap each call intry. - Two identical-looking lambdas are two different delegate instances, so nothing was removed. Store the lambda in a variable and use that same instance for
+=and-=(we then gotcount=1). Method groups (m -= Hello) compare by method and target, so removing them works.
Also, with nullable reference types enabled, -= triggers warning CS8601: Possible null reference assignment. Removing the last method leaves the delegate null, which is why you call it as log?.Invoke(). Invoking a null delegate directly throws NullReferenceException.
Call cost: direct, interface, delegate (measured)
To test the "delegates are slow" claim, we called a method computing x * 2 200 million times. The direct call is marked [MethodImpl(MethodImplOptions.NoInlining)] so the JIT cannot inline it away.
static long RunFunc(Func<int, int> f, int n) { long s = 0; for (int i = 0; i < n; i++) s += f(i); return s; }
// RunDirect: Calc.TwiceNoInline(i), RunIface: IOp.Apply(i), RunCustom: delegate int Op(int x)
Func<int, int> func = Calc.Twice; // Func pointing at a static method
Func<int, int> lam = x => x * 2; // lambda
.NET 10.0.14, Release, Apple M1 Pro, 200 million calls, three runs:
| Call | Run 1 | Run 2 | Run 3 | Per call |
|---|---|---|---|---|
| direct (no inlining) | 222.5 ms | 221.8 ms | 223.8 ms | 1.11 ns |
| interface | 302.3 ms | 303.1 ms | 303.1 ms | 1.51 ns |
Func to static method |
312.0 ms | 312.6 ms | 311.6 ms | 1.56 ns |
Func to lambda |
265.9 ms | 262.9 ms | 263.0 ms | 1.31 ns |
| custom delegate to static method | 315.3 ms | 312.5 ms | 312.1 ms | 1.56 ns |
A delegate call cost about 0.2–0.45 ns more than a direct call, roughly the same as an interface call. Func and a custom delegate type performed identically. The interesting part: the lambda was faster than a delegate to the equivalent static method. The compiler emits lambdas as instance methods, and a delegate bound to a static method is known to need an extra argument-shuffling step on each call. Either way the gap is tens of milliseconds per 200 million calls, so choose for readability, not speed.
When to use Func/Action and when to declare your own
| Situation | Use |
|---|---|
| A method takes a callback or condition | Func/Action (the LINQ convention) |
You need out/ref/in parameters |
a custom delegate |
| Public library API where the name should explain the role | a custom delegate (e.g. delegate decimal PriceRule(Order order)) |
| Events | event EventHandler<T> or event Action<T>, invoked with ?.Invoke |
| You need every subscriber's return value | iterate GetInvocationList() |
| Several related methods plus state | an interface instead of delegates |
List<T>.FindAll, Array.Find |
Predicate<T> (not assignable to or from Func) |
For lambda syntax when assigning to Func (natural types, default parameters), see C# lambda expressions; for the API that takes the most Funcs, see LINQ basics.
Tested with .NET SDK 10.0.403 / runtime 10.0.14, C# 14, macOS on Apple M1 Pro.
FAQ
What is the difference between Func and Action?
Func is for methods that return a value, and its last type argument is the return type (Func<int, float> takes an int and returns a float). Action is for methods that return void. Counting on .NET 10, there are 17 of each, for 0 to 16 parameters.
Can a Func hold a method with out or ref parameters?
No. Func<string, int, bool> tp = int.TryParse; fails with error CS0123. Declare your own delegate, for example delegate bool TryParser<T>(string s, out T value);. Since C# 10, a lambda with an out parameter assigned to var gets a compiler-generated anonymous delegate type.
What does a multicast Func return?
Every method in the chain runs, but only the last return value survives. With two methods returning 1 and 2, the call returned 2. To get every result, call each delegate from GetInvocationList().