📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is a delegate in C#? Explain what a delegate object contains, how it is created and invoked, delegate equality, how instance and static method targets differ, what allocations happen, and how delegates compare with interfaces and function pointers.

Delegates as type-safe method references: anatomy (target plus method), creation and invocation, allocation and caching, equality, and choosing delegates versus interfaces or function pointers.

Junior Theory Intermediate callbacks csharp delegates functional performance

🎯 Key points

  • A delegate type declares a method signature; a delegate instance is an object referencing a method (and its target object for instance methods)
  • Every delegate type derives from `System.MulticastDelegate`; the compiler generates `Invoke` (and BeginInvoke/EndInvoke, unsupported on .NET Core)
  • Creating a delegate from an instance method captures the target object; it keeps that object alive
  • Delegates are immutable reference types; equality compares target and method
  • Creating a delegate allocates; the compiler caches non-capturing lambdas and, since C# 11, static method group conversions
  • Use `Func`/`Action` for most cases; declare a custom delegate when the name, ref/out parameters or documentation matter
  • Interfaces suit multi-method contracts; function pointers (`delegate*`) are for low-level, allocation-free interop

⏱ 30-second answer

A delegate is a type-safe reference to a method. The delegate type defines the signature, and a delegate instance is an object that holds the method and, for instance methods, the target object, so calling Invoke calls that method on that object. Delegates are immutable reference types that derive from MulticastDelegate, and two delegates are equal if they point at the same method on the same target. Creating one allocates, though the compiler caches non-capturing lambdas. I use Func and Action for most callbacks, and an interface when the contract has more than one method.

🗣 Full answer (senior)

How I would answer this in an interview

A delegate is a type-safe reference to a method — an object you can store, pass and invoke later, like a function pointer that also knows its target object and is checked by the type system. Delegates power callbacks, LINQ, events, Task.Run, ASP.NET Core middleware and minimal APIs. I'd explain what a delegate object contains, creation and invocation, equality, static vs instance targets, allocations, and how delegates compare with interfaces and function pointers.

1. Declaring, creating and invoking

public delegate decimal DiscountRule(Order order);   // a delegate TYPE (a class deriving from MulticastDelegate)

decimal LoyaltyDiscount(Order o) => o.Total * 0.05m;

DiscountRule rule = LoyaltyDiscount;                  // method group conversion
DiscountRule rule2 = o => o.Total > 1000m ? 50m : 0m; // lambda
DiscountRule rule3 = new DiscountRule(LoyaltyDiscount); // explicit (old style)

decimal d = rule(order);                              // invoke (same as rule.Invoke(order))

In practice you usually use the built-in generic delegates: Func<Order, decimal>, Action<string>, Predicate<T>.

2. What a delegate object contains

A delegate instance is a heap object (a class derived from System.MulticastDelegate) holding:

  • _target — the object to call the method on (this for instance methods; for static methods, the delegate itself or null depending on the invocation stub);
  • _methodPtr — a pointer to the code to call (or to a stub);
  • _methodPtrAux — extra pointer used for static methods (shuffle thunks) and other cases;
  • _invocationList / _invocationCount — for multicast delegates, the array of combined delegates.

Method and Target properties expose the method (MethodInfo) and target object via reflection.

3. Instance vs static targets

  • Instance method: _target = the object; invocation calls the method with _target as this. The delegate keeps the target alive — important for memory leaks (event handlers holding subscribers).
  • Static method: there's no this, so the runtime uses a small shuffle thunk to rearrange arguments (the delegate's own this slot is dropped). Historically this made static-method delegates slightly slower than instance-method delegates; modern runtimes have reduced the difference. Lambdas that capture nothing are compiled as instance methods on a cached singleton (<>c class) partly for this reason.

4. Delegate equality

Two delegates are equal (==/Equals) when they have the same type, the same target and the same method (and for multicast, equal invocation lists in order). That's how -= finds the handler to remove:

button.Click += OnClick;
button.Click -= OnClick;            // works: new delegate equal to the stored one

button.Click += (s, e) => Log();
button.Click -= (s, e) => Log();    // does NOT remove: different lambda = different method (and maybe target)

Store a lambda in a variable if you'll need to unsubscribe it.

5. Allocations

  • Creating a delegate from a method group allocates a delegate object (~64 bytes). C# 11+ caches delegates for static method groups (and non-capturing lambdas have always been cached in a static field).
  • A capturing lambda allocates a closure object plus a delegate — each time the containing scope runs.
  • Invoking a delegate doesn't allocate; it's an indirect call (not inlined unless the JIT can prove the target, e.g. with guarded devirtualisation via PGO).
  • Combining delegates (+=) allocates a new delegate with a new invocation list array (delegates are immutable).

Hot-path tips: use static lambdas, state-passing overloads (GetOrAdd(key, static (k, arg) => ..., arg)), cache delegates in fields.

6. Delegates vs interfaces vs function pointers

