DEV Community

Rahul Kumar Jha
Rahul Kumar Jha

Posted on

Mastering Action and Func Delegates in C#: A Comprehensive Guide

In this article, we are going to learn about implementing Action and Func delegates in C#.

Delegates are an essential part of the internal implementations of a broader range of functionalities in the .Net ecosystem.

Action and Func are built-in delegates that the framework provides, easing the burden of cluttering the code base with similar type definitions.

They are an essential component of functional programming in C#.

We have heard the famous OOP proverb "Programming to an interface, not to an implementation" or "Programming to an abstraction, not to an implementation." That's exactly what delegates can help us with.

After understanding delegates in general, we will continue shaping our knowledge around Action and Func, solidifying our understanding with concrete examples of how to use them in .Net.

Let's get going.

What is a Delegate?

A delegate is a type-safe reference to methods defined in a class. But what is type-safety? And why is it important when it comes to delegates?

The delegate defines the exact type of parameters it accepts and the type it returns. Compatibility between the method and the delegate is possible if the method has the same signature as the delegate.

Let's define a delegate.

public delegate string[] Filter(string str);
Enter fullscreen mode Exit fullscreen mode

The delegate Filter takes a string parameter and returns a string array.

What about methods compatibility with Filter delegate?

Let's take a look:

public static string[] SplitText(string str)
{
    return str.Split(' ');
}

public static int TextLength(string str)
{
    return str.Split(' ').Length;
}

Enter fullscreen mode Exit fullscreen mode

The SplitText method has the same signature as the Filter delegate, but the TextLength method does not match the Filter argument type due to a dissimilar return type.

What happens when we assign TextLength to the Filter delegate? No surprises here:

Filter filter = TextLength; // Error: Cannot convert from 'method group' to 'Filter'
Enter fullscreen mode Exit fullscreen mode

The compiler will issue an error when trying to assign TextLength as a method reference to the Filter delegate.

The type safety feature of delegates acts as a binding contract that methods must adhere to.

Why Action and Func?

Before the introduction of Generics in the .Net framework 2.0, a new delegate definition was required for varying arguments or return types.

Let's define a few delegates:

public delegate void Log(string message);

public delegate void TotalDiscount(decimal discount);

Enter fullscreen mode Exit fullscreen mode

The Log and TotalDiscount delegates differ only in terms of the type of argument they expect. Isn't it better to have just one delegate definition for both of them?

public delegate void Action<T>(T arg);
Enter fullscreen mode Exit fullscreen mode

The Action delegate is compatible with any method that has no return type and accepts one argument of type T.

What about a delegate with a return type other than void?

public delegate string Filter(string phrase);

public delegate string[] Split(string phrase);
Enter fullscreen mode Exit fullscreen mode

Not to worry, we can define a delegate using generics to match both the Filter and Split delegates:

public delegate TResult Func<T, TResult>(T arg);

The Func delegate accepts an argument of type T and returns type Tresult. This matches any method definition that has one argument of type T and returns a type other than void.

We don't need to define similar delegates anymore. We can use generic Action and Func instead, correct?

Consider this: a delegate could take a varying number of parameters with or without a return type other than void.

Let's define a few generic delegates:

public delegate void Action<Tin1, Tin2>(Tin1 arg1, Tin2 arg2);

public delegate Tout Func<Tin1, Tin2, Tout>(Tin1 arg1, Tin2 arg2);
Enter fullscreen mode Exit fullscreen mode

Action and Func take two arguments. Similarly, we could define them with as many arguments as we wish.

Thankfully, Action and Func delegates were made available in the .Net Framework version 3.5 under the System namespace. In most cases, we don't need to define them ourselves.

The Action delegate has a return type of void whereas the Func delegate has a return type other than void. Both can take up to 16 parameters.

How Do We Declare/Initialize Action and Func?

The Action delegate have generic as well as non-generic definitions. We can declare a variable of a type Action that takes no argument or a more generic one as Action<T1>, Action<T2>, Action<T3> all the way to Action<T1, T2, T3, ............, T16> that accepts from 1 to 16 arguments.

Let’s look at a few inbuilt Action declarations and initialization:

Action notify = () =>
{
    Console.WriteLine("Actions are great.");
};

Action<int> doMath = (x) =>
{
    Console.WriteLine($"The square of {x} is {x * x}");
};

Action<int, decimal> calculateDiscount = (x, y) =>
{
    Console.WriteLine($"The discount is {x * y}");
};
Enter fullscreen mode Exit fullscreen mode

