C# Fundamentals: From Your First Program to Async and JSON · Modern C# in Practice: Generics, Async, Errors and JSON

Generics, Delegates, Lambdas and Events

Write reusable generic types with constraints, pass behavior as Func and Action delegates, use lambdas, and publish events that other code can subscribe to.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A generic in-memory Repository<T> that stores any entity with an id and supports queries via delegates, and a Counter that raises an event whenever its value changes.

The idea

Generics let you write code once for many types without losing type safety. List<T> and Dictionary<TKey, TValue> are generic. Declare your own: class Repository<T>. Constraints state what a type argument must support:

class Repository<T> where T : class, IEntity, new()

Common constraints: class/struct, an interface or base class, new() (has a parameterless constructor), notnull.

Delegates are types that represent methods. In practice you use the built-in ones:

  • Func<T, TResult>: takes inputs, returns a value. Func<int, int, int> adds two ints.
  • Action<T>: takes inputs, returns nothing.
  • Predicate<T>: returns bool.

A lambda (x => x * 2) is a compact way to create a delegate; the compiler infers parameter types. Lambdas can capture variables from the surrounding scope (closures).

Events let a class announce that something happened: public event EventHandler<int>? Changed;. Subscribers attach with += and detach with -=; the publisher raises it with Changed?.Invoke(this, value). Events decouple the publisher from whoever is listening.

The code

A generic repository, delegates and an event
var repo = new Repository<Product>();
repo.Add(new Product(1, "Keyboard", 49m));
repo.Add(new Product(2, "Monitor", 189m));
repo.Add(new Product(3, "Cable", 5m));

// Delegates as arguments: the caller supplies the behavior
var cheap = repo.Find(p => p.Price < 50m);
Console.WriteLine("cheap: " + string.Join(", ", cheap.Select(p => p.Name)));

var names = repo.Select(p => p.Name.ToUpper());
Console.WriteLine("names: " + string.Join(", ", names));

// Func and Action stored in variables
Func<decimal, decimal, decimal> add = (a, b) => a + b;
Action<string> shout = text => Console.WriteLine(text.ToUpper() + "!");
shout($"total is {repo.Find(_ => true).Aggregate(0m, (sum, p) => add(sum, p.Price))}");

// A closure captures the variable `threshold`
var threshold = 100m;
Func<Product, bool> expensive = p => p.Price > threshold;
Console.WriteLine("expensive: " + repo.Find(expensive).Count);
threshold = 40m;   // the lambda sees the updated value
Console.WriteLine("expensive after change: " + repo.Find(expensive).Count);

// Events: the counter publishes, subscribers react
var counter = new Counter();
counter.Changed += (sender, value) => Console.WriteLine($"  changed to {value}");
counter.Changed += (sender, value) => { if (value % 3 == 0) Console.WriteLine("  divisible by three!"); };
for (var i = 0; i < 4; i++) counter.Increment();

interface IEntity
{
    int Id { get; }
}

record Product(int Id, string Name, decimal Price) : IEntity;

class Repository<T> where T : IEntity
{
    private readonly Dictionary<int, T> _items = new();

    public void Add(T item) => _items[item.Id] = item;
    public List<T> Find(Func<T, bool> predicate) => _items.Values.Where(predicate).OrderBy(i => i.Id).ToList();
    public IEnumerable<TResult> Select<TResult>(Func<T, TResult> selector) => _items.Values.OrderBy(i => i.Id).Select(selector);
}

class Counter
{
    public event EventHandler<int>? Changed;
    private int _value;

    public void Increment()
    {
        _value++;
        Changed?.Invoke(this, _value);     // ?. avoids an error when nobody has subscribed
    }
}

Output

cheap: Keyboard, Cable
names: KEYBOARD, MONITOR, CABLE
TOTAL IS 243!
expensive: 1
expensive after change: 2
  changed to 1
  changed to 2
  changed to 3
  divisible by three!
  changed to 4

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • Forgetting the null check when raising events. Changed(this, 1) throws if there are no subscribers; use ?.Invoke.
  • Never unsubscribing. A long-lived publisher keeps subscribers alive (a memory leak); detach with -= when done.
  • Capturing a loop variable by mistake. for (var i...) shares one i across closures; copy it to a local first. (foreach variables are safe.)
  • Over-constraining or under-constraining generics. Add only the constraints your code needs.
  • Using object instead of generics. You lose type safety and add casts and boxing.

Key points

  • Generics provide type-safe reuse; constraints (where T : IEntity) declare capabilities.
  • Func, Action and Predicate are the standard delegate types; lambdas create them concisely.
  • Lambdas capture variables, so they see later changes.
  • Events publish notifications with +=, -= and ?.Invoke; unsubscribe to avoid leaks.

Further reading: Generics and Events.

Practice questions

What does `where T : IEntity` accomplish?

Answer

It restricts the type argument to types implementing IEntity, so inside the generic class you can safely use IEntity members like Id. Types that don't implement it fail to compile.

Why write `Changed?.Invoke(this, value)` rather than `Changed(this, value)`?

Answer

An event with no subscribers is null, and invoking null throws NullReferenceException. The null-conditional operator only calls the event when at least one handler is attached.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?