C++ Scripting: Part 16 – Events
Last week’s article covered delegates, so it’s only natural that we follow up this week by covering events. Supporting delegates has laid a good foundation for supporting events, so let’s dive in and see how to implement and use them in C++.
Table of Contents
- Part 1: C#/C++ Communication
- Part 2: Update C++ Without Restarting the Editor
- Part 3: Object-Oriented Bindings
- Part 4: Performance Validation
- Part 5: Bindings Code Generator
- Part 6: Building the C++ Plugin
- Part 7: MonoBehaviour Messages
- Part 8: Platform-Dependent Compilation
- Part 9: Out and Ref Parameters
- Part 10: Full Generics Support
- Part 11: Collaborators, Structs, and Enums
- Part 12: Exceptions
- Part 13: Operator Overloading, Indexers, and Type Conversion
- Part 14: Arrays
- Part 15: Delegates
- Part 16: Events
- Part 17: Boxing and Unboxing
- Part 18: Array Index Operator
- Part 19: Implement C# Interfaces with C++ Classes
- Part 20: Performance Improvements
- Part 21: Implement C# Properties and Indexers in C++
- Part 22: Full Base Type Support
- Part 23: Base Type APIs
- Part 24: Default Parameters
- Part 25: Full Type Hierarchy
- Part 26: Hot Reloading
- Part 27: Foreach Loops
- Part 28: Value Types Overhaul
- Part 29: Factory Functions and New MonoBehaviours
- Part 30: Overloaded Types and Decimal
First let’s recap delegates and events from a purely C# perspective: no C++. Delegates are a class type that represent zero or more functions. Unlike interfaces or classes, these can be any functions: static methods, instance methods, lambdas, or anonymous functions. They can use generic types for their parameters and return values. And we can even define them as “top level” types like classes, structs, interfaces, and enums. Here are some quick examples:
// Define a delegate that takes no parameters and returns void delegate void Runnable(); // Use a Runnable void Foo() { Debug.Log("Foo called"); } Runnable runnable = Foo; // Define a delegate that takes two floats and returns a bool delegate bool FloatOp(float a, float b); // Use a FloatOp bool IsGreater(float a, float b) { return a > b; } FloatOp floatOp = IsGreater; // Define a delegate that has generic parameters and return values delegate TReturn BinaryFunc<TParam1, TParam2, TReturn>(TParam1 param1, TParam2 param2); // Use a BinaryFunc uint Add(byte a, ushort b) { return (uint)(a + b); } BinaryFunc<byte, ushort, uint> binaryFunc = Add;
Delegate types internally keep track of the functions to call when they are called. In all of the examples above, there’s just one function to call. However, we can add and remove more functions like this:
// Define the delegate delegate void Runnable(); // Define a couple of functions usable with the delegate void Foo() { Debug.Log("Foo called"); } void Bar() { Debug.Log("Bar called"); } // Initialize the delegate with just one function Runnable runnable = Foo; // Calling the delegate calls just the one function runnable(); // prints: "Foo called" // Add a function. Calling the delegate now calls both. runnable += Bar; runnable(); // prints "Foo called" and "Bar called" // Remove a function. Calling the delegate just calls the one we didn't remove. runnable -= Foo; runnable(); // prints "Bar called"
Keep in mind that this is all just delegates. Events haven’t entered the picture yet. It’s a common misconception that events are required for more than one function to be called when a delegate is called (a.k.a. invoked).
Now let’s layer on events. Events only do one thing: add syntax sugar for two special functions. Imagine we wanted users of our class, struct, or interface to be able to add functions to and remove functions from the delegate. We would have to define functions like this:
public class Button { // Define a delegate for when clicks happen public delegate void ClickHandler(); // Keep an instance of the delegate private ClickHandler onClick; // Allow users of the Button to add and remove functions to the delegate void AddClickHandler(ClickHandler handler) { onClick += handler; } void RemoveClickHandler(ClickHandler handler) { onClick -= handler; } } // User code Button loginButton = new Button(); loginButton.AddClickListener(HandleLoginButtonClicked); void HandleLoginButtonClicked() { // ... do log in }
AddClickHandler
and RemoveClickHandler
allow Button
to maintain “encapsulation” by tightly controlling the access to the onClick
delegate field. If Button
were to make onClick
into a public field or property, users of Button
would have uncontrolled access to it. That would mean that they could write code like button.onClick = null
and clear all the functions that have been added to the delegate.
The language designers of C# thought “add” and “remove” functions were such a good idea that they gave them their own keyword: event
. By adding the event
keyword to the delegate field, we automatically get AddClickHandler
and RemoveClickHandler
plus some special syntax for users of our class, struct, or interface. Here’s how it looks:
public class Button { // Define a delegate for when clicks happen public delegate void ClickHandler(); // Keep an instance of the delegate as an event public event ClickHandler onClick; } // User code Button loginButton = new Button(); loginButton.onClick += HandleLoginButtonClicked; void HandleLoginButtonClicked() { // ... do log in }
The differences here are that the field is now public and has the event
keyword. AddClickHandler
and RemoveClickHandler
are essentially automatically generated by the compiler. To call them, users use special syntax sugar like this: button.onClick += del
and button.onClick -= del
. Other than that, nothing has changed. It’s just a more concise way of writing the same code.
So what if we wanted to be able to write AddClickHandler
and RemoveClickHandler
functions that did more than just add and remove functions to the delegate? Just like with auto-properties (e.g. public string Name { get; set; }
), we can define our own add
and remove
functions for events very similarly to defining our own get
and set
for properties.
public class Button { // Define a delegate for when clicks happen public delegate void ClickHandler(); // Keep an instance of the delegate (not as an event) private ClickHandler onClick; // Define an event for the delegate public event ClickHandler OnClick { add { Debug.Log("Added to OnClick"); onClick += value; } remove { Debug.Log("Removed from OnClick"); onClick -= value; } } }
This is just like properties where we have a private “backing field” and a public property with custom get
and set
functions. We even get an implicit parameter named value
. The differences are that they’re named add
and remove
, use the event
keyword, only work on delegate types, and users call them with +=
and -=
instead of obj.Name
and obj.Name = "Jackson"
.
With this understanding of events, it’s easy to see that exposing them to C++ is going to be easy. First we’ll need a new section in the code generator’s JSON config to define which events to allow C++ to use. This goes in the Types
block alongside the sections for fields, properties, methods, and constructors:
{ "Name": "UnityEngine.Application", "Events": [ { "Name": "onBeforeRender" } ] },
The above JSON snippet tells the code generator that we want C++ code to have access to the onBeforeRender
event in Unity’s Application
class. Of course we’ll also need to specify JSON for the UnityAction
delegate type to make the event usable:
"Delegates": [ { "Type": "UnityEngine.Events.UnityAction" } ]
Now we can write C++ game code to use the event:
// Define a delegate type struct BeforeRenderHandler : UnityAction { // Define the function to call when the delegate is invoked void operator()() override { Debug::Log(String("Before render handler called")); } }; // Create a delegate BeforeRenderHandler handler; // Add the delegate to the event. Equivlanet to +=. Application.AddOnBeforeRender(handler);
The explicit AddX
and RemoveX
method names are used in C++, just like the explicit GetX
and SetX
names are used with properties. Properties are really similar to events, so this symmetry makes sense.
When using delegates and events in C++, it’s important to keep in mind the lifecycle of the event object. Unlike C# where the memory manager is keeping our object alive until it’s garbage-collected, C++ has no memory manager or garbage collector to do this for us. That’s for better and for worse. On the “better” side, it’s easier to write casual C# code that doesn’t really care where the memory is, when it gets freed, and how much time garbage collection takes. On the “worse” side, it’s impossible to write C# code that cares about any of these things. It’s a little reminder of the sort of doors that scripting in C++ opens for us.
That said, let’s look briefly at how C++ object lifecycles work. This will help us avoid situations where we think that our delegate should be called but actually isn’t because the delegate object is dead. The main thing to keep in mind is that a C++ object is destructed when it goes out of scope. So this code will not result in the delegate getting called:
void Foo() { // Create a delegate // Its constructor is called // This creates the C# delegate BeforeRenderHandler handler; // Add the delegate to the event // The event's delegate gets a reference to the C# delegate for 'handler' Application.AddOnBeforeRender(handler); // The function ends and 'handler' goes out of scope // This calls the destructor for 'handler' // This sets a flag in C# to not call the C++ delegate // This is good because it's dead now // This is bad because we won't get called back }
Note that there isn’t any code for the last set of comments. Merely having handler
go out of scope at the next }
is enough to trigger its destructor. It’s essential to keep this in mind: class objects go away just like other local variables when their scope ends.
To work around this, we need a way to store variables long-term. Instead of storing them on the “stack” like other temporary local variables, we want to decouple event handlers like this from the lifecycle of the function that’s creating them. We need to use the heap for this, and that’s easily done in C++. The new
operator allocates space on the heap and calls the constructor. We can use it like this:
void Foo() { // Create a delegate on the heap. We get a pointer (*) to it. // Its constructor is called // This creates the C# delegate BeforeRenderHandler* handler = new BeforeRenderHandler(); // Add the delegate to the event by dereferencing (*handler) // The event's delegate gets a reference to the C# delegate for 'handler' Application.AddOnBeforeRender(*handler); // The function ends and 'handler' goes out of scope // But it's just a pointer, so its destructor isn't called }
Now that the object continues to live on the heap beyond the end of the function, it’s important to make sure we later on call delete handler
to free up its memory or we’ll have a leak on our hands. This means we’ll need to keep track of handler
and delete it at the right time. We can also explicitly say where on the heap handler
should be allocated using “placement new”: new (address) BeforeRenderHandler()
. So with C++ we have total control over where the memory is allocated, when it is freed, and how it is freed.
With that, we now have support for events in C++. It’s a simple addition onto delegates once we’ve understood what events in C# really are: syntax sugar around two functions. However, they’re used pretty widely in C# APIs, so supporting them is important to make C++ a viable scripting alternative to C#. Check out the GitHub project to get access to the support for events or to dig into the nitty-gritty details of how they’re implemented.