1. Introduction
You've probably heard in school: "It's a function of a function." Higher-order functions (Higher-Order Functions, HOF) are functions that either take functions as arguments, or return functions as results, or both.
Simply put: if your method can accept another function as a parameter (for example, a delegate or a lambda), or return one as a result — congrats, you have a higher-order function!
A higher-order function is like a master key that can not only open a door itself, but also hand you another key so you can open the right door later.
Real-world usage
All that sounds interesting and non-trivial. But why do developers on C# need such tricks in everyday life? Here's the shortest answer:
Higher-order functions make code flexible, reusable, and concise.
They're the foundation for things like LINQ, filtering and sorting collections, building data-processing pipelines, wiring callbacks, events, and even dependency injection.
Some real scenarios:
- Restrict a method's behavior by passing logic into it (e.g., filtering, sorting, transforming).
- Write a generic data handler where you "screw in" the needed operation via a delegate.
- Build processing chains ("pipelines") where each function mutates data according to its recipe.
- Create cross-platform abstractions: what to do on Windows, what — on Linux? Just pass the right function.
2. Examples of simple higher-order functions
A function that accepts another function
The classic example — a method that takes a delegate or a lambda.
// Higher-order function: accepts a function process as a parameter
void ForEach<T>(IEnumerable<T> collection, Action<T> process)
{
foreach (var item in collection)
{
process(item); // Call the function-argument
}
}
// Usage:
var numbers = new List<int> { 1, 2, 3 };
ForEach(numbers, n => Console.WriteLine($"Element: {n}"));
What's happening here? The ForEach method doesn't know what to do with each element. All it does is call the passed processor (process). That processor can be anything — print to console, save to a database, draw on screen, etc.
Yes, that's how the ForEach method on collections in C# works, and almost all LINQ methods are higher-order functions!
A function that returns a function
Now a slightly heavier example — a method that not only accepts but also returns a function.
// Higher-order function: returns another function
Func<int, int> CreateMultiplier(int factor)
{
// Return a lambda that uses the variable factor
return x => x * factor;
}
// Usage:
var multiplyBy10 = CreateMultiplier(10);
Console.WriteLine(multiplyBy10(7)); // 70
Here CreateMultiplier returns a function that multiplies its argument by a pre-set factor. This is more than an HOF — it's a "function factory."
A function that accepts and returns a function
Func<int, int> Compose(Func<int, int> f, Func<int, int> g)
{
// Will return a function that applies g, then f: f(g(x))
return x => f(g(x));
}
// Usage:
Func<int, int> increment = x => x + 1;
Func<int, int> doubleIt = x => x * 2;
var incrementThenDouble = Compose(doubleIt, increment);
Console.WriteLine(incrementThenDouble(5)); // (5 + 1) * 2 = 12
Compositions like this are at the core of stream processing — for example, when you process an array with Select, Where, OrderBy, etc.
3. How C# supports higher-order functions
In functional languages (Haskell, F#) all functions are higher-order by default. But C# (since version 2.0) supports this approach too thanks to delegates and lambdas.
- Delegates (Func, Action, Predicate) — function types.
- Lambda expressions — syntax for creating functions inline.
- Methods can accept and return delegates — so higher-order functions are supported "out of the box".
Visual diagram
flowchart LR
A[Data] --> B[Function 1]
B --> C[Function 2]
C --> D[Result]
subgraph "Processing pipeline (higher-order functions)"
B
C
end
4. Evolving our app
Let's continue developing our step-by-step demo app — let's make it a "Mini string processor".
Add a simple higher-order method
Imagine we have a list of user names and want to arbitrarily modify them using functions.
// A method that accepts a list of strings and a function for transformation
List<string> TransformNames(List<string> names, Func<string, string> transformer)
{
var result = new List<string>();
foreach (var name in names)
{
result.Add(transformer(name));
}
return result;
}
How to use this method?
var names = new List<string> { "Anna", "Boris", "Sergey" };
// Transform to upper case
var upperNames = TransformNames(names, n => n.ToUpper());
// Add "Mr./Ms." to each name
var politeNames = TransformNames(names, n => "Dear " + n);
foreach (var n in upperNames)
Console.WriteLine(n); // ANNA, BORIS, SERGEY
foreach (var n in politeNames)
Console.WriteLine(n); // Dear Anna, ...
The TransformNames method is generic: it delegates the transformation logic to the provided parameter (transformer), for example a call to ToUpper or any other recipe.
Adapting to types
Our example can be easily adapted for any data types.
// Generic method - higher-order function working with any T
List<TResult> Map<T, TResult>(List<T> items, Func<T, TResult> transformer)
{
var result = new List<TResult>();
foreach (var item in items)
{
result.Add(transformer(item));
}
return result;
}
Usage:
var numbers = new List<int> { 1, 2, 3 };
var doubled = Map(numbers, x => x * 2); // [2, 4, 6]
var strings = Map(numbers, x => $"Number: {x}"); // ["Number: 1", ...]
5. Filtering and aggregation via higher functions
Filtering and searching logic has long been implemented via higher-order functions.
// Filtering: higher-order function
List<T> Filter<T>(List<T> items, Predicate<T> criteria)
{
var result = new List<T>();
foreach (var item in items)
{
if (criteria(item)) // Call the criteria function
{
result.Add(item);
}
}
return result;
}
How to use:
var names = new List<string> { "Anna", "Boris", "Andrey" };
var aNames = Filter(names, n => n.StartsWith("A"));
// Result: "Anna", "Andrey"
6. The concept of function composition (function composition)
Higher-order functions let you not only use individual functions but also assemble them into chains — so-called compositions. In C# you can implement this as a function that takes two functions and returns a new one that combines them.
// Function composer: returns a function that applies g, then f
Func<T, TResult> Compose<T, TIntermediate, TResult>(
Func<TIntermediate, TResult> f,
Func<T, TIntermediate> g)
{
return x => f(g(x));
}
// Example:
Func<int, int> plusOne = n => n + 1;
Func<int, int> timesTwo = n => n * 2;
var plusOneThenDouble = Compose(timesTwo, plusOne);
Console.WriteLine(plusOneThenDouble(3)); // (3 + 1) * 2 = 8
7. Useful nuances
Historical background: why things used to be harder?
Before delegates and lambdas appeared, developers often wrote many similar loops, copied chunks of code to "filter", "transform", or "group" data. With higher-order functions you can extract the variable part of behavior into separate function-parameters — drastically reducing duplication and making code more expressive.
A bit of syntactic sugar: functions as expressions
Higher-order functions are often implemented via expression-bodied methods — concise one-line methods:
List<string> FilterNames(Predicate<string> pred) =>
Names.Where(name => pred(name)).ToList();
List<TResult> MapNames<TResult>(Func<string, TResult> transformer) =>
Names.Select(transformer).ToList();
Comparison with LINQ mechanisms
Let's see how LINQ uses higher-order functions:
| LINQ method | Which delegate it accepts | Purpose |
|---|---|---|
|
|
Filters elements |
|
|
Transforms elements |
|
|
Sorts by a key |
|
|
Aggregates (folds) a collection |
|
|
Checks for existence by condition |
All these methods are built around the idea of higher-order functions: you write your rules of operation, and the standard library provides the "infrastructure".
8. Possible mistakes and pitfalls
Confusion with delegate types.
At first it can be tricky to figure out where to use Action, where Func, and where Predicate.
Tip: If the function returns bool — it's probably a Predicate. If it returns a value — use Func, if it returns nothing — Action.
Variable capture (closures).
If a returned function uses variables from the outer scope, make sure the values are valid at the time of invocation. Variables are not copied, they are "captured".
Debugging complex chains.
When functions are composed into long pipelines, it gets hard to know which layer processed the data incorrectly. Add temporary logs and comments:
n => {
Console.WriteLine("Before operation:" + n);
var res = n * 2;
Console.WriteLine("After:" + res);
return res;
}
GO TO FULL VERSION