Mastering C# and Unity3D

An Alternative to Events

C# has built-in events and they work fine in Unity projects. End of story, right? Not so fast! Have you ever wondered why the Unity API doesn’t have any C# events in it? Or why Unity made their own UnityEvent class for UGUI? Maybe there are some valid reasons to avoid C#’s events. Today’s article discusses an alternative with some serious upsides. Read on to learn more!

C# has the event keyword and it seems to make events trivial. Here’s a little example where the game dispatches an “enemy spawned” event and the sound system listens for it to know when to play a sound:

// Define a class containing all the event argumentsclass EnemySpawnedEventArgs : EventArgs
{publicint EnemyId {get;privateset;}public Vector3 Location {get;privateset;}public EnemySpawnedEventArgs(int enemyId, Vector3 location){
EnemyId = enemyId;
Location = location;}}class Game
{// Declare an eventpublicevent EventHandler<EnemySpawnedEventArgs> OnEnemySpawned;publicvoid SpawnEnemy(int enemyId, Vector3 location){// The event is is null if there are no listenersif(OnEnemySpawned !=null){// Bundle up the parameters into an objectvar eventArgs =new EnemySpawnedEventArgs(enemyId, location);// Call all the listeners of the event
OnEnemySpawned(eventArgs);}}}class SoundManager
{private Game game;public SoundManager(Game game){this.game= game;// Listen to be notified when an enemy spawns
game.OnEnemySpawned+= HandleEnemySpawned;}// Called when an enemy spawnsprivatevoid HandleEnemySpawned(object sender, EnemySpawnedEventArgs eventArgs){// Use the event arguments
Debug.LogFormat("Enemy with ID {0} spawned at {1}", eventArgs.EnemyId, eventArgs.Location);// Stop listening so this function is no longer called when enemies spawn
game.OnEnemySpawned-= HandleEnemySpawned;}}

This code is almost purely boilerplate. While the event functionality of starting and stopping listening and dispatching is reusable, we still had to define an EventArgs, check for null, listen, and stop listening. All this boilerplate code is the first cost that events impose on us.

Technically we could skip the EventArgs class and make our event have a type like Action<int, Vector3>. That too comes at a cost because the code will now be a lot harder to understand. What are these parameters? Since they don’t have names, you’ll have to type a comment, keep it updated, and go read it. When you make your listener functions (e.g. HandleEnemySpawned) you’ll need to type out all of the arguments. If the number, type, or meaning of the arguments changes, you’ll need to go update all the listener functions. So you have a tradeoff between the usability of EventArgs and the boilerplate and garbage reduction of Action<int, Vector3>. A middle-ground is to define your own delegate with named parameters.

Let’s move on from the boilerplate concerns and talk about performance. In this article I showed that normal function calls are about 10x faster than delegates, which are essentially C# events. Then in this article I showed C# events outperforming Unity’s events in both CPU and garbage creation. What I didn’t point out is that creating the delegates to listen to the event with often creates garbage, such as in the case of lambdas or non-static functions. In short, events are 10x slower than normal functions and involve creating garbage to use them.

The next concern with events is with debugging them. Events are basically a list of functions. That list is built at runtime by adding listeners and cleaned up by removing listeners. As such, you can’t look at the code and easily know what functions get called when the event is dispatched because you need to run the code to build the list of functions. That list naturally changes over time, so sometimes functions A and B get called and other times B and C get called. Or maybe no functions get called. To debug, you need to inspect the state of this list in a debugger. The functions in this list probably won’t have useful names since they’re delegate objects, so it’s even harder to tell what’ll get called. It really is a painful debugging experience. This article discusses these issues in more depth and gives some alternatives.

This is also the greatest upside for events. Since you’re dynamically modifying a list of functions that should be called when the event is dispatched, you have a ton of flexibility. There could be no functions to call, one function, or thousands. You can even change the order the listener functions are called in. All of this is at runtime and determined by however complex of code you want to write. C# and Unity events are reusable code that handle all of this listener function list management so you never have to write or maintain any of it.

Those are some of the main “pros” and “cons” of events. Now how about an alternative? In The Problems with Events I showed some simple alternatives for when you just have one listener function. What if you wanted an arbitrary number of listeners? Let’s tackle that problem today.

The main purpose of events is to call zero or more listener functions when we dispatch the event. We don’t want the code that dispatches the event to be tightly coupled to the code that listens for the event, so we need a middle-man. Just like with events, we need the dispatching code to call the middle-man’s function and the middle-man to call the listener functions. We don’t need the middle-man to keep a dynamic list of functions though. That’s just how the C# and Unity event systems happen to work. What if we skipped the list of delegates and just typed out which functions should be called? Let’s try that!

