You've used Action and Func in every lesson since 098. This time, open the BCL source, read their real declarations, and price out what each one actually costs to create.
By now Action<T> and Func<T, TResult> are second nature — you reach for them without thinking, the same way you reach for List<T>. That familiarity is exactly why this lesson is worth your time: the details you skipped over while learning the basics are the same details that explain three things experienced .NET developers are expected to know cold — why Func<Dog> assigns to Func<Animal>, why nobody writes Action<T1, ..., T17>, and why "just pass a lambda" is not actually a free operation.
None of this changes how you write Action/Func code. It changes how precisely you can explain what that code costs and why it's shaped the way it is.
There is no special language feature called "Action" or "Func." They are ordinary generic delegate types — the exact same delegate keyword mechanism from lesson 097 — that Microsoft happened to write once and ship inside System.Private.CoreLib, the assembly that forms the core of the BCL. Nothing about them is more privileged than a delegate type you declare yourself.
Here is (a representative slice of) how Action and Func are actually declared in the .NET source, variance annotations and all:
namespace System
{
public delegate void Action();
public delegate void Action<in T>(T obj);
public delegate void Action<in T1, in T2>(T1 arg1, T2 arg2);
// ... continues to Action<in T1, ..., in T16>
public delegate TResult Func<out TResult>();
public delegate TResult Func<in T, out TResult>(T arg);
public delegate TResult Func<in T1, in T2, out TResult>(T1 arg1, T2 arg2);
// ... continues to Func<in T1, ..., in T16, out TResult>
public delegate bool Predicate<in T>(T obj);
}
Notice the in/out keywords — this is not a simplification for the lesson, it's the literal shape of these declarations in System.Private.CoreLib. Every parameter type is marked in (contravariant); Func's return type is marked out (covariant). This is precisely the mechanism lesson 186 demonstrated with a hand-rolled Producer<out T>/Consumer<in T> pair — Action, Func, and Predicate simply are variant delegates, using exactly the rules from lesson 181.
Action/FuncAction<Animal> assignable to Action<Dog>Func's return type — Action has no return type to varyFunc<Dog> assignable to Func<Animal>Two separate, unrelated design pressures shaped how Action/Func actually behave in practice:
Func<T1, TResult> and Func<T1, T2, TResult> are entirely separate generic type definitions, not one flexible type with a variable number of parameters. Every arity the BCL wants to support has to be hand-declared and shipped.Func or Action variable isn't free — it constructs an actual object on the managed heap, the same way new Customer() does. You already know from Advanced Part I's GC lessons that heap allocation is cheap but never zero-cost.Microsoft drew the line at 16 type parameters for Action and Func (17 total for Func, once you count TResult) and stopped there — not arbitrarily, but because a method needing more than about 16 parameters is almost always a sign the design itself needs fixing, not that the delegate family needs to grow. And rather than pretend lambdas are magically free, .NET is explicit (in its allocation profiling tools, and in how the JIT/runtime actually behaves) about when a lambda allocates and when it doesn't — which is precisely what lesson 189 dissects in full.
Action/Func aren't an unlimited, free abstraction — they're a deliberately bounded family of ordinary heap-allocating delegate types. Both of those facts are design decisions with real, learnable reasons behind them, not incidental quirks.
items.Where(x => x.Price > threshold)
Func<Item, bool>'s exact signature — one parameter in, one bool out.x => x.Price > threshold reads the outer variable threshold, the compiler also needs a place to store that captured variable — which, as lesson 189 covers in full, usually means a second allocation for a closure object.Where calls the Func<Item, bool> once per item — the allocation already happened once, up front, when the lambda was created, not per-call.Reading the BCL source directly settles what "variance" means for Func/Action in practice — no guessing required:
using System;
class Animal { public virtual string Name => "Animal"; }
class Dog : Animal { public override string Name => "Dog"; }
class Program
{
static void Main()
{
// out TResult — Func's return type is covariant
Func<Dog> dogFactory = () => new Dog();
Func<Animal> animalFactory = dogFactory; // no cast needed
Console.WriteLine(animalFactory().Name); // "Dog"
// in T — Action's (and Func's) parameter types are contravariant
Action<Animal> announceAnimal = a => Console.WriteLine($"An animal: {a.Name}");
Action<Dog> announceDog = announceAnimal; // no cast needed
announceDog(new Dog()); // "An animal: Dog"
}
}
Code → Meaning → Result: Neither assignment above needs a cast, because the compiler is checking the exact same in/out annotations you just read straight out of Func's and Action's real declarations — this isn't special-cased behavior for these two types, it's ordinary generic delegate variance, doing exactly what lesson 186 predicted.
A shipping-cost calculator service accepts a pluggable pricing strategy. Because Func<TResult> is covariant, a factory built to hand back a specific, more-derived pricing result can be plugged in wherever the general base type is expected — no adapter class required:
using System;
public class ShippingQuote
{
public decimal Amount { get; init; }
}
public class ExpressShippingQuote : ShippingQuote
{
public DateTime GuaranteedByUtc { get; init; }
}
public class ShippingCalculator
{
// Accepts ANY Func that produces a ShippingQuote or anything more derived
public ShippingQuote Calculate(Func<ShippingQuote> quoteFactory) => quoteFactory();
}
class Program
{
static void Main()
{
var calculator = new ShippingCalculator();
// This factory promises a more specific type — ExpressShippingQuote —
// but Func<out TResult> covariance lets it stand in for Func<ShippingQuote>
Func<ExpressShippingQuote> expressFactory = () => new ExpressShippingQuote
{
Amount = 24.99m,
GuaranteedByUtc = DateTime.UtcNow.AddDays(1)
};
ShippingQuote quote = calculator.Calculate(expressFactory); // compiles — no cast
Console.WriteLine($"Quote: {quote.Amount:C}");
}
}
ShippingCalculator.Calculate was written once, against the base ShippingQuote type, and never needs to change to accept increasingly specific factories — exactly the same payoff variance gave you for interfaces in lesson 181, now showing up in an everyday Func signature.
Action/Func are like a standard socket set: a fixed, finite set of sizes (arities), manufactured once and shipped everywhere, that fits the overwhelming majority of bolts (method shapes) you'll ever encounter. Past a certain size, though, no toolmaker keeps producing bigger sockets forever — if you need something that large, the real fix is usually a different tool for the job (a parameter object), not a bigger socket.
And picking up any tool from the set — reaching into the box, grabbing a socket — takes a moment, however small. Assigning a lambda to Func/Action is the same: it's fast, it's routine, but it's not instantaneous or free — you're still reaching into the (managed) toolbox every time.
Func\`1, Func\`2, Func\`3 (using the CLR's internal backtick-arity naming) are genuinely separate metadata definitions, not one type with a variable-length parameter list.Action/Func family the BCL ships.)Func<int,int> f = Square;) or a lambda (Func<int,int> f = x => x * x;) — is a real object with a type derived from MulticastDelegate. Creating it means a heap allocation, exactly like new Customer().threshold in the earlier example), the compiler typically needs a second object — a compiler-generated closure — to hold that captured state, so the lambda can still reach it after the enclosing method has moved on. Lesson 189 covers exactly when and why that closure object gets created.Func<T, TResult> inside a hot loop, passing a fresh capturing lambda on every iteration, means paying that allocation cost on every iteration — a real, measurable pattern in allocation profilers, not a theoretical concern.Every Action type parameter is in because they're all parameters — there's no return type to mark out, since Action always returns void. This isn't an inconsistency; it directly follows from what Action is for.
In practice, the overwhelming majority of real-world Func/Action usage sits at 0, 1, or 2 type parameters. The higher arities exist for completeness and the rare case that needs them — their existence doesn't mean you should be reaching for Func<T1,...,T8,TResult> as a normal design choice.
Func<int,int> f = Square; (a method group) and Func<int,int> f = x => x * x; (a lambda) both construct a delegate object. The difference that matters for allocation is whether the lambda captures anything, and whether the compiler can cache the resulting delegate — not whether it's spelled as a method group or a lambda. Lesson 189 draws this line precisely.
Func<string, int, decimal, bool, DateTime, string, OrderResult> — technically legal, but unreadable at every call site, and a strong sign the underlying method's parameter list itself needs redesigning.
Group the related inputs into a small record: Func<OrderRequest, OrderResult>. This was already true at 5 parameters in lesson 098 — it's not less true at 7.
// A new Func + closure is allocated on every single iteration
for (int i = 0; i < items.Count; i++)
{
Process(x => x.Id == items[i].Id);
}
// Build the delegate once, outside the loop, when the logic doesn't need to change per-iteration
Func<Item, bool> matchesCurrent = x => x.Id == targetId;
Process(matchesCurrent);
When the exact same lambda logic is being (re)built inside a loop with nothing actually varying between iterations, hoist it out. When it genuinely must capture a per-iteration value, that allocation is often unavoidable — the point isn't "never allocate," it's "don't allocate needlessly."
Expecting List<Dog> to assign to a List<Animal> variable the same way Func<Dog> assigns to Func<Animal>.
Remember lesson 181's boundary: variance is a feature of generic interfaces and delegates only. List<T> is a class with read/write storage and is always invariant — this doesn't change just because Func happens to look similar syntactically.
Func/Action variance deliberately when designing an API that accepts factories or handlers — it lets callers supply more/less specific delegates without you writing overloads or adapters.Action/Func signature climbs past 2–3 type parameters, rather than treating "up to 16 is available" as "up to 16 is advisable."Action<in T1, ..., in T16> — every parameter, contravariant.Func<in T1, ..., in T16, out TResult> — parameters contravariant, result covariant.Action<in T1,...> and Func<in T1,...,out TResult> are the real, literal declarations in the BCL — parameters are contravariant, Func's result is covariant, using exactly the variance rules from lessons 181 and 186.Action/Func allocates a real delegate object on the heap; a lambda that captures outer variables typically requires an additional closure allocation too.You've read the real BCL declarations and priced out what a lambda assignment actually costs. Let's check it stuck.
1. In the BCL, Func<in T, out TResult> marks the parameter type in and the result type out. What does this tell you?
Correct: B
Why B is correct: These are the same generic variance annotations from interfaces and custom delegates — the compiler enforces them by checking that each type parameter is used exclusively in the matching position throughout the signature.
Why A is incorrect: Func uses the general-purpose variance mechanism available to any generic interface or delegate — it isn't special-cased.
Why C is incorrect: in/out are real, compiler-enforced modifiers that determine what assignments the compiler will and won't accept.
Why D is incorrect: Variance applies to reference-type conversions specifically; nothing about it restricts Func to value types — in fact the opposite, value types don't participate in reference conversions this way at all.
Reinforcement: Reading a delegate's own declaration is the definitive way to know its variance — no memorization needed.
2. Why does the BCL stop at Action<T1,...,T16> and Func<T1,...,T16,TResult> instead of supporting arbitrarily many parameters?
Correct: B
Why B is correct: Generics are resolved per exact arity, so Func with 3 parameters and Func with 4 parameters are genuinely different type definitions the BCL had to declare separately. Sixteen was a deliberate, generous stopping point, not a technical ceiling — and a method signature past that size almost always needs a parameter object regardless.
Why A is incorrect: There's no CLR-enforced cap at 16 — you can declare a custom generic delegate with more parameters yourself; the limit is specific to the shipped Action/Func family.
Why C is incorrect: C# syntax has no such parsing restriction; this is purely a question of which types the BCL chose to declare.
Why D is incorrect: This isn't a compatibility constraint — Action/Func were introduced together with generics and could have been declared at any arity from the start.
Reinforcement: "Arity explosion" — the need to hand-declare every parameter count — is the real, structural reason the family is finite.
3. Which statement about assigning a lambda to a Func<T, TResult> variable is accurate?
Correct: C
Why C is correct: A delegate is a real object derived from MulticastDelegate, so creating one is a heap allocation. Capturing an outer variable typically adds a second, compiler-generated closure object to hold that captured state.
Why A is incorrect: Delegates are real runtime objects, not something the compiler erases entirely — this is exactly why 096's "Under the Hood" described them as instances of a generated class.
Why B is incorrect: A capture-free lambda doesn't need a closure object at all — as lesson 189 covers, the compiler can even cache a single delegate instance for it and reuse that instance on every evaluation.
Why D is incorrect: The allocation happens wherever the lambda is evaluated and assigned — a loop makes the cost repeat more often, but a single lambda outside any loop still allocates once when it's created.
Reinforcement: Delegate creation is a real allocation; capturing is what usually turns it into two.
4. A method signature evolves to Func<string, int, decimal, bool, DateTime, OrderResult>. What does this lesson recommend?
Correct: C
Why C is correct: A high-arity Func/Action signature is hard to read at every call site and is a clear signal the inputs belong together conceptually — grouping them into one type restores clarity and matches the guidance already introduced in lesson 098.
Why A is incorrect: Having headroom up to 16 doesn't make using it a good idea — readability degrades well before that ceiling.
Why B is incorrect: Action always returns void; this method returns an OrderResult, so Action could never fit regardless of arity.
Why D is incorrect: A custom delegate has the same readability problem at the call site as a high-arity Func — parameter names on the delegate declaration don't appear at the call site either way; the actual fix is reducing the parameter count.
Reinforcement: Parameter objects, not higher arity, are the correct response to a growing Func/Action signature.
You can now read Action/Func's real BCL declarations, explain their variance precisely, and reason about their allocation cost. Next: a brand-new topic — expression trees, where a lambda stops being compiled code and becomes inspectable data instead.
dotnetmadeeasy.com — Learn C# and .NET, the right way.