Rediger

Del via


Standard .NET event patterns

Previous

.NET events generally follow a few known patterns. Standardizing on these patterns means that developers can apply knowledge of those standard patterns, which can be applied to any .NET event program.

Let's go through these standard patterns so you have all the knowledge you need to create standard event sources, and subscribe and process standard events in your code.

Event delegate signatures

The standard signature for a .NET event delegate is:

void EventRaised(object sender, EventArgs args);

This standard signature provides insight into when events are used:

  • The return type is void. Events can have zero to many listeners. Raising an event notifies all listeners. In general, listeners don't provide values in response to events.
  • Events indicate the sender: The event signature includes the object that raised the event. That provides any listener with a mechanism to communicate with the sender. The compile-time type of sender is System.Object, even though you likely know a more derived type that would always be correct. By convention, use object.
  • Events package more information in a single structure: The args parameter is a type derived from System.EventArgs that includes any more necessary information. (You'll see in the next section that this convention is no longer enforced.) If your event type doesn't need any more arguments, you still must provide both arguments. There's a special value, EventArgs.Empty that you should use to denote that your event doesn't contain any additional information.

Let's build a class that lists files in a directory, or any of its subdirectories that follow a pattern. This component raises an event for each file found that matches the pattern.

Using an event model provides some design advantages. You can create multiple event listeners that perform different actions when a sought file is found. Combining the different listeners can create more robust algorithms.

Here's the initial event argument declaration for finding a sought file:

public class FileFoundArgs : EventArgs
{
    public string FoundFile { get; }

    public FileFoundArgs(string fileName) => FoundFile = fileName;
}

Even though this type looks like a small, data-only type, you should follow the convention and make it a reference (class) type. That means the argument object is passed by reference, and any updates to the data are viewed by all subscribers. The first version is an immutable object. You should prefer to make the properties in your event argument type immutable. That way, one subscriber can't change the values before another subscriber sees them. (There are exceptions to this practice, as you see later.)

Next, we need to create the event declaration in the FileSearcher class. Using the System.EventHandler<TEventArgs> type means that you don't need to create yet another type definition. You just use a generic specialization.

Let's fill out the FileSearcher class to search for files that match a pattern, and raise the correct event when a match is discovered.

public class FileSearcher
{
    public event EventHandler<FileFoundArgs>? FileFound;

    public void Search(string directory, string searchPattern)
    {
        foreach (var file in Directory.EnumerateFiles(directory, searchPattern))
        {
            FileFound?.Invoke(this, new FileFoundArgs(file));
        }
    }
}

Define and raise field-like events

The simplest way to add an event to your class is to declare that event as a public field, as in the preceding example:

public event EventHandler<FileFoundArgs>? FileFound;

This looks like it's declaring a public field, which would appear to be a bad object-oriented practice. You want to protect data access through properties, or methods. While this code might look like a bad practice, the code generated by the compiler does create wrappers so that the event objects can only be accessed in safe ways. The only operations available on a field-like event are add and remove handler:

var fileLister = new FileSearcher();
int filesFound = 0;

EventHandler<FileFoundArgs> onFileFound = (sender, eventArgs) =>
{
    Console.WriteLine(eventArgs.FoundFile);
    filesFound++;
};

fileLister.FileFound += onFileFound;
fileLister.FileFound -= onFileFound;

There's a local variable for the handler. If you used the body of the lambda, the remove handler wouldn't work correctly. It would be a different instance of the delegate, and silently do nothing.

Code outside the class can't raise the event, nor can it perform any other operations.

Beginning with C# 14, events can be declared as partial members. A partial event declaration must include a defining declaration and an implementing declaration. The defining declaration must use the field-like event syntax. The implementing declaration must declare the add and remove handlers.

Return values from event subscribers

Your simple version is working fine. Let's add another feature: Cancellation.

When you raise the Found event, listeners should be able to stop further processing, if this file is the last one sought.

The event handlers don't return a value, so you need to communicate that in another way. The standard event pattern uses the EventArgs object to include fields that event subscribers can use to communicate cancel.

Two different patterns could be used, based on the semantics of the Cancel contract. In both cases, you add a boolean field to the EventArguments for the found file event.

One pattern would allow any one subscriber to cancel the operation. For this pattern, the new field is initialized to false. Any subscriber can change it to true. After the raising the event for all subscribers, the FileSearcher component examines the boolean value and takes action.

The second pattern would only cancel the operation if all subscribers wanted the operation canceled. In this pattern, the new field is initialized to indicate the operation should cancel, and any subscriber could change it to indicate the operation should continue. After all subscribers process the raised the event, the FileSearcher component examines the boolean and takes action. There's one extra step in this pattern: the component needs to know if any subscribers responded to the event. If there are no subscribers, the field would indicate a cancel incorrectly.

Let's implement the first version for this sample. You need to add a boolean field named CancelRequested to the FileFoundArgs type:

public class FileFoundArgs : EventArgs
{
    public string FoundFile { get; }
    public bool CancelRequested { get; set; }

    public FileFoundArgs(string fileName) => FoundFile = fileName;
}

This new field is automatically initialized to false so you don't cancel accidentally. The only other change to the component is to check the flag after raising the event to see if any of the subscribers requested a cancellation:

private void SearchDirectory(string directory, string searchPattern)
{
    foreach (var file in Directory.EnumerateFiles(directory, searchPattern))
    {
        var args = new FileFoundArgs(file);
        FileFound?.Invoke(this, args);
        if (args.CancelRequested)
            break;
    }
}

One advantage of this pattern is that it isn't a breaking change. None of the subscribers requested cancellation before, and they still aren't. None of the subscriber code requires updates unless they want to support the new cancel protocol.

Let's update the subscriber so that it requests a cancellation once it finds the first executable:

EventHandler<FileFoundArgs> onFileFound = (sender, eventArgs) =>
{
    Console.WriteLine(eventArgs.FoundFile);
    eventArgs.CancelRequested = true;
};

Adding another event declaration

Let's add one more feature, and demonstrate other language idioms for events. Let's add an overload of the Search method that traverses all subdirectories in search of files.

This method could get to be a lengthy operation in a directory with many subdirectories. Let's add an event that gets raised when each new directory search begins. This event enables subscribers to track progress, and update the user as to progress. All the samples you created so far are public. Let's make this event an internal event. That means you can also make the argument types internal as well.

You start by creating the new EventArgs derived class for reporting the new directory and progress.

internal class SearchDirectoryArgs : EventArgs
{
    internal string CurrentSearchDirectory { get; }
    internal int TotalDirs { get; }
    internal int CompletedDirs { get; }

    internal SearchDirectoryArgs(string dir, int totalDirs, int completedDirs)
    {
        CurrentSearchDirectory = dir;
        TotalDirs = totalDirs;
        CompletedDirs = completedDirs;
    }
}

Again, you can follow the recommendations to make an immutable reference type for the event arguments.

Next, define the event. This time, you use a different syntax. In addition to using the field syntax, you can explicitly create the event property with add and remove handlers. In this sample, you don't need extra code in those handlers, but this shows how you would create them.

internal event EventHandler<SearchDirectoryArgs> DirectoryChanged
{
    add { _directoryChanged += value; }
    remove { _directoryChanged -= value; }
}
private EventHandler<SearchDirectoryArgs>? _directoryChanged;

In many ways, the code you write here mirrors the code the compiler generates for the field event definitions you saw earlier. You create the event using syntax similar to properties. Notice that the handlers have different names: add and remove. These accessors are called to subscribe to the event, or unsubscribe from the event. Notice that you also must declare a private backing field to store the event variable. This variable is initialized to null.

Next, let's add the overload of the Search method that traverses subdirectories and raises both events. The easiest way is to use a default argument to specify that you want to search all directories:

public void Search(string directory, string searchPattern, bool searchSubDirs = false)
{
    if (searchSubDirs)
    {
        var allDirectories = Directory.GetDirectories(directory, "*.*", SearchOption.AllDirectories);
        var completedDirs = 0;
        var totalDirs = allDirectories.Length + 1;
        foreach (var dir in allDirectories)
        {
            _directoryChanged?.Invoke(this, new (dir, totalDirs, completedDirs++));
            // Search 'dir' and its subdirectories for files that match the search pattern:
            SearchDirectory(dir, searchPattern);
        }
        // Include the Current Directory:
        _directoryChanged?.Invoke(this, new (directory, totalDirs, completedDirs++));
        SearchDirectory(directory, searchPattern);
    }
    else
    {
        SearchDirectory(directory, searchPattern);
    }
}

private void SearchDirectory(string directory, string searchPattern)
{
    foreach (var file in Directory.EnumerateFiles(directory, searchPattern))
    {
        var args = new FileFoundArgs(file);
        FileFound?.Invoke(this, args);
        if (args.CancelRequested)
            break;
    }
}

At this point, you can run the application calling the overload for searching all subdirectories. There are no subscribers on the new DirectoryChanged event, but using the ?.Invoke() idiom ensures it works correctly.

Let's add a handler to write a line that shows the progress in the console window.

fileLister.DirectoryChanged += (sender, eventArgs) =>
{
    Console.Write($"Entering '{eventArgs.CurrentSearchDirectory}'.");
    Console.WriteLine($" {eventArgs.CompletedDirs} of {eventArgs.TotalDirs} completed...");
};

You saw patterns that are followed throughout the .NET ecosystem. By learning these patterns and conventions, you're writing idiomatic C# and .NET quickly.

See also

Next, you see some changes in these patterns in the most recent release of .NET.