Here’s a version of the above example with the C# event removed and a new EnemySpawnedEvent added:

There are only a few differences in this version. First, there’s a new EnemySpawnedEvent and the Game has one instead of the C# event. The EnemySpawnedEvent class has an explicit reference to the SoundManager rather than the implicit reference that a C# event would have. It allows for the SoundManager to start and stop listening simply by setting the SoundManager field to this or null. Its Dispatch function does the null check instead of the Game. Lastly, HandleEnemySpawned needs to be public (or otherwise accessible) so the EnemySpawnedEvent can call it.

Already the EnemySpawnedEventArgs is seeming a little silly. We don’t normally package up our function parameters into a class and create garbage to make an instance of it, so why do we do it here? It’s also weird to pass a plain object “sender” to functions you call. So let’s remove those and see how it looks:

That was really easy to do. We no longer need to type out the boilerplate EventArgs or sender parameters and the code is a lot more natural. Unlike with an Action<int, Vector3> C# event, the parameters are explicitly named in the Dispatch function. It’s more like we defined our own delegate type, except we didn’t have to type out that boilerplate.

So how do we add more listeners? Easy! We just add them to the EnemySpawnedEvent class. The listeners don’t even need to have the same function signature as each other, which is even more flexible than with normal events. Let’s add a class that tracks game statistics and have it listen to our “enemy spawned” event:

// Define a class for the event itselfclass EnemySpawnedEvent
{public SoundManager SoundManager;public StatsManager StatsManager;publicvoid Dispatch(int enemyId, Vector3 location){if(SoundManager !=null){
SoundManager.HandleEnemySpawned(enemyId, location);}if(StatsManager !=null){
StatsManager.HandleEnemySpawned(enemyId);}}}class Game
{// Declare an eventpublic EnemySpawnedEvent OnEnemySpawned {get;privateset;}public Game(){// Create the event
OnEnemySpawned =new EnemySpawnedEvent();}publicvoid SpawnEnemy(int enemyId, Vector3 location){// Call all the listeners of the event
OnEnemySpawned.Dispatch(enemyId, location);}}class SoundManager
{private Game game;public SoundManager(Game game){this.game= game;// Listen to be notified when an enemy spawns
game.OnEnemySpawned.SoundManager=this;}// Called when an enemy spawnspublicvoid HandleEnemySpawned(int enemyId, Vector3 location){// Use the event arguments
Debug.LogFormat("Enemy with ID {0} spawned at {1}", enemyId, location);// Stop listening so this function is no longer called when enemies spawn
game.OnEnemySpawned.SoundManager=null;}}class StatsManager
{private Game game;public StatsManager(Game game){this.game= game;// Listen to be notified when an enemy spawns
game.OnEnemySpawned.StatsManager=this;}// Called when an enemy spawnspublicvoid HandleEnemySpawned(int enemyId){// Use the event arguments
Debug.LogFormat("Enemy with ID {0} spawned", enemyId);// Stop listening so this function is no longer called when enemies spawn
game.OnEnemySpawned.StatsManager=null;}}

Notice how the critical separation of the dispatcher (Game) and the listeners (SoundManager, StatsManager) is preserved by the middle-man (EnemySpawnedEvent). We can add and remove listeners without changing either side.

That’s about all there is to this pattern, so let’s look at its “pros” and “cons” compared to C# events. We’ll judge it by the same criteria as before: boilerplate, performance, garbage, debugging, and flexibility.

First up is boilerplate. With C# events you don’t need to type an event class, but you probably do need to type an EventArgs class. You could skip it, but only with a sizable usability hit. With this alternative system—let’s call them pseudo-events—you need to type the event class but you can skip the EventArgs class without a usability hit. Both systems have to check for null listeners, it’s just moved to the event class from the dispatch points. I’ll call this category a tie.

Next is performance. Pseudo-events just use normal function calls and are therefore 10x faster. You could opt to use interfaces (e.g. IEnemySpawnedListener) instead, but you’d take a speed hit to do so. This category is a clear win for pseudo-events.

Now for garbage creation. C# events are a class containing a dynamic list of listener function delegates. You usually create garbage for the delegate, for the list, and for the event itself. With pseudo-events there are no delegates and no list, so that’s zero garbage. You could even make the event class a struct and have no garbage for that, either. This is another clear win for pseudo-events.

