Delegates and Lambdas
In C#, functions are values: you can store a method in a variable, pass it to another method, and return it as a result. The type of such a value is a delegate, and the most convenient way to write one is a lambda expression like x => x * 2. Delegates and lambdas power LINQ, events, callbacks and much of modern C#.
Func and Action
.NET provides ready-made generic delegate types, so you rarely need to declare your own:
Func<T1, …, TResult>— takes parameters and returns a value. The last type argument is the return type.Action<T1, …>— takes parameters and returns nothing.Predicate<T>— takes one value and returnsbool(equivalent toFunc<T, bool>).
Func<int, int> square = x => x * x; Func<double, double, double> hypot = (a, b) => Math.Sqrt(a * a + b * b); Func<string> greeting = () => "Hello!"; Action<string> shout = msg => Console.WriteLine(msg.ToUpper()); Predicate<int> isNegative = n => n < 0; Console.WriteLine(square(7)); Console.WriteLine(hypot(3, 4)); Console.WriteLine(greeting()); shout("delegates are values"); Console.WriteLine(isNegative(-5));
49 5 Hello! DELEGATES ARE VALUES True
Lambda syntax
A lambda has parameters, an arrow =>, and a body. The body can be a single expression or a block with statements:
Func<int, bool> isEven = n => n % 2 == 0; // one parameter: no parentheses needed Func<int, int, int> add = (a, b) => a + b; // several parameters Func<string, int, string> repeat = (string s, int n) => string.Concat(Enumerable.Repeat(s, n)); // explicit types Func<int, string> classify = n => // statement body { if (n < 0) return "negative"; return n == 0 ? "zero" : "positive"; }; Console.WriteLine(isEven(10)); Console.WriteLine(add(2, 3)); Console.WriteLine(repeat("ab", 3)); Console.WriteLine(classify(-4)); var multiply = (int a, int b) => a * b; // natural type: Func<int, int, int> var withDefault = (int step = 1) => step * 10; Console.WriteLine(multiply(6, 7)); Console.WriteLine(withDefault());
True 5 ababab negative 42 10
Passing behaviour into methods
Accepting a delegate parameter lets callers decide part of what a method does. One method handles many situations:
int[] numbers = [5, 12, 8, 21, 3, 16]; Console.WriteLine(string.Join(", ", Filter(numbers, n => n > 10))); Console.WriteLine(string.Join(", ", Filter(numbers, n => n % 2 == 1))); Console.WriteLine(string.Join(", ", Transform(numbers, n => n * 100))); Console.WriteLine(string.Join(", ", Transform(numbers, n => $"<{n}>"))); static List<T> Filter<T>(IEnumerable<T> items, Func<T, bool> keep) { var result = new List<T>(); foreach (T item in items) if (keep(item)) result.Add(item); return result; } static List<TOut> Transform<TIn, TOut>(IEnumerable<TIn> items, Func<TIn, TOut> map) { var result = new List<TOut>(); foreach (TIn item in items) result.Add(map(item)); return result; }
12, 21, 16 5, 21, 3 500, 1200, 800, 2100, 300, 1600 <5>, <12>, <8>, <21>, <3>, <16>
You've just written simplified versions of LINQ's Where and Select.
Method groups
Any method with a matching signature can be used as a delegate — just write its name without parentheses:
string[] inputs = ["12", "abc", "7", "", "40"]; var valid = inputs.Where(IsNumber); Console.WriteLine(string.Join(", ", valid)); int[] parsed = valid.Select(int.Parse).ToArray(); Console.WriteLine(parsed.Sum()); Array.ForEach(parsed, Console.WriteLine); static bool IsNumber(string s) => int.TryParse(s, out _);
12, 7, 40 59 12 7 40
Closures: capturing variables
A lambda can use variables from the method around it. It captures the variable itself, not a copy of its value, so later changes are visible — and the lambda can change it too:
int counter = 0; Action increment = () => counter++; increment(); increment(); increment(); Console.WriteLine(counter); Func<int, Func<int, int>> makeAdder = amount => x => x + amount; var addTen = makeAdder(10); var addHundred = makeAdder(100); Console.WriteLine(addTen(5)); Console.WriteLine(addHundred(5));
3 15 105
makeAdder returns a function. Each returned lambda remembers its own amount — that remembered environment is what "closure" means.
One trap: a classic for loop has a single variable shared by every pass, so lambdas created in the loop all see its final value. foreach creates a fresh variable each time and doesn't have this problem:
var fromFor = new List<Func<int>>(); for (int i = 0; i < 3; i++) fromFor.Add(() => i); var fromForeach = new List<Func<int>>(); foreach (int n in new[] { 0, 1, 2 }) fromForeach.Add(() => n); Console.WriteLine(string.Join(", ", fromFor.Select(f => f()))); Console.WriteLine(string.Join(", ", fromForeach.Select(f => f())));
3, 3, 3 0, 1, 2
To fix the for version, copy the loop variable into a local inside the loop body: int copy = i; fromFor.Add(() => copy);. Mark a lambda static (static x => x * 2) to forbid capturing altogether.
Custom delegate types
You can declare your own delegate type with the delegate keyword. It gives the signature a meaningful name, which can make APIs clearer:
PriceRule blackFriday = price => price * 0.7m; PriceRule clearance = price => Math.Max(1m, price - 20m); foreach (PriceRule rule in new[] { blackFriday, clearance }) { Console.WriteLine(rule(49.99m)); } delegate decimal PriceRule(decimal original);
34.993 29.99
Multicast delegates
A delegate can hold several methods. += adds one and -= removes one; invoking the delegate calls them all in order. Removing the last method leaves null, so the variable is declared nullable and called with ?.Invoke. This is the basis of events (next lesson):
Action<string>? notify = msg => Console.WriteLine($"email: {msg}"); notify += msg => Console.WriteLine($"sms: {msg}"); Action<string> log = msg => Console.WriteLine($"log: {msg}"); notify += log; notify?.Invoke("Order shipped"); notify -= log; Console.WriteLine("--"); notify?.Invoke("Order delivered");
email: Order shipped sms: Order shipped log: Order shipped -- email: Order delivered sms: Order delivered
Simple lambda parameters with modifiers (C# 14)
When a delegate type has out or ref parameters, C# 14 lets a lambda add the modifier without spelling out every parameter type:
TryParser<int> parse = (text, out result) => int.TryParse(text, out result); if (parse("123", out int value)) Console.WriteLine(value + 1); delegate bool TryParser<T>(string text, out T result);
124
Try it yourself
Build a small pipeline of text transformations as a list of Func<string, string> and apply them in order.
[learning-delegates-a]