The notify, doMath and calculateDiscount variables of type Action have varying lengths of arguments initialized with compatible inline method definitions.

The Func delegates are all generic as they always have a return type associated with them which is not the case with Action delegate that always returns void.

The Func<TResult> delegate takes no argument and returns TResult. We can declare Func<T1, TResult>, Func<T1, T2, TResult> all the way to Func<T1, T2, T3, ........., T16, TResult> that accepts from 1 to 16 arguments always returning the value of type TResult.

Let’s see a few inbuilt Func declarations and initialization:

Func<bool> isTrue = () => true;

Func<string, string[]> ToArray = (s) => s.Split(' ');

Func<string, int, string> Concat = (s, i) => s + i.ToString();

Enter fullscreen mode Exit fullscreen mode

The last parameter in the Func declaration denotes the return type of the delegate. The isTrue has no argument, it returns a boolean value. The ToArray accepts a string and returns an array of strings. The Concat accepts two arguments of type int and string, and it returns a string value.

Enough talking, let’s have a practical demonstration to understand Action and Func delegates better.

Action and Func as Callback Functions

Action and Func are special types of variables that hold methods as references.

The variables of type Action and Func can be used just like any other variables, but they can also be invoked like a method.

This can be useful when you need to pass a method as an argument to another method, or when you want to store a method in a variable for later use.

Let’s unravel it:

public static IEnumerable<int> ProcessList(IEnumerable<int> nums, Func<int, int> func)
{
    foreach (var num in nums)
    {
        yield return func(num);
    }
}
Enter fullscreen mode Exit fullscreen mode

The ProcessList method accepts two arguments, one of which is a func delegate of type Func<int, int>. The ProcessList method has no idea of what method the func delegate refers to, as long as the method passed to it adheres to the func definition.

This is an example of how you can use the Func delegate to pass a method as an argument to another method. The ProcessList method can then use the func delegate to invoke the method passed to it.

Simple Callback Pattern Implementation With Action and Func

Having a working solution is the best thing. We will create a small application to experiment with the concept of callbacks using Action and Func.

We start with creating our model classes first:

public class Student
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ICollection<Course> Courses { get; set; } = new List<Course>();

    public static IEnumerable<Student> DummyStudents()
    {
        return new List<Student>
        {
            new Student { Id = 1, Name = "Rahul" },
            new Student { Id = 2, Name = "Vikas" },
            new Student { Id = 3, Name = "Neha" },
            new Student { Id = 4, Name = "Abhi" },
            new Student { Id = 5, Name = "Lara" }
        };
    }
}

Enter fullscreen mode Exit fullscreen mode

The Student class has an Id and Name property. Each Student has a Courses collection. It also defines a static helper method called DummyStudents which populates some dummy data for the Student objects.

Next, we create the Course class with an Id, Name, and Price property. The Course class also contains the DummyCourses static method that returns a list of courses:

