C# (.NET 8 LTS) track

Lesson 18 · Part 5: Modern C#

Delegates and Lambdas

Pass behaviour with delegates and lambdas, and publish or subscribe to events.

Part 5: Modern C#4 min readFree lesson

Learning Objectives

  • Declare and use a custom delegate type.
  • Use the standard Action<>, Func<> and Predicate<> delegates.
  • Write expression-bodied and block-bodied lambdas with =>.
  • Method group conversion — create a delegate from a method name
  • Understand the basic event publish/subscribe model.

Key concepts

It is a concept that adds type safety to a function pointer. It can contain any method as long as the signature (parameters/return type) is the same.

delegate int BinaryOp(int a, int b);

BinaryOp add = (a, b) => a + b;
int r = add(3, 4);   // 7

.NET+%EC%9D%B4+%EC%A0%9C%EA%B3%B5%ED%95%98%EB%8A%94+%EC%9D%BC%EB%B0%98+%EB%8D%B8%EB%A6%AC%EA%B2%8C%EC% 9D%B4%ED%8A%B8%EB%A7%8C+%EC%95%8C%EC%95%84%EB%8F%84+99%+%EC%B6%A9%EB%B6%84%ED%95%A9%EB%8B%88%EB%8B%A4.

Standard type meaning Yes
Action / Action<T> / Action<T1,T2> … No return value Action<string> log = s => Console.WriteLine(s);
Func<TResult> / Func<T,TResult> … Last is the return type Func<int,int,int> add = (a,b) => a+b;
Predicate<T> returns bool (1-argument) Predicate<int> pos = n => n > 0;

Predicate<T> is effectively the same as Func<T, bool>, but some method signatures in BCL require Predicate<T>, such as Array.Find / List<T>.FindAll.

One-line anonymous function notation. If the body is long, you can create blocks with curly braces.

Func<int,int> square = n => n * n;            //Expression body
Func<int,string> classify = n =>              //Block body
{
    if (n > 0) return "positive number";
    return n < 0 ? "Negative number" : "0";
};

If the signature is correct, you can fill the delegate with just the method name, without the lambda.

list.ForEach(Console.WriteLine);   //Only method name in place of Action<string>!

The event keyword adds two constraints to the delegate.

  • Like obj.OnAlert = ... from the outside, cannot be completely replaced (only +=, -= are allowed)
  • Cannot be called from outside — Issued only inside the declared class
public event Action<string>? OnAlert;
OnAlert?.Invoke("Danger!");   //subscribers?. Safety call by

See as example

BinaryOp add = (a, b) => a + b;
BinaryOp mul = (a, b) => a * b;

int Apply(int x, int y, BinaryOp op) => op(x, y);

Console.WriteLine($"Apply(10, 5, add) = {Apply(10, 5, add)}");
Console.WriteLine($"Apply(10, 5, mul) = {Apply(10, 5, mul)}");

//top-level statements file, the type declaration must come last.
delegate int BinaryOp(int a, int b);

Execution result

add(3, 4) = 7
mul(3, 4) = 12
Apply(10, 5, add) = 15
Apply(10, 5, mul) = 50

Memo: A delegate is a first-class citizen that can be used anywhere: variable, parameter, or return value.

Action<string> greet = name => Console.WriteLine($"Hello, {name}!");
Func<int, int, int> add = (a, b) => a + b;
Func<int, int> square = n => n * n;

greet("Alice");
Console.WriteLine($"add(2, 3)  = {add(2, 3)}");
Console.WriteLine($"square(7)  = {square(7)}");

Execution result

Hi, Alice!
Hi, Bob!
add(2, 3)  = 5
square(7)  = 49
Negative number

Note: Action is “action only”, Func is “return value”. Even if the number of parameters increases, the same type is expanded as is.

Predicate<int> isPositive = n => n > 0;
Predicate<int> isEven = n => n % 2 == 0;

int[] numbers = [-3, -2, -1, 0, 1, 2, 3];
int firstPositive = Array.Find(numbers, isPositive);
List<int> evens = [.. numbers].FindAll(isEven);

Execution result

First positive number: 1
Even numbers: -2, 0, 2
3First value greater than: 0

Note: If the value you are looking for is not found, default(T) is returned. The default of int is 0, so to distinguish it from the real 0, use FindIndex, etc.

List<string> names = ["Alice", "Bob", "Charlie"];
names.ForEach(n => Console.WriteLine(n));   //lambda
names.ForEach(Console.WriteLine);           //method group (more concise)

Execution result

Alice
Bob
Charlie
Alice
Bob
Charlie
Length: 7