Delegate Interface Function pointer (delegate*<int, int>, C# 9)
Represents One method (+ optional target) A set of methods + object state Raw pointer to a static method
Allocation Delegate object (+ closure) Object implementing it None
Call cost Indirect call via delegate Interface dispatch Direct indirect call, cheapest
Multicast Yes No No
Can capture state Yes (closures) Yes (fields) No (static methods only)
Safe code Yes Yes Requires unsafe
Best for Callbacks, LINQ, events, small strategies Rich contracts, DI, multiple operations Interop, extreme hot paths

Use delegates for single-operation customisation (Func<Order, bool> filter), interfaces when there are several related operations or you want DI and naming.

7. A production example

A rules engine stored thousands of capturing lambdas created per request (rules.Select(r => (Func<Order,bool>)(o => r.Matches(o, ctx)))), allocating closures and delegates on every call. Profiling showed them in the top allocations. We compiled each rule once into a cached delegate that took the context as a parameter (Func<Order, RuleContext, bool>), using static lambdas — per-request allocations dropped to zero for the rules path.

🔍 What the interviewer is checking

Checks the fundamentals behind events, LINQ and middleware.

Green flags: target plus method; immutability; keeps target alive; allocation and caching; knows Func/Action versus custom delegates; mentions BeginInvoke is unsupported on .NET Core.

Red flags: "a delegate is a pointer" with nothing about the target; cannot explain why event subscriptions leak; thinks delegates are value types.

📚 Learn in depth

1. Delegate types are classes

public delegate decimal DiscountRule(Order o); compiles to:

public sealed class DiscountRule : MulticastDelegate
{
    public DiscountRule(object target, IntPtr method);
    public virtual decimal Invoke(Order o);                         // implemented by the runtime
    public virtual IAsyncResult BeginInvoke(Order o, AsyncCallback cb, object state);  // not supported on .NET Core
    public virtual decimal EndInvoke(IAsyncResult result);
}

Invoke has no IL body — the runtime provides it. BeginInvoke/EndInvoke throw PlatformNotSupportedException on .NET Core; use Task.Run instead.

2. How invocation works

  • Single-cast instance delegate: Invoke loads _target into the this register and jumps to _methodPtr.
  • Static method delegate: _methodPtr points to a shuffle thunk that shifts arguments, then jumps to _methodPtrAux (the real method).
  • Multicast: Invoke iterates the invocation list and calls each.
  • Delegates to virtual methods resolve the target method at creation (ldvirtftn).

3. Compiler caching of delegates

  • Non-capturing lambdas: compiled to instance methods on a singleton <>c class; the delegate is cached in a static field on first use.
  • Static method groups: since C# 11, Func<int,int> f = Math.Abs; caches the delegate in a static field too.
  • Instance method groups and capturing lambdas: new delegate per evaluation.

4. Function pointers

unsafe
{
    delegate*<int, int, int> add = &Add;
    int r = add(1, 2);
}
static int Add(int a, int b) => a + b;
  • delegate* managed<...> for managed static methods; delegate* unmanaged[Cdecl]<...> for native callbacks ([UnmanagedCallersOnly]).
  • No allocation, no target, no multicast.
  • Used in high-performance interop (LibraryImport, CsWin32) and runtime internals.

5. Delegates and the JIT

Delegate calls are indirect and normally not inlined. With dynamic PGO (.NET 8+), the JIT can perform guarded devirtualisation for delegates: if a call site almost always invokes the same method, it checks _methodPtr and calls/inlines that method directly. This reduces the cost of LINQ predicates and callbacks in hot loops.

6. Delegates and memory leaks

A delegate holds a strong reference to its target. Storing delegates in long-lived places (static events, singletons, caches) keeps targets alive — the classic event-handler leak. Use weak event patterns, unsubscribe in Dispose, or avoid capturing large objects.

Try it yourself

  1. Inspect rule.Method and rule.Target for instance, static and lambda delegates.
  2. Benchmark allocations of a capturing lambda vs a static lambda vs a cached delegate.
  3. Try removing a lambda handler with -= using an identical-looking lambda.
  4. Write a function pointer call and compare with a delegate call in BenchmarkDotNet.

💻 Code

namespace Delegates.Basics;

public sealed record Order(decimal Total, bool IsLoyaltyMember);

public delegate decimal PriceRule(Order order, decimal currentPrice);

public sealed class LoyaltyDiscount(decimal rate)
{
    public decimal Apply(Order order, decimal price) => order.IsLoyaltyMember ? price * (1 - rate) : price;
}

public static class Rules
{
    public static decimal AddGst(Order order, decimal price) => price * 1.10m;
}

public sealed class PricingEngine(IReadOnlyList<PriceRule> rules)
{
    public decimal Price(Order order)
    {
        var price = order.Total;
        foreach (var rule in rules) price = rule(order, price);
        return decimal.Round(price, 2);
    }
}

public static class Program
{
    public static void Main()
    {
        var loyalty = new LoyaltyDiscount(0.05m);

        PriceRule discount = loyalty.Apply;   // instance target captured
        PriceRule gst = Rules.AddGst;         // static, cached since C# 11
        PriceRule promo = (o, p) => o.Total > 100m ? p - 10m : p;

        var engine = new PricingEngine([discount, promo, gst]);
        Console.WriteLine(engine.Price(new Order(150m, IsLoyaltyMember: true)));

        Console.WriteLine(discount.Target == loyalty);       // True: delegate keeps loyalty alive
        Console.WriteLine(gst.Target is null);              // True: static method
        Console.WriteLine(discount.Method.Name);            // Apply

        PriceRule again = loyalty.Apply;
        Console.WriteLine(discount == again);               // True: same target and method
        Console.WriteLine(ReferenceEquals(discount, again)); // False: two objects
    }
}

⚠ Common mistakes

1. Unsubscribing lambdas with new lambdas

What happens: x.Changed -= (s, e) => Handle();. Why it is wrong: a new delegate isn't equal to the original; nothing is removed. Do instead: store the delegate in a field and use it for both += and -=.

2. Capturing lambdas in hot paths

What happens: delegates created per call that capture locals. Why it is wrong: closure and delegate allocations per call. Do instead: static lambdas, state-passing overloads, cached delegates.

3. Using BeginInvoke on .NET Core

What happens: old async delegate pattern ported from .NET Framework. Why it is wrong: throws PlatformNotSupportedException. Do instead: Task.Run or async methods.

4. Delegates for rich contracts

What happens: passing five Func<> parameters to configure behaviour. Why it is wrong: unreadable and hard to test. Do instead: use an interface or options object.

5. Keeping targets alive unintentionally

What happens: long-lived delegates referencing short-lived objects. Why it is wrong: memory leaks. Do instead: unsubscribe, avoid capturing large objects, use weak references where appropriate.

6. Ignoring nullability of delegate fields

What happens: invoking a possibly null delegate field directly. Why it is wrong: NullReferenceException. Do instead: callback?.Invoke(args).

7. Custom delegate types where Func/Action suffice

What happens: many custom delegate declarations for simple signatures. Why it is wrong: extra types without benefit. Do instead: use Func/Action unless names, ref/out parameters or documentation justify a custom type.

8. Using function pointers without need

What happens: unsafe function pointers in ordinary application code. Why it is wrong: unsafe code for negligible gains. Do instead: reserve them for interop and measured hot paths.

🏛 Senior insight: trade-offs

Delegates are the backbone of LINQ, events, middleware (RequestDelegate) and minimal APIs, so seniors understand their cost model: one object per creation, a strong reference to the target, an indirect call that is rarely inlined. In application code that cost is irrelevant; in hot paths I check allocations with BenchmarkDotNet and use static lambdas or cached delegates. For extensibility I choose delegates for single operations and interfaces for multi-method contracts that need DI and mocking.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says delegates derive from MulticastDelegate. Why: Every delegate is a MulticastDelegate; tests knowledge of invocation lists.
    How to answer

    Any delegate can hold several methods. += calls Delegate.Combine, which creates a new immutable delegate with an invocation list, and invoking it calls each method synchronously in subscription order. For a delegate with a return value you only get the last handler's result, and if one handler throws, the rest are skipped. -= removes the last matching entry, which only works for lambdas if you kept the same instance. When I need every result or want one failing handler not to stop the others, I loop over GetInvocationList().

  2. When: Candidate mentions lambdas or caching of non-capturing lambdas. Why: Most delegates today are created from lambdas; tests closure and allocation knowledge.
    How to answer

    A lambda is an anonymous function, either an expression like x => x * 2 or a statement block. It has no type until it is converted, either to a delegate, which is compiled code, or to an Expression<Func<...>>, which is a data structure EF Core can turn into SQL. Since C# 10 a lambda can have a natural type, so var f = (int x) => x * 2 is a Func<int,int>. When it captures variables, the compiler hoists them into a closure class, which allocates. static lambdas forbid capture. Newer additions are attributes and return types (C# 10), default parameters (C# 12), and C# 14 parameter modifiers like out without writing types.

  3. When: Candidate declares a custom delegate. Why: Checks when to use built-in Func/Action instead of custom delegate types.
    How to answer

    Func is a delegate that returns a value, so Func<int, string> takes an int and returns a string. Action returns void, and Predicate<T> is a delegate that takes a T and returns bool. Older APIs like List<T>.FindAll use it, while LINQ uses Func<T, bool>. Even with identical signatures they are different delegate types, so you cannot pass a Func<T, bool> variable as a Predicate<T> without wrapping it. A lambda converts to either. I declare my own delegate only when I need ref or out parameters or a meaningful name.

↩ Usually asked after

More in Delegates and Events

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me