Developer Tips: C# Events
In today's guest post, Andrew Hinkle explains how to use events in C#.
We've all used events whether we know it or not, such as WebForms click events. Usually, you use the UI properties to click on the event you want, populate the method, and you're done. For the most part of my career, that's been the extent of my usage.
Recently I ran into some legacy code that raised events when an address was changed. Yes, events have been around for a long while now. It took me a little bit to pick up what was going on (an order of events issue, pun intended!), but it emphasized my lack of understanding.
I recently watched an excellent PluralSight video titled "C# Events, Delegates and Lambdas" by Dan Wahlin. It's definitely worth your 3h 11m.
In the following example, I wanted to illustrate how to use events with a fairly simple example. The shopping cart contains a billing and shipping address, and a Boolean flag if the shipping address is the same as the billing address. Declare the event handler with a custom parameter of Address. The event handler will default the sender as the current object, ShoppingCart in this case.
In the shopping cart constructor:
- Register a logger method to add the city info to a string list before any changes were applied
- Register a method to update the shipping address
- Register a logger method to add the city info to a string list after the changes were applied.
Keep in mind that you don't have to implement the event methods (delegates) in the class. In this case the Logger is also used. I could have decoupled the logger completely from the ShoppingCart by only passing in the delegate, so the ShoppingCart wouldn't have any knowledge of the logger or where the delegates came from.
When the billing address is changed, update the shipping address if it should be changed to match the billing address. Whenever possible add proper unit tests to test the functionality (not the integration) to verify your code works as expected. Code samples are provided below and in my GitHub Tips repository.
Address
namespace Tips.Events { public class Address { public string StreetAddress1 { get; set; } public string StreetAddress2 { get; set; } public string City { get; set; } public string State { get; set; } public string Country { get; set; } public string Zip { get; set; } } }
Logger
The logger simply adds messages to a string list, though you could have performed any action. I added the before and after scenarios to clearly illustrate that the order you add the events to the handler are honored when called.
using System; using System.Collections.Generic; using System.Text; namespace Tips.Events { public class Logger { public List<string> Logs { get; }
public Logger() { Logs = new List<string>(); }
public void OnBillingAddressChangedBefore(object sender, Address address) { OnBillingAddressChanged(sender, address, "Before"); }
public void OnBillingAddressChangedAfter(object sender, Address address) { OnBillingAddressChanged(sender, address, "After"); }
private void OnBillingAddressChanged(object sender, Address address, string status) { // You could access the variables via "this" if we were in the // ShoppingCart because it has the EventHandler.
// In this case we're in a different class, so we need to cast // the sender to the appropriate type, do a null check, // and then perform an action. if (!(sender is ShoppingCart cart)) throw new ArgumentNullException($"{nameof(sender)} was not a {nameof(ShoppingCart)}.");
var billingAddressCity = cart.BillingAddress.City; var shippingAddressCity = cart.ShippingAddress.City;
Logs.Add($"{status}; " + $"Sender type: {sender.GetType()}; " + $"BillingAddress.City: {billingAddressCity}; " + $"ShippingAddress.City: {shippingAddressCity}; " + $"Address.City: {address.City}"); } } }
Shopping Cart
Declare the EventHandler on the object where the event will be triggered. I injected the addresses and other settings to simplify the unit tests. This approach also makes the class honest in that this information is expected to be provided before any actions may be performed on the object.
That's what is expected for this scenario, however, real world implementation may vary.
using System; namespace Tips.Events { public class ShoppingCart { public EventHandler<Address> BillingAddressEventHandler;
public Logger Logger { get; } public Address BillingAddress { get; } public Address ShippingAddress { get; } public bool IsShippingSameAsBilling { get; }
public ShoppingCart(Logger logger, Address billingAddress, Address shippingAddress, bool isShippingSameAsBilling) { Logger = logger ?? throw new ArgumentNullException($"{nameof(logger)} is null.");
BillingAddress = billingAddress ?? throw new ArgumentNullException($"{nameof(billingAddress)} is null."); ShippingAddress = shippingAddress ?? throw new ArgumentNullException($"{nameof(shippingAddress)} is null.");
IsShippingSameAsBilling = isShippingSameAsBilling;
// When the event handler is called we want both of these methods to be called. BillingAddressEventHandler += Logger.OnBillingAddressChangedBefore; BillingAddressEventHandler += OnBillingAddressChanged; BillingAddressEventHandler += Logger.OnBillingAddressChangedAfter; }
public void UpdateBillingAddress(Address address) { UpdateAddress(address, BillingAddress);
// The billing address is changed, call the event handler. BillingAddressEventHandler(this, address); }
public void UpdateShippingAddress(Address address) { UpdateAddress(address, ShippingAddress); }
private void UpdateAddress(Address sourceAddress, Address targetAddress) { if (sourceAddress == null) throw new ArgumentNullException($"{nameof(sourceAddress)} is null."); if (targetAddress == null) throw new ArgumentNullException($"{nameof(targetAddress)} is null.");
targetAddress.StreetAddress1 = sourceAddress.StreetAddress1; targetAddress.StreetAddress2 = sourceAddress.StreetAddress2; targetAddress.City = sourceAddress.City; targetAddress.State = sourceAddress.State; targetAddress.Country = sourceAddress.Country; targetAddress.Zip = sourceAddress.Zip; }
private void OnBillingAddressChanged(object sender, Address address) { // Only update the billing address if shipping is same as billing. if (IsShippingSameAsBilling) UpdateShippingAddress(address); } } }
Unit Tests
The tests are a little verbose, but get the point across.
Usually, I separate the test methods so that the main setup (Arrange) and process (Act) are handled in the constructor or [TestInitialize], and each test (Assert) is a separate [TestMethod]. For the sake of brevity and a lot of dead whitespace, the assert statements have been grouped into two unit test methods.
using System.Linq; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Tips.Events.Tests { [TestClass] public class BillingAddressIsChanged { [TestMethod] public void AndIsShippingSameAsBillingIsTrueThenShippingIsUpdated() { var isShippingSameAsBilling = true; var shoppingCart = new ShoppingCart(CreateLogger(), CreateBillingAddress(), CreateShippingAddress(), isShippingSameAsBilling);
shoppingCart.UpdateBillingAddress(CreateTargetAddress());
AssertAddressAreEqual(CreateTargetAddress(), shoppingCart.BillingAddress); AssertAddressAreEqual(CreateTargetAddress(), shoppingCart.ShippingAddress);
Assert.IsNotNull(shoppingCart.Logger);
// Remember, BillingAddress has been changed first before the event is triggered. // After: ShippingAddress.City should change to the BillingAddress.City. Assert.IsNotNull(shoppingCart.Logger); Assert.AreEqual("Before; " + "Sender type: Tips.Events.ShoppingCart; " + "BillingAddress.City: Austin; " + "ShippingAddress.City: Columbus; " + "Address.City: Austin", shoppingCart.Logger.Logs.First());
Assert.AreEqual("After; " + "Sender type: Tips.Events.ShoppingCart; " + "BillingAddress.City: Austin; " + "ShippingAddress.City: Austin; " + "Address.City: Austin", shoppingCart.Logger.Logs.Last()); }
[TestMethod] public void AndIsShippingSameAsBillingIsFalseThenShippingIsNotUpdated() { var isShippingSameAsBilling = false; var shoppingCart = new ShoppingCart(CreateLogger(), CreateBillingAddress(), CreateShippingAddress(), isShippingSameAsBilling);
shoppingCart.UpdateBillingAddress(CreateTargetAddress());
AssertAddressAreEqual(CreateTargetAddress(), shoppingCart.BillingAddress); AssertAddressAreEqual(CreateShippingAddress(), shoppingCart.ShippingAddress);
// Remember, BillingAddress has been changed first before the event is triggered. Assert.IsNotNull(shoppingCart.Logger); Assert.AreEqual("Before; " + "Sender type: Tips.Events.ShoppingCart; " + "BillingAddress.City: Austin; " + "ShippingAddress.City: Columbus; " + "Address.City: Austin", shoppingCart.Logger.Logs.First());
// After: ShippingAddress.City should NOT change. Assert.AreEqual("After; " + "Sender type: Tips.Events.ShoppingCart; " + "BillingAddress.City: Austin; " + "ShippingAddress.City: Columbus; " + "Address.City: Austin", shoppingCart.Logger.Logs.Last()); }
private void AssertAddressAreEqual(Address expectedAddress, Address actualAddress) { Assert.IsNotNull(expectedAddress); Assert.IsNotNull(actualAddress); Assert.AreEqual(expectedAddress.StreetAddress1, actualAddress.StreetAddress1); Assert.AreEqual(expectedAddress.StreetAddress2, actualAddress.StreetAddress2); Assert.AreEqual(expectedAddress.City, actualAddress.City); Assert.AreEqual(expectedAddress.State, actualAddress.State); Assert.AreEqual(expectedAddress.Country, actualAddress.Country); Assert.AreEqual(expectedAddress.Zip, actualAddress.Zip); }
private static Logger CreateLogger() { return new Logger(); }
private static Address CreateBillingAddress() { return new Address() { StreetAddress1 = "123 Original Street", StreetAddress2 = "Suite ABC", City = "Miami", State = "FL", Country = "USA", Zip = "33133" }; }
private static Address CreateShippingAddress() { return new Address() { StreetAddress1 = "456 Delivery Court", StreetAddress2 = "Apartment DEF", City = "Columbus", State = "OH", Country = "USA", Zip = "43272" }; }
private static Address CreateTargetAddress() { return new Address() { StreetAddress1 = "789 Bullseye Blvd", StreetAddress2 = "Flat GHI", City = "Austin", State = "TX", Country = "USA", Zip = "73301" }; } } }
Conclusion
As you can see, events can assist with development when state changes for a particular operation. Once properly implemented, they can also bring new life to your code.
Have you used events lately? Do you have a better technique? Post your comments below and let's discuss!