Hi there!
Delegates, Funcs, and Actions are all C# words, and are used extensively throughout the language, the frameworks you use, and in almost every project you're going to work on, you'll encounter them in one form or another, so what are they, why are they used, what's their benefit, and what are the different types of Delegates do we have in C#?
Let's Get Started!
Introduction 🎬
Delegates are an important feature of C#, and they're widely used, they're considered an advanced topic and sometimes aren't taught early on, but they're one of those concepts that's once learned, you'll find yourself using a lot. I always try to keep things as simple, and as straightforward as possible so that you don't only understand the concept discussed, but also feel comfortable using it.
So in this series about delegates, we'll be exploring the concept in depth, with practical code samples, easy to understand explanations, and dedicated posts to each delegate variation we have in C#. If that sounds interesting, keep reading!
Defining Delegates 📜
To begin our trip, we'll start by a definition from Microsoft Documentation:
A delegate is a type that represents references to methods with a particular parameter list and return type. When you instantiate a delegate, you can associate its instance with any method with a compatible signature and return type.
In summary, a delegate is like a type that defines how a method should look like, i.e its signature and return type, and instances of that delegate can be assigned different methods as long as they comply with the signature of the delegate.
If you don't know what a method signature means, let me explain.
Take a look at this simple Sum
method:
private int Sum(int x, int y) => x + y;
The name of the method, Sum
, the parameters int, int
, and their order, makes up what's known as a method signature. Two methods with the exact same signature cannot co-exist in the same class, like assuming you have another Sum
method that uses a different adding algorithm, will cause a compile time exception if the 2 methods shared the same name, same parameter list, and same parameters order.
NOTE: ⚠️ The name of the parameters isn't equated to in the method signature, like defining
Sum
again but asSum(int a, int b)
will still not work.
public int Sum(int x, int y) => x + y;
public int Sum(int a, int b) => a + b;
This code will throw this compile time exception:
Member with the same signature is already declared
Now with the definition out of the way, let's declare our first delegate that we'll invoke the previous sum method through.
Declaring a Delegate 🛠️
To define a delegate, we need to use the following template:
[ACCESS_MODIFIER] delegate [RETURN_TYPE] [NAME]([PARAMETERS_LIST]);
Let's break it down:
- Access Modifier: Defines the accessibility level for the delegate type, can be
public
,private
,internal
just to mention a few. -
delegate
: This keyword is a C# reserved keyword, used to denote that the declared type is a delegate type. - Return Type: Defines the return type of the delegate type, can be any primitive data type like
int
,string
... Or a user defined type likeCustomerDetails
,Employee
... In addition tovoid
indicating the delegate returns nothing. - Name: A unique identifier for the delegate type.
- Parameters List: The list of the parameters the delegate type is expecting, remember the order matters, this can be up to 254 parameters for instance methods, and 255 for static methods including
this
keyword.
Let's Declare Our First Delegate 🚀
In a text editor of your choice, type out this code in a console application:
public delegate int SumDelegate(int x, int y);
Now a compatible method should look like that:
int Sum(int x, int y) => x + b;
It's got the same return type, and parameters list. Let's see how we can put these together and invoke the Sum
method using our SumDelegate
.
Inside Program.cs, we'll write this code:
int x = 5;
int y = 6;
SumDelegate sumDel = new SumDelegate(Sum);
int result = sumDel(x,y);
Console.WriteLine(result); // OUTPUT: 11
static int Sum(int x, int y) => x + y;
public delegate int SumDelegate(int x, int y);
NOTE: ⚠️ Please make sure to put the delegate definition at the bottom of the file, because we're using top-level statements where namespace and type declaration should go below any code that we want to execute (Any top-level statements).
This program will output 11
to the console window, which is the correct result of our addition operation. The interesting part to notice, is how we instantiated our delegate instance.
To new up a delegate, we use the new
keyword, just like with classes, structs and so on. Then in the constructor invocation, we pass the target method the delegate will point to, in our case it's the Sum
method. If you try to pass a method with an incompatible signature, this code will simply not compile, take a look at the following code:
SumDelegate sumDel = new SumDelegate(Sum);
...
static double Sum(double a, double b) => a + b;
// static int Sum(int a, int b) => a + b;
public delegate int SumDelegate(int a, int b);
I replaced the old Sum with a Sum
that uses double
as both return type and parameters, now my editor will show this compile-time error:
Expected a method with 'int Sum(int, int)' signature
The other thing to note, is how we invoked the delegate, we did so like how we would invoke a normal method, by typing the delegate name, followed by parenthesis, passing down the parameters we want to invoke Sum
with. Also, because our delegate SumDelegate
returns an int
, we were able to store the result inside a variable of type int
too, we then used that to log the operation result to the console.
🎉 Congratulations! You declared your first delegate and invoked a method through it!
Clearing Some Confusion 🤔
I know by now, you're having the question as to why did we do all of that, just to call a method which we could've done the normal way, and got the same result, like I'm sure you're asking yourself why don't we make the code look like this:
int x = 5;
int y = 6;
int result = Sum(x, y);
Console.WriteLine(result);
static int Sum(int a, int b) => a + b;
Wouldn't that result in the same output but with just less code, and less complexity? The answer is, you're absolutely right.
But the thing is, delegates aren't just used to invoke methods like we just did, instead, the power of delegates shines when you get to pass methods as arguments to other methods, which in normal method invocation, is simply not possible, in addition to being able to invoke multiple methods at once, using multicast delegates, defining callbacks or respond to events, all of which we're going to discuss later in this series.
Assigning Methods to Delegates 🧪
We declared a delegate earlier, and we assigned a target method to it through the constructor. However, there are other ways of doing that in C#, plus the ability to replace target methods with different ones.
We'll code a basic message logging example, which will set us up to look at multicast delegates and see them in action.
Coding a Basic Logger 🪵
We'll start by declaring a class called ConsoleLogger
, which has the following code:
public static class ConsoleLogger
{
public static void Log(string message)
{
Console.WriteLine($"Log: {message} - {DateTime.Now}");
}
}
The logger is pretty basic, it's got a single static method called Log
, accepting a string
message which it writes to the console as well as the current date time.
In Program.cs, I added a new delegate declaration at the bottom which has the following definition:
public delegate void LogDelegate(string message);
As you can see, the Log
method matches the signature and return type of the LogDelegate
, which means it's compatible with it, so what we will do now is, instantiate an instance of LogDelegate
, and assign it the Log
method of ConsoleLogger
like this:
LogDelegate logDel = ConsoleLogger.Log;
logDel("Hello, world!");
public delegate void LogDelegate(string message);
public class ConsoleLogger
{
public static void Log(string message)
{
Console.WriteLine($"Log: {message} - {DateTime.Now}");
}
}
That's some syntactic sugar, we didn't have to use the new
keyword and invoke the constructor explicitly, we just assigned ConsoleLogger.Log
to a variable of type LogDelegate
right away.
If you were to run this, you would see something like this:
Ok now let's assume we introduced a new logger, called FileLogger
, which also has a Log
method, but instead of writing a message to the console, it rather logs it into a text file called log.txt
, like this:
public class FileLogger
{
public static void Log(string message)
{
File.AppendAllText("log.txt", $"Log: {message} - {DateTime.Now}" + Environment.NewLine);
}
}
Same thing as before, but we're just writing to a file this time instead of the console window, we log the message, with the current date time, and append a new line at the end so our next log sits nicely on a separate line.
Now change Program.cs to this:
LogDelegate logDel = ConsoleLogger.Log;
logDel("Hello, world!");
logDel -= ConsoleLogger.Log;
logDel += FileLogger.Log;
logDel("Hello, world, but from File!");
Here's a summary of the changes made:
- Removes the reference to the
ConsoleLogger.Log
method usinglogDel -= ConsoleLogger.Log
. - Adds a reference to the
FileLogger.Log
method usinglogDel += FileLogger.Log
- Invokes
logDel
with a new message that saysHello, world, but from File!
Run the application now, and you should see a Hello, world!
logged to the console, and the application should terminate. However, if you navigate to the project folder, go inside bin/debug/net8.0 (Assuming you're on .NET 8), you should see a file called log.txt
that was created automatically, which would have the following content:
Awesome! You saw how we can replace method references and assign a target method to a delegate instance in 2 different ways!
Delegates with Anonymous Methods 🎭
We've seen delegate instantiation with methods that are pre-defined like Sum
, but sometimes, you want to instantiate a delegate instance with an inline-implementation. That's useful when the method is not going to be reused somewhere else, which means defining a named method will be just more code, or in some cases, the implementation is short and you want to keep it concise.
Using this new delegate declaration, we'll see how we can associate an anonymous method with a delegate instance.
Operation op = delegate(double a, double b)
{
return a + b;
}
delegate double Operation(double a, double b);
Invoking this delegate will add the two numbers together. However, there's a shorter way of doing it, because the language has evolved, we no longer need to use the delegate
keyword, and also, we don't necessarily have to specify the types of the parameters, because the compiler is smart enough right now, and it knows the definition of the delegate and that the Operation
delegate accepts 2 double
parameters, with that, we can transform the previous assignment to this:
Operation op = (x, y) => x + y;
delegate double Operation(double x, double y);
This does the same thing now, but it's much more concise. Now omitting the types names is not necessary, in cases where the parameters list is long with complex type names, it's a good idea to keep the names present for enhanced readability and understanding.
Multicast Delegates 🐙
Earlier we've seen how to invoke a method through a delegate, then we replaced the method reference and called a different method which does a totally different job (Logging to a file instead of the console window), but what if we wanted to do both with one shot only? Enter multicast delegates.
A multicast delegate is a delegate that references 2 or more methods, which it invokes in the order they were added in, that would come in handy because right now, instead of logging to the console, then replacing the logging outlet to a file, then maybe in the future to a database, and who knows what else, we can simply reference all the Log
methods and have the delegate invoke them all when we want to log something. This is how we would do that in C#:
LogDelegate logDel = ConsoleLogger.Log;
logDel += FileLogger.Log;
logDel("Hello, world!"); // Logs Hello, world! to both the console and log.txt
Now the code logs the message to the console first, then logs it to the log file, because as I just mentioned, the order in which the references were added determines the order in which they'll be invoked, so pay good attention to that in case the operations your application is going to perform requires the processing to happen in a certain order.
Conclusion ✅
We looked in this introductory post about delegates on some key concepts and discussed the delegate
reference type in detail, we saw through some code samples to learn how we can declare a delegate, how to assign a target method to it in two different ways, and also how we can remove or add method references to a delegate object instance. Lastly, we briefly looked at delegates with anonymous methods for more concise delegate assignments, and multicast delegates which allowed us to reference multiple methods at once using a single delegate object instance.
That's about it for this post, in the next one in the series, we'll see how we can pass methods as arguments into other methods using what we've learned from this article, so stay tuned for that one.