C# Delegates: Type-Safe Function Pointers Explained

This article explores delegates in C#, explaining their fundamental role as managed, type-safe function pointers within the .NET ecosystem. You will learn what delegates are, how they contrast with traditional C/C++ function pointers by enforcing strict compile-time signature checks, and how to define and execute them efficiently in your applications.

What Is a Delegate in C#?

A delegate in C# is a reference type that encapsulates a method with a specific signature and return type. Rather than holding data, a delegate holds a reference to one or more methods. Once instantiated, a delegate can invoke its referenced method as if it were called directly, allowing methods to be passed as arguments, stored in variables, and executed dynamically.

How Delegates Act as Function Pointers

In unmanaged languages like C and C++, function pointers store raw memory addresses pointing to executable code. While powerful, traditional function pointers are inherently unsafe: they do not guarantee that the target function matches the expected parameters or return type, which can lead to memory corruption, stack imbalance, and runtime crashes.

A C# delegate provides the same core capability—decoupling the caller from the target method—while executing under the control of the Common Language Runtime (CLR). Instead of pointing to arbitrary memory locations, a delegate is an object derived from System.MulticastDelegate. It maintains metadata about the method it targets, including a reference to the method and the object instance on which the method should be invoked.

The Meaning of "Type-Safe"

The term "type-safe" signifies that the C# compiler and runtime rigorously verify that the signature of the target method matches the signature defined by the delegate declaration.

Type safety is enforced in several ways:

  • Return Type Verification: The method assigned to the delegate must return the exact type (or a compatible type via covariance) specified by the delegate.
  • Parameter Matching: The number, order, and data types (including modifiers like ref and out) of the parameters must strictly align with the delegate's definition.
  • Compile-Time Validation: If an attempt is made to assign an incompatible method to a delegate, the compiler throws an error immediately, preventing invalid method calls before the code ever runs.

Declaring and Using a Delegate

Using a custom delegate involves three primary steps: declaration, instantiation, and invocation.

using System;

// 1. Declare a delegate type with a specific signature
public delegate void LogMessage(string message);

class Program
{
    static void Main()
    {
        // 2. Instantiate the delegate pointing to a compatible method
        LogMessage logger = ConsoleLogger;

        // 3. Invoke the method through the delegate
        logger("Execution started.");
    }

    static void ConsoleLogger(string text)
    {
        Console.WriteLine($"[LOG]: {text}");
    }
}

If ConsoleLogger took an int instead of a string, or returned a bool instead of void, the compiler would reject the assignment.

Modern C# Alternatives: Action and Func

While custom delegates can be defined using the delegate keyword, modern C# development frequently utilizes the built-in generic delegate types provided in the System namespace:

  • Action<...>: Represents a method that takes zero or more parameters and does not return a value (void).
  • Func<..., TResult>: Represents a method that takes zero or more parameters and returns a value of type TResult.
  • Predicate<T>: Represents a method that takes an object of type T and returns a bool.

These built-in types maintain the exact same type-safety and function-pointer behavior while eliminating the boilerplate code required to declare custom delegate types.