Note: Method groups are slightly faster than lambdas (cached in .NET 7) and more readable. However, the signature must match exactly.

internal sealed class Sensor
{
    public event Action<string>? OnAlert;
    public void CheckTemperature(int t)
    {
        if (t >= 100) OnAlert?.Invoke($"High temperature warning: {t} degrees");
    }
}

Execution result

[Log] High temperature warning: 120 degrees
[Screen] !! High temperature warning: 120 degrees!!

Memo: If you attach multiple handlers with +=, they will all be called in the order of registration. If you are worried about memory leaks, turn it off with -= when you no longer use it.

Common Mistakes

  1. Declare the delegate type directly — in most cases, Action/Func is sufficient.
  2. Trying to overwrite event with = from outside — compilation error. Only available for += / -=.
  3. Capture the external variable within the lambda, pass it asynchronously, and replace the original variable — You will see an unintended value called “Closure Capture”.
  4. There are no parameters like Func<int>, but () is omitted — the call is f(), and the variable reference is f.
  5. Substitute Predicate<T> and Func<T,bool> — Direct substitution is not possible due to different types. You can recreate it with Lambda.

Cleanup

  • Delegate to treat a method like a value, lambda to create it on the fly
  • Action / Func / Predicate takes precedence over direct delegate declarations
  • Shorten your code by one step with method group conversion
  • event is the issue/subscription model — only +=/-= are allowed, issuance is only inside the class

Try it yourself

cd src/DelegateBasics && dotnet run
cd ../ActionFunc && dotnet run
cd ../Predicate && dotnet run
cd ../MethodGroup && dotnet run
cd ../EventBasics && dotnet run

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

src/ActionFunc/ActionFunc.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern18</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
src/ActionFunc/Program.csC#
//Action<T...>: A standard delegate that points to a method with no return value.
Action<string> greet = name => Console.WriteLine($"Hello, {name}!");
greet("Alice");
greet("Bob");

//Func<...,TResult>: The last type is the return type.
Func<int, int, int> add = (a, b) => a + b;
Func<int, int> square = n => n * n;

Console.WriteLine($"add(2, 3)  = {add(2, 3)}");
Console.WriteLine($"square(7)  = {square(7)}");

//If the method body has one line, you can use a lambda, and if the method body has multiple lines, you can use a brace block.
Func<int, string> classify = n =>
{
    if (n > 0) return "positive number";
    if (n < 0) return "Negative number";
    return "0";
};

Console.WriteLine(classify(-5));
src/DelegateBasics/DelegateBasics.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern18</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
src/DelegateBasics/Program.csC#
//Declare the delegate type directly and treat the method like a variable.
BinaryOp add = (a, b) => a + b;
BinaryOp mul = (a, b) => a * b;

Console.WriteLine($"add(3, 4) = {add(3, 4)}");
Console.WriteLine($"mul(3, 4) = {mul(3, 4)}");

//The delegate can be placed in a variable and passed to another method.
int Apply(int x, int y, BinaryOp op) => op(x, y);

Console.WriteLine($"Apply(10, 5, add) = {Apply(10, 5, add)}");
Console.WriteLine($"Apply(10, 5, mul) = {Apply(10, 5, mul)}");

//In files with top-level statements, the type declaration must come after.
delegate int BinaryOp(int a, int b);
src/EventBasics/EventBasics.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern18</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
src/EventBasics/Program.csC#
//event is a delegate member that "issues a notification to multiple subscribers".
Sensor sensor = new();

//Subscription: Add a handler with =.
sensor.OnAlert += msg => Console.WriteLine($"[Log] {msg}");
sensor.OnAlert += msg => Console.WriteLine($"[Screen] !! {msg}!!");

sensor.CheckTemperature(80);    //normal
sensor.CheckTemperature(120);   //Issue a notification

internal sealed class Sensor
{
    //event Keyword: cannot be replaced with = from outside, only = / -= is allowed.
    public event Action<string>? OnAlert;

    public void CheckTemperature(int t)
    {
        if (t >= 100)
        {
            OnAlert?.Invoke($"High temperature warning: {t} degrees");   //?. Safe call when there are no subscribers.
        }
    }
}
src/MethodGroup/MethodGroup.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern18</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
src/MethodGroup/Program.csC#
//Instead of lambda, you can pass the existing method name as is — this is called "method group conversion".
List<string> names = ["Alice", "Bob", "Charlie"];

//Lambda version
names.ForEach(n => Console.WriteLine(n));

//Method group version — If the signature is correct, the method name is enough.
names.ForEach(Console.WriteLine);

//Func can also be filled with a method group.
Func<string, int> length = GetLength;
Console.WriteLine($"Length: {length("Charlie")}");