When it comes to debugging, C# events are quite opaque. You’ve got to use a debugger and even then you’ll see a cryptic list of delegates that might not have good names. With pseudo-events you can see named fields referencing your listeners. There is an actual .cs file with the event source code that you can step into and inspect. Or you can add log statements if you prefer. It’s much more debug-friendly than C# events, so that’s a win for pseudo-events.

Finally there’s flexibility. Both systems allow for the same types of functions as listeners: static, non-static, lambda, or delegate. Both allow for starting and stopping listening at runtime. C# events arguably have two advantages though. First, you can add listeners according to runtime variables. For example, you could make a for loop to add N listeners where N is computed at runtime. Second, you can reorder the list of delegates in C# events based on information you don’t have at compile time. In practice, both of these are pretty unusual. When’s the last time you cared about the order that listeners were called in? Is it even a good idea to write code that relies on that ordering? And when’s the last time you added an arbitrary number of listeners? Usually you just add one, like in the examples. Either of these can be replicated with pseudo-events using a List or priority values, but you probably won’t need to. On the other hand, C# events force all listener functions to have the same signature. Pseudo-events listeners can have different signatures, as seen in the example. C# events are probably a bit more flexible overall though.

To summarize, here’s a table comparing C# events to pseudo-events:

Category

C# Events

Pseudo-Events

Performance

Slow

Fast

Garbage

Yes

No

Boilerplate

EventArgs

Event

Debugging

Hard

Easy

Flexibility

Great

Good

What do you think about this alternative to C# and Unity events? Let me know in the comments!

Comments

…Love it! Wish I had something more constructive to say, but I’ll have to leave that to other super-smart coders like yourself. I’ll be checking the comments though as I too would love to hear more about these pseudo events. I’m just here to learn and once again you did not disappoint JD.

As always, great article. I learn a lot from each, including this one. My style is heavily influenced by some of the techniques I’ve discovered through many of your past articles. Thank you.

May I play devils advocate? :)

First question/comment: I may be naive but are we not simply leveraging the original intentions of OO message passing? In other words, we’re doing regular ol’ boring method calls so the only relation to ‘events’ is that we’ve labeled and abstracted a class as such, right? Essentially, we’ve bypassed the convenience of builtin constructs for a supposed gain in performance.

Second question based on the data in your article ‘virtual function performance’ — it seems there’s a ~200ms difference between using abstract and concrete classes, so my question is this: is the gain in performance worth the implications of maintenance when the project and system is scaled up?

As stated we commonly only ever have one or two listeners, but my gut (however irrational) would attempt to improve encapsulation and lessen coupling by defining a common interface/base class, thus replacing the hard coded manager references with a List, and also replacing the if statements with a for loop that iterates over the list. Probably overkill.

I imagine this is all context specific and that we must always balance performance against encapsulation/coupling/complexity/readability but for the sake of discussion, I raise these points (however silly they may be).

Glad you enjoyed the article. You’re always welcome to play devil’s advocate! :)

Q: Isn’t this just method calls?
A: It’s just method calls, but that doesn’t mean it’s not an event. As the Events Tutorial for C# says, “An event in C# is a way for a class to provide notifications to clients of that class when some interesting thing happens to an object.” That’s true of the pseudo-events in the article. They act as a middle-man that takes one function call and uses it to call many functions.

Q: Are we trading convenience for performance?
A: I judged both systems based on boilerplate, performance, garbage, debugging, and flexibility. Performance and garbage are important, but convenience is taken into account by the boilerplate, debugging, and flexibility categories. On the whole, pseudo-events are at least as convenient as C# events and have much better performance.

Q: Are the benefits of virtual function calls worth the performance hit?
A: That depends on many factors, but not necessarily related to this article. In the pseudo-events example I showed non-virtual function calls, but you could easily make all the functions virtual. If you prefer, feel free to add interfaces or abstract classes. This often isn’t necessary though and I’d encourage you to delay creating those interfaces until you have at least a second class that will implement them.

Q: Should the event class have a List of abstract listeners?
A: If you do this you’ll basically have a C# event that can only accept objects implementing a particular interface. That will save you from the slowness and garbage creation of delegates and allow you to use a single event class for all your events. It basically requires virtual function calls, mandates a particular event-handling signature, requires garbage for the list, increases complexity, and may lead you to create several versions of it for various numbers of parameters. It’s an interesting middle ground between pseudo-events and C# or Unity events. Let me know how it works out if you give it a shot.