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.
Action<>: Represents a method that performs an action (a side effect) but returns nothing (void).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.
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, returnsTResult)
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 anint, returns abool.Func<int, int, int>: Takes twoints, returns anint.
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
Doginherits fromAnimal, aFunc<Dog>can be assigned to aFunc<Animal>. This makes sense because a function returning aDogis effectively returning anAnimal. - Contravariance (Input): If
Animalis the base ofDog, anAction<Animal>can be assigned to anAction<Dog>. This makes sense because a function that can process anyAnimalcan certainly process aDog.
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.
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:
- Struct-based delegates: Using
System.Delegate.CreateDelegateto avoid boxing. - Interfaces: If the logic is complex and needs to be stateful, an interface might be more appropriate than a simple
Func. - Expression Trees: For AI, sometimes you need to compile logic at runtime.
Funcis compiled to IL, butExpression<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
- 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. - Using
Func<>for Filtering:- The
FilterAndPrintmethod accepts aFunc<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 theFilterAndPrintmethod itself.
- The
- Lambda Expressions:
- In the second call to
FilterAndPrint, we usedn => 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 "inputnreturnsn > 5".
- In the second call to
- Using
Action<>for Side Effects:- The
ProcessAndActmethod accepts anAction<int>. This delegate represents a method that takes an integer and returnsvoid. - 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.
- The
- Separation of Concerns:
- The logic what to do (filter, calculate square) is decoupled from the logic how to iterate (the
foreachloop). This is a fundamental architectural pattern for building flexible and maintainable AI data structures.
- The logic what to do (filter, calculate square) is decoupled from the logic how to iterate (the
Visualizing the Flow
The following diagram illustrates how logic is passed into methods and executed.
Common Pitfalls
-
Confusing
FuncandAction:- Mistake: Using
Actionwhen you need to return a value, orFuncwhen you just want to perform an action (void). - Correction: Remember the "F" in
Funcstands for Function (returns a value). The "A" inActionstands for Action (performs a task, returns void). If your logic needs to return a result (like a calculation or a boolean check), useFunc. If it just needs to execute code (like printing to the console), useAction.
- Mistake: Using
-
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,
nis a parameter, not a captured variable, which is safe.
-
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.
- Mistake: Passing a method with the wrong signature (e.g., passing a method that takes two integers to a
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.