← Open in the full interactive course (progress tracking, search & more)

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.

What Is It?

The Simple Explanation

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.

The Technical Definition — Their Real Declarations

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.

in T (contravariant)

out TResult (covariant)

Why Does It Exist?

The Problem — A Family Can't Grow Forever, and Neither Can a Lambda Be Free

Two separate, unrelated design pressures shaped how Action/Func actually behave in practice:

The Solution — A Bounded Family, and Honest Accounting

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.

The key insight

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.

Big Picture

Question Beginner-level answer (098-100) This lesson's answer
What are they?"Built-in delegate types"Ordinary generic delegates, declared with real in/out variance, in System.Private.CoreLib
Why stop at 16 parameters?Not coveredFixed arity per generic type + design smell past a handful of params
Does assigning a lambda cost anything?Not coveredYes — a delegate object, sometimes plus a closure object too

How It Works

FROM LAMBDA TO ALLOCATION
1. YOU WRITE A LAMBDA WHERE A Func/Action IS EXPECTED
items.Where(x => x.Price > threshold)
2. THE COMPILER TARGET-TYPES THE LAMBDA TO Func<Item, bool>
3. A REAL DELEGATE OBJECT IS CONSTRUCTED ON THE HEAP
4. THE DELEGATE IS INVOKED, ONCE PER ELEMENT

Simple Example

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.

Real-World Example

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.

Analogy

A Standard Socket Set, Not a Custom-Milled Wrench

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.

Under the Hood

ARITY EXPLOSION, AND THE REAL COST OF "JUST PASS A LAMBDA"
1. WHY 16, SPECIFICALLY
2. WHAT ASSIGNING A LAMBDA ACTUALLY ALLOCATES
3. WHY THIS MATTERS, CONCRETELY

Common Confusion

1. "Action can be marked out too, for consistency" — no, and there's a structural reason why not

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.

2. "All 16 arities of Func/Action are equally likely to be used" — they aren't, and that's fine

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.

3. "A method-group-assigned Func doesn't allocate, only a lambda does" — both allocate a delegate

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.

Common Mistakes

Mistake 1 — Reaching for a high-arity Func/Action instead of a parameter object

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.

Mistake 2 — Allocating a fresh capturing lambda inside a hot loop when it could be hoisted out

//  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."

Mistake 3 — Assuming a variance-enabled assignment also works for a generic class, not just Func/Action

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.

When Should I Use It?

Mental Model

Action<in T1, ..., in T16> — every parameter, contravariant.
Func<in T1, ..., in T16, out TResult> — parameters contravariant, result covariant.
A fixed family, not an unlimited one — past a handful of parameters, redesign, don't add more.
A lambda assigned to either one is an object, not free syntax — sometimes two objects, if it captures something.

Key Takeaway


Check Your Understanding

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?

Show answer

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?

Show answer

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?

Show answer

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?

Show answer

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.