public class Course
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }

    public static IEnumerable<Course> DummyCourses()
    {
        return new List<Course>
        {
            new Course { Id = 1, Name = "C#", Price = 99.99m },
            new Course { Id = 2, Name = "Java", Price = 149.99m },
            new Course { Id = 3, Name = "Python", Price = 199.99m },
            new Course { Id = 4, Name = "JavaScript", Price = 149.99m },
            new Course { Id = 5, Name = "Ruby", Price = 199.99m },
            new Course { Id = 6, Name = "PHP", Price = 99.99m }
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Now let's create a StudentService class. We want to declare a method to filter students that match a specific condition:

public static class StudentService
{
    public static Student GetStudent(IEnumerable<Student> students, Func<Student, bool> predicate)
    {
        foreach (var student in students)
        {
            if (predicate(student))
            {
                return student;
            }
        }

        return default;
    }
}
Enter fullscreen mode Exit fullscreen mode

The GetStudent() method takes a list of students and applies a special Func called predicate, iterating over the list.

Next, we are going to use the GetStudent() method of the StudentService class to filter students and see the result in our Main method inside our Program class.

internal class Program
{
    static void Main(string[] args)
    {
        var students = Student.DummyStudents();
        var student = StudentService.GetStudent(students, (s) => s.Id == 3);

        Print(student, (s) => Console.WriteLine($"Id: {s.Id}, Name: {s.Name}"));
    }

    public static void Print<T>(T arg, Action<T> printAction)
    {
        Console.WriteLine("Let's look at the output..");
        printAction(arg);
    }
}
Enter fullscreen mode Exit fullscreen mode

Inside our Main method, we first populate the dummy students. Then, we call the GetStudent() method of the StudentService class, passing in the students and an inline method that matches the Func<Student, bool> delegate.

Looking at (s) => s.Id == 1, we see that the lambda expression returns true if the id matches 1.

We call the Print method to display the result, verifying that indeed we receive a student with an id of 1:

Let's look at the output..
Id: 1 Name: Rahul
Enter fullscreen mode Exit fullscreen mode

We should not ignore the awesomeness of the Print method. It can operate on any argument of type T and call any action on it.

We can modify the display behavior by updating the inline function (s) => Console.WriteLine($"Id: {s.Id} Name: {s.Name}") that we pass to the Print method, matching the generic variable of type Action<T>.

The dynamic nature of the Print method allows it to operate on any argument of type T and call any action on it, which means that it can save the result in memory instead of printing it on the console.

This demonstrates the concept of "programming to an abstraction, not to an implementation" which means that you should design your code to depend on abstractions (such as interfaces or delegates) rather than on specific implementations.

This allows you to change the implementation of a component without affecting the rest of the system.

One of the features of a delegate is the ability to hold references to more than one method. This is called multicasting, and it allows you to invoke multiple methods with a single delegate invocation.

This can be useful when you want to notify multiple listeners about an event, or when you want to perform multiple actions in response to a single event.

We are going to extend our solution to demonstrate how Action or Func can call more than one method.

Simple Pub/Sub Pattern Using Action

Let’s extend the StudentService class to include the method EnrollStudentToCourse().

We want to send notifications to different departments whenever a student gets enrolled in a new course:

public static class StudentService
{
    public static Student GetStudent(IEnumerable<Student> students, Func<Student, bool> predicate)
    {
        foreach (var student in students)
        {
            if (predicate(student))
            {
                return student;
            }
        }

        return default;
    }

    public static void EnrollStudentToCourse(Student student, Course course, Action<Student> notify)
    {
        student.Courses.Add(course);
        notify(student);
    }
}
Enter fullscreen mode Exit fullscreen mode

The EnrollStudentToCourse() method adds a new course to the student's courses list, and then calls the notify action.

Next, we update the Program class to include the NotifyAdmin() and NotifyAccount() methods:

public static void NotifyAdmin(Student student)
{
    Console.WriteLine($"{student.Name} is now enrolled in {student.Courses.Last().Name}.");
}

public static void NotifyAccount(Student student)
{
    var course = student.Courses.Last();
    var amount = course.Price;
    Console.WriteLine($"Please deduct {amount}$ from {student.Name}'s account.");
}
Enter fullscreen mode Exit fullscreen mode

Both NotifyAdmin() and NotifyAccount() matches the Action<Student> that the EnrollStudentToCourse() of the class StudentService accepts as one of the arguments.

All is set, we can extend the Main to enroll the student in a new course and see if the process involves the notification to the Admin and Account departments:

static void Main(string[] args)
{
    // fetch students and courses
    var students = Student.DummyStudents();
    var courses = Course.DummyeCourses();
    // get student with a name: Rahul
    var student_Rahul = StudentService.GetStudent(students, (s) =>
    // create an Action<Student> variable and assign a method
    Action<Student> sendNotifications = NotifyAdmin;
    // add another method to the sendNotifications
    sendNotifications += NotifyAccount;
    // call EnrollStudentToCourse passing student, course and send
    StudentService.EnrollStudentToCourse(student_Rahul, courses.Fi
    console.ReadLine();
}
Enter fullscreen mode Exit fullscreen mode

We define a sendNotifications variable of type Action<Student> in Main, assigning NotifyAdmin to it.

We also add NotifyAccount using sendNotification += NotifyAccount.

The call to EnrollStudentToCourse() invokes all the subscribers of the sendNotifications action.

Indeed, from the result, we see that both NotifyAdmin() and NotifyAccount() gets called when a student gets enrolled in a course.

Rahul is now enrolled in ASP.NET CORE API course
Please deduct amount: 100 dollars from Rahul
Enter fullscreen mode Exit fullscreen mode

Undeniably, we have just scratched the surface a bit, there is more to Action and Func to know about. For now, let's conclude our discussion.

Conclusion

Action and Func are the inbuilt delegates. We tried to answer the question - why they are important and how we can provide a layer of abstraction using delegates.

In this article, we implemented the callback functionality using Action & Func. We also achieved a simple pub/sub model using Action. Hopefully, this article clears the air on the usability of Action and Func.

Top comments (0)