static int GetLength(string s) => s.Length;
src/Predicate/Predicate.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern18</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
src/Predicate/Program.csC#
//Predicate<T>: 1-argument delegate that returns bool (effectively the same as Func<T, bool>)
Predicate<int> isPositive = n => n > 0;
Predicate<int> isEven = n => n % 2 == 0;

int[] numbers = [-3, -2, -1, 0, 1, 2, 3];

//Array.Find — Finds the first element that satisfies the condition.
int firstPositive = Array.Find(numbers, isPositive);
Console.WriteLine($"First positive number: {firstPositive}");

//List<T>.FindAll — Returns all elements that satisfy the condition as a new list.
List<int> list = [.. numbers];
List<int> evens = list.FindAll(isEven);
Console.WriteLine($"Even numbers: {string.Join(First value greater than", ", evens)}");

//Lambdas can also be created and passed on the fly.
Console.WriteLine($"3: {Array.Find(numbers, n => n > 3)}"); //If not, 0 (default)

Assignment

  • Reference solution: shown below
  • Key concepts: Func<int,bool> parameters, lambda, IEnumerable<T>

Requirements

  • Create the IEnumerable<int> Filter(IEnumerable<int> source, Func<int,bool> predicate) method.
  • For int[] nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];, call the following as lambda and output it.
  • Only even numbers
  • 5 or more
  • Prime number (2 or more and no divisors other than itself)

Expected output

Even numbers: 2 4 6 8 10
5 or more: 5 6 7 8 9 10
Prime numbers: 2 3 5 7

Hint

  • It is OK to write Filter as foreach and yield return, or just collect it and return it as List<int>.

  • For decimal judgment, if n is less than 2, it is false, just divide from 2 to n-1 (simplified version).

  • Reference solution: shown below

  • Key concepts: Dictionary<string, Func<int,int,int>>, lambda registration, method group

Requirements

  • Place Dictionary<string, Func<int,int,int>> Ops in the Calculator class and register four operations (" ", "-", "*", "/").
  • The int Run(string op, int a, int b) method finds lambda in the dictionary and executes it.
  • If an unregistered operator enters, InvalidOperationException is thrown.
  • Call the following and print the results.
    • Run("+", 10, 3), Run("-", 10, 3), Run("*", 10, 3), Run("/", 10, 3)

Expected output

10 + 3 = 13
10 - 3 = 7
10 * 3 = 30
10 / 3 = 3

Hint

  • Registered like Ops["+"] = (a, b) => a + b;.
  • if (!Ops.TryGetValue(op, out var fn)) throw new InvalidOperationException(...) pattern.
  • Between int and /, only the quotient is returned.

Check the correct answer

After solving it yourself, compare it with the reference solution below.

Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

Homework01/Homework01.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern18</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
Homework01/Program.csC#
int[] nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

Console.WriteLine("Even numbers:" + string.Join(" ", Filter(nums, n => n % 2 == 0)));
Console.WriteLine("5 or more:" + string.Join(" ", Filter(nums, n => n >= 5)));
Console.WriteLine("Prime number:" + string.Join(" ", Filter(nums, IsPrime)));

//Receives Func<int,bool> and returns only elements that meet the conditions.
static IEnumerable<int> Filter(IEnumerable<int> source, Func<int, bool> predicate)
{
    foreach (int n in source)
    {
        if (predicate(n)) yield return n;
    }
}

//Decimal number judgment — Pass as is in place of Func<int,bool> as a method group.
static bool IsPrime(int n)
{
    if (n < 2) return false;
    for (int i = 2; i < n; i++)
    {
        if (n % i == 0) return false;
    }
    return true;
}
Homework02/Homework02.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern18</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
Homework02/Program.csC#
Calculator calc = new();

foreach (string op in new[] { "+", "-", "*", "/" })
{
    int r = calc.Run(op, 10, 3);
    Console.WriteLine($"10 {op} 3 = {r}");
}

internal sealed class Calculator
{
    //Operation name → Lambda mapping.
    public Dictionary<string, Func<int, int, int>> Ops { get; } = new()
    {
        ["+"] = (a, b) => a + b,
        ["-"] = (a, b) => a - b,
        ["*"] = (a, b) => a * b,
        ["/"] = (a, b) => a / b,
    };

    public int Run(string op, int a, int b)
    {
        if (!Ops.TryGetValue(op, out Func<int, int, int>? fn))
        {
            throw new InvalidOperationException($"Unsupported operation: {op}");
        }
        return fn(a, b);
    }
}

Learn this lesson with a mentor

Work through the C# (.NET 8 LTS) track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now