Skip to content

Chapter 14: Func<> and Action<> - Passing Logic as Parameters

Theoretical Foundations

In the previous book, we established the foundation of object-oriented programming (OOP) by encapsulating data and behavior into classes. We explored how interfaces define contracts, allowing different implementations to be swapped. However, as we move into modeling complex systems and AI data structures, we encounter a limitation: interfaces are excellent for defining who can perform an action, but they are often too heavy-weight for defining what the action actually is when that action is a single, specific piece of logic.

To solve this, C# provides delegates, which are type-safe function pointers. In this chapter, we focus on two specific built-in delegates: Func<> and Action<>. These allow us to treat logic as a "first-class citizen"—meaning we can pass executable code around just like we pass integers or strings.

The Core Distinction: Action vs. Func

At their core, these two delegates categorize executable logic based on one fundamental difference: return values.

  1. Action<>: Represents a method that performs an action (a side effect) but returns nothing (void).
  2. Func<>: Represents a method that calculates a value and returns it.

The Action<> Delegate

Action is used when you want to tell a system, "Here is a block of code; execute it, but don't expect a result back."

The generic Action can take up to 16 input parameters. The syntax is Action<T1, T2, ...>, where T represents the input types. There is no return type parameter because Action always returns void.

Example:

// A simple Action that takes a string and prints it to the console.
Action<string> logMessage = (message) => Console.WriteLine($"LOG: {message}");

// Execution
logMessage("System initialized."); 

The Func<> Delegate

Func is used when you want to transform input into output. It is essential for data processing pipelines, common in AI data preprocessing.

The syntax is Func<T1, T2, ..., TResult>, where TResult is always the last type parameter and represents the return type.

Example:

// A Func that takes a string and returns its length (an integer).
Func<string, int> calculateLength = (text) => text.Length;

// Execution
int length = calculateLength("Hello World"); 
// length is 11

Real-World Analogy: The Kitchen

Imagine a professional kitchen.

  • Action<> is a Task Command.
    A chef shouts, "Chop these onions!" (Input: onions). The chef expects the chopping to happen (Side Effect: the onions are now diced), but they do not expect a physical object returned to them. The result is the changed state of the world (the chopped onions).
    Signature: Action<Ingredient>

  • Func<> is a Recipe Transformation.
    A baker asks, "Mix flour and water to make dough." (Inputs: flour, water). The baker expects a specific result back: the dough.
    Signature: Func<Flour, Water, Dough>

Lambda Expressions: The Syntax of Logic

While we can pass named methods to Func and Action, modern C# relies heavily on Lambda Expressions for conciseness. A lambda expression is an anonymous function that allows you to write inline logic.

Syntax: (parameters) => expression

  • For Action: (x) => DoSomething(x)
  • For Func: (x) => x * 2

Why Lambdas Matter for AI Data Structures

In AI, we often perform operations on datasets where the logic needs to be dynamic. For example, in a neural network, the activation function (like ReLU or Sigmoid) changes the output of a neuron based on an input.

Instead of writing a class hierarchy IRelation with Sigmoid and ReLU classes, we can simply pass the math as a Func.

// Defining an activation function dynamically
Func<double, double> relu = (x) => x > 0 ? x : 0;

// Applying it to a tensor-like operation
double ProcessNeuron(double input, Func<double, double> activation)
{
    return activation(input);
}

Architectural Implications: Decoupling Logic

This pattern allows us to decouple the invoker (the code running the logic) from the implementation (the specific logic).

Consider an AI data processing pipeline. The pipeline needs to clean data, but "cleaning" might mean different things depending on the model (e.g., OpenAI vs. Local Llama).

Without Func/Action (Hard-coded):

public class DataPipeline
{
    public void Process(string data)
    {
        // Hard-coded logic
        string cleaned = data.Trim().ToLower();
        // ... rest of processing
    }
}

With Func/Action (Flexible):

public class DataPipeline
{
    // The pipeline doesn't know HOW to clean, it just receives the cleaning logic.
    private readonly Func<string, string> _cleaningStrategy;

    public DataPipeline(Func<string, string> cleaningStrategy)
    {
        _cleaningStrategy = cleaningStrategy;
    }

    public void Process(string data)
    {
        string cleaned = _cleaningStrategy(data);
        // ... rest of processing
    }
}

// Usage
var openAiPipeline = new DataPipeline(text => text.Trim().ToLower());
var localLlamaPipeline = new DataPipeline(text => text.Replace("\n", " ")); // Different cleaning rule

Visualizing the Flow of Logic

The following diagram illustrates how logic flows through a system using delegates, contrasting it with direct method calls.

A diagram illustrating the flow of logic through delegates versus direct method calls, contrasting a pipeline that transforms text to lowercase with one that replaces newline characters.
Hold "Ctrl" to enable pan & zoom

A diagram illustrating the flow of logic through delegates versus direct method calls, contrasting a pipeline that transforms text to lowercase with one that replaces newline characters.

Deep Dive: Signatures and Variance

Understanding the exact signature is crucial for advanced type safety.

1. The Zero-Arity Cases

  • Action (no parameters, void return)
  • Func<TResult> (no parameters, returns TResult)

2. The Func Return Type

The Func delegate always places the return type at the end. This is a deliberate design choice to allow the compiler to infer types easily.

  • Func<int, bool>: Takes an int, returns a bool.
  • Func<int, int, int>: Takes two ints, returns an int.

3. Covariance and Contravariance

In advanced OOP, we often deal with inheritance hierarchies. Delegates support variance, which allows us to assign compatible delegates where specific ones are expected.

  • Covariance (Output): If Dog inherits from Animal, a Func<Dog> can be assigned to a Func<Animal>. This makes sense because a function returning a Dog is effectively returning an Animal.
  • Contravariance (Input): If Animal is the base of Dog, an Action<Animal> can be assigned to an Action<Dog>. This makes sense because a function that can process any Animal can certainly process a Dog.
class Animal { }
class Dog : Animal { }

void Example()
{
    // Covariance: Func<Derived> is assignable to Func<Base>
    Func<Dog> getDog = () => new Dog();
    Func<Animal> getAnimal = getDog; // Valid

    // Contravariance: Action<Base> is assignable to Action<Derived>
    Action<Animal> processAnimal = (a) => { /* ... */ };
    Action<Dog> processDog = processAnimal; // Valid
}

Edge Cases and Nuances

1. Capturing State (Closures)

When using lambda expressions with Func and Action, they can capture local variables from the surrounding scope. This is called a closure.

int multiplier = 10;
Func<int, int> multiply = (x) => x * multiplier; 
// 'multiplier' is captured. Even if the original method exits, the delegate holds the value.
Warning: Be careful with capturing loop variables in older C# versions (pre-C# 5), as they captured the variable reference, not the value. In modern C#, foreach captures the iteration value safely.

2. Null References

A Func or Action variable is a reference type. It can be null. Always check before invoking.

Func<int, int> safeFunc = null;
// safeFunc(5); // This throws NullReferenceException

if (safeFunc != null)
{
    safeFunc(5);
}
// Or use the null-conditional operator (C# 6+)
safeFunc?.Invoke(5);

3. Performance Considerations

Using Func and Action introduces a level of indirection. While the JIT compiler is excellent at optimizing delegates, creating many short-lived delegates in tight loops (like in a neural network training epoch) can cause pressure on the Garbage Collector.

For high-performance AI calculations, you might prefer:

  1. Struct-based delegates: Using System.Delegate.CreateDelegate to avoid boxing.
  2. Interfaces: If the logic is complex and needs to be stateful, an interface might be more appropriate than a simple Func.
  3. Expression Trees: For AI, sometimes you need to compile logic at runtime. Func is compiled to IL, but Expression<Func<...>> allows you to inspect the code structure before compiling it. This is how many AI frameworks translate C# code into GPU kernels.

Summary for AI Application

In the context of building AI systems, Func<> and Action<> are the glue that binds data structures to behavior.

  • Strategy Pattern: Swapping loss functions (e.g., Mean Squared Error vs. Cross Entropy) in a training loop.
  • Event Systems: Handling callbacks when an AI model finishes generating text (using Action<string>).
  • Data Transformation: Mapping raw database entities to AI-ready tensors using Func<Entity, Tensor>.

By mastering these delegates, you move from writing rigid, procedural code to writing flexible, functional-style code that adapts to the complex, shifting requirements of AI development.

Basic Code Example

Here is a basic code example illustrating the use of Func<> and Action<> to pass logic as parameters.

using System;

public class Program
{
    public static void Main()
    {
        // 1. Define a data source (a list of numbers)
        int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

        Console.WriteLine("--- Using Func<> to pass a calculation logic ---");

        // 2. Use Func<> to pass a predicate (a function that returns a bool)
        // We are passing the logic "IsEven" directly into the filter method.
        FilterAndPrint(numbers, IsEven);

        Console.WriteLine("\n--- Using Lambda Expressions for concise syntax ---");

        // 3. Use a Lambda Expression to define the logic inline
        // This avoids defining a separate named method for simple logic.
        FilterAndPrint(numbers, n => n > 5);

        Console.WriteLine("\n--- Using Action<> to pass an output logic ---");

        // 4. Use Action<> to pass a void method (a side effect)
        // We are passing the logic "PrintSquare" to execute on each number.
        ProcessAndAct(numbers, PrintSquare);
    }

    // ---------------------------------------------------------
    // METHOD: FilterAndPrint
    // Uses Func<> to accept a function that returns a boolean.
    // ---------------------------------------------------------
    public static void FilterAndPrint(int[] data, Func<int, bool> condition)
    {
        foreach (int num in data)
        {
            // The logic passed in via 'condition' is executed here.
            if (condition(num))
            {
                Console.Write(num + " ");
            }
        }
        Console.WriteLine(); // New line for formatting
    }

    // ---------------------------------------------------------
    // METHOD: IsEven
    // A specific function matching the Func<int, bool> signature.
    // ---------------------------------------------------------
    public static bool IsEven(int number)
    {
        return number % 2 == 0;
    }

    // ---------------------------------------------------------
    // METHOD: ProcessAndAct
    // Uses Action<> to accept a void method (side effect).
    // ---------------------------------------------------------
    public static void ProcessAndAct(int[] data, Action<int> action)
    {
        foreach (int num in data)
        {
            // The logic passed in via 'action' is executed here.
            action(num);
        }
    }

    // ---------------------------------------------------------
    // METHOD: PrintSquare
    // A specific action matching the Action<int> signature.
    // ---------------------------------------------------------
    public static void PrintSquare(int number)
    {
        Console.WriteLine($"{number} squared is {number * number}");
    }
}

Code Breakdown

  1. Defining the Data Structure: We start with a simple integer array numbers. In a complex AI system, this could represent a dataset of features or sensor readings.
  2. Using Func<> for Filtering:
    • The FilterAndPrint method accepts a Func<int, bool>. This delegate represents a method that takes an integer and returns a boolean.
    • Instead of hardcoding the logic (e.g., if (num % 2 == 0)), we accept the logic as a parameter. This makes the method reusable. We can filter for even numbers, odd numbers, or numbers greater than 5 without changing the FilterAndPrint method itself.
  3. Lambda Expressions:
    • In the second call to FilterAndPrint, we used n => n > 5.
    • This is a lambda expression. It is a shorthand syntax for creating an anonymous method that matches the Func<int, bool> signature. It reads as "input n returns n > 5".
  4. Using Action<> for Side Effects:
    • The ProcessAndAct method accepts an Action<int>. This delegate represents a method that takes an integer and returns void.
    • This is useful for operations that don't return a value but perform an action, such as logging, writing to a database, or updating a display.
  5. Separation of Concerns:
    • The logic what to do (filter, calculate square) is decoupled from the logic how to iterate (the foreach loop). This is a fundamental architectural pattern for building flexible and maintainable AI data structures.

Visualizing the Flow

The following diagram illustrates how logic is passed into methods and executed.

A method receives logic as a parameter, encapsulating it within a delegate, and then executes that logic dynamically when invoked.
Hold "Ctrl" to enable pan & zoom

A method receives logic as a parameter, encapsulating it within a delegate, and then executes that logic dynamically when invoked.

Common Pitfalls

  1. Confusing Func and Action:

    • Mistake: Using Action when you need to return a value, or Func when you just want to perform an action (void).
    • Correction: Remember the "F" in Func stands for Function (returns a value). The "A" in Action stands for Action (performs a task, returns void). If your logic needs to return a result (like a calculation or a boolean check), use Func. If it just needs to execute code (like printing to the console), use Action.
  2. Lambda Expression Scope:

    • Mistake: Trying to modify variables outside the lambda scope inside a lambda that is used in LINQ queries or parallel processing, leading to unexpected behavior or compilation errors.
    • Correction: Lambdas capture variables from their surrounding scope (closures). Be mindful that if the lambda modifies a captured variable, it can lead to side effects that are hard to debug. In the example above, n is a parameter, not a captured variable, which is safe.
  3. Signature Mismatch:

    • Mistake: Passing a method with the wrong signature (e.g., passing a method that takes two integers to a Func<int, bool> parameter).
    • Correction: Ensure the method or lambda you pass exactly matches the delegate's signature: the number of parameters, their types, and the return type must align.

The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon



Code License: All code examples are released under the MIT License. Github repo.

Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.

All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.