C++ Scripting: Part 15 – Delegates
This week’s article adds another major feature to the C++ scripting system: delegates. These are vital so C++ game code can use features like Unity’s UI system (a.k.a. UGUI). Without them, we wouldn’t be able to handle button clicks or other UI events. So read on to learn how these were implemented in the GitHub project.
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
Delegates, like MonoBehaviour
and arrays, are different from normal types like classes, enums, and structs. As such, we need to create a new section in the code generator’s JSON config file:
"Delegates": [ { "Type": "System.Action" } ]
Here we’re simply saying that we want C++ game code to be able to use the Action
delegate type.
Sometimes delegate types are generic, including the very common Action<T>
, Action<T1, T2>
, etc. To generate code for those, just add a GenericParams
section like with other types (e.g. List<T>
).
{ "Type": "System.Action`2", "GenericParams": [ { "Types": [ "System.Single", "System.Single" ] }, { "Types": [ "System.Int32", "System.Double" ] } ] }
This JSON snippet says to generate code so C++ can use either Action<float, float>
or Action<int, double>
.
The last bit of JSON config is to specify the maximum number of simultaneous delegates that C++ can use at once. By default, it’ll use whatever we pass to Bindings.Init
at runtime. If we want to override this, we can do so at two levels:
{ "Type": "System.Action`2", "MaxSimultaneous": 100, "GenericParams": [ { "Types": [ "System.Single", "System.Single" ], "MaxSimultaneous": 500 }, { "Types": [ "System.Int32", "System.Double" ] } ] }
MaxSimultaneous
in the outer block says that we want a maximum of 100 Action<T1, T2>
types at once. Then MaxSimultaneous
in the GenericParams
block says that we want a maximum of 500 Action<float, float>
types at once. The GenericParams
for Action<int, double>
doesn’t specify MaxSimultaneous
, so it inherits the default for Action<T1, T2>
: 100.
That’s all there is to configuring the code generator. Now let’s look at how to use the generated code.
In C#, we can create a delegate in several ways. Here’s an overview:
// A lambda Action<float, float> lambda = (x, y) => Debug.Log("Lambda called"); // An anonymous function Action<float, float> anon = delegate(float x, float y) { Debug.Log("Anonymous function called"); }; // A method group void Method(float x, float y) { Debug.Log("Method called"); } Action<float, float> method = new Action<float, float>(InstanceMethod); Action<float, float> methodImplicit = InstanceMethod; // compiler generates the "new"
There are no global functions in C#, so a class is required to hold them. That means that the compiler will create a class for us when we create a delegate from a lambda or anonymous method. It looks something like this:
// Create a class for the lambda class LambdaClass { // Put the lambda function in it void Invoke(float x, float y) { Debug.Log("Lambda called"); } } // Now use the class to make a method group lambda LambdaClass lambdaClass = new LambdaClass(); Action<float, float> lambda = new Action<float, float>(lambdaClass.Invoke);
This is also how “closures” work. Our lambda can access the local variables of the function because they’re copied into fields of the class that the compiler generates.
In C++, we’ll skip the syntax sugar for lambdas and anonymous functions for now and just create a class in the first place. First, the code generator will create a class like this:
// The number (2) is the number of type/generic parameters template<> struct Action2<float, float> : Object { // Constructor. Creates the Action<float, float> in C#. Action2(); // Invoke the Action<float, float> in C# void Invoke(float arg1, float arg2); // Called when the Action<float, float> is invoked in C# virtual void operator()(float arg1, float arg2); // Add and remove delegates to this delegate void operator+=(System::Action2<float, float>& del); void operator-=(System::Action2<float, float>& del); };
This class is pretty useless by itself. If we create one, we’ll have an Action<float, float>
that does nothing when invoked. To make it useful, we need to derive from it and override the operator()
to do something useful.
struct SetXYDelegate : Action2<float, float> { Transform transform; void operator()(float x, float y) override { Vector3 pos = transform.GetPosition(); pos.x = x; pos.y = y; transform.SetPosition(pos); } };
Now we have a delegate that actually does something when it’s invoked. In this case it uses the delegate parameters to set the position
property of a Transform
field. We can use it like this:
// Create the delegate SetXYDelegate del; del.transform = gameObj.GetTransform(); // Invoke the delegate del.Invoke(123, 456);
This all works very much like how delegates work behind the scenes in C#. One key difference is that we have direct access to the delegate class rather than having it hidden from us. This allows us to add whatever fields and functions we want to the delegate class instead of being limited to just Invoke
.
On the downside, we’ve given up some syntax sugar. We have to type a lot more code to make quick delegates compared to the minimal syntax that lambdas require. We can work around that, though. C++ also includes support for lambdas, so all we need to do is create a delegate class that contains one and call that from operator()
.
Here’s the delegate class we’ll create:
// The first two paramters are the type parameters to Action // The third parameter is the type of object to call from operator() template <typename T1, typename T2, typename Callable> struct CallableAction2 : Action2<T1, T2> { // Hold the thing to call Callable func; // Constructor requires something to call CallableAction2(Callable& func) : func(func) { } // Instead of doing anything here, call the thing to call and forward the // parameters on to it void operator()(T1 arg1, T2 arg2) override { func(arg1, arg2); } };
A helper function makes this class easier to use:
// This basically just wraps the constructor so we don't need to type out // all the type parameters template <typename T1, typename T2, typename Callable> CallableAction2<T1, T2, Callable> MakeAction2(Callable&& callable) { return CallableAction2<T1, T2, Callable>(callable); }
Finally, we can use it with a delegate like this:
// "auto" in C++ is like "var" in C# // Pass in a lambda auto lambdaDel = MakeAction2<float, float>( [](float x, float y) { Debug::Log(String("Lambda called")); }); // Invoke the delegate lambdaDel.Invoke(123, 456);
Likewise, it works with other “callable” types like global functions:
void GlobalFunction(float x, float y) { Debug::Log(String("Global function called")); } auto globalFuncDel = MakeAction2<float, float>(GlobalFunction); globalFuncDel.Invoke(123, 456);
Now we don’t need to create any delegate classes if we don’t want to. Instead, we can just use MakeAction2
and pass in whatever we want to be invoked. A future version of the code generator will create these wrapper classes and functions for us, but for now we can do it manually as above.
We also have overloaded +=
and -=
operators on all delegate types. These work just like in C#:
// Create two delegates SetXYDelegate del1; del1.transform = go1.GetTransform(); SetXYDelegate del2; del2.transform = go2.GetTransform(); // Add the second delegate to the first del1 += del2; // Invoke the first. The second will also be invoked. del1.Invoke(10, 20); // Remove the second delegate from the first del1 -= del2; // Invoke the first. The second won't be invoked. del1.Invoke(30, 40);
Finally for usage, delegates can have return values. This also works as it does in C#:
// Add the two parameters and return the sum struct AddDelegate : Func<int, int, int> { int operator()(int x, int y) override { return x + y; } }; AddDelegate addDel; int sum = addDel.Invoke(2, 3); // sum == 5
Returning values from delegates can be useful when using functions like Array.Sort
which takes a Comparison
delegate. That delegate is called with two elements of the array and returns an int
indicating their sort order.
Note that we’re able to call both Invoke
and operator()
from C++ code. This gives us the flexibility to use two different behaviors. Calling Invoke
is just like invoking the delegate in C# and will cause its C# counterpart to be invoked as normal. This may have side-effects on the C# side, including calling delegates that were added to the delegate. However, we can also directly call operator()
on a C++ delegate to invoke it alone. No added delegates will be invoked and no calls will be made into C#. This makes those calls faster than Invoke
and provides a nice, lightweight option to a full Invoke
call. To call this way, simply call the delegate like a function: del(2, 3)
.
Implementing delegates in the code generator relies on a combination of techniques from previous articles in this series plus a few extra tricks. Calling from C++ into C# for construction, destruction, Invoke
, operator+=
, and operator-=
is all just like with any other function call into C#. Calling from C# into C++ when the delegate is invoked is just like when a MonoBehaviour
“message” like Update
is called. However, there are some critical extra steps to consider in both languages to make this work.
When a delegate is invoked, C# needs to tell C++ which delegate was invoked. That’s just like how C++ needs to tell C# which object to call a method on. Again, we’ll use handles to solve this problem. Instead of object handles stored on the C# side, we’ll store delegate handles on the C++ side. These allow C# to reference a delegate by an int
and C++ to look up a pointer to that delegate using that int
. This is why we added the MaxSimultaneous
properties to JSON: we need to know how many delegate handles to store.
Next, we need to coordinate the lifecycles of C# and C++ objects. When a delegate class is instantiated on the C++ side, it’ll instantiate a delegate on the C# side. The delegate may be passed around and referenced by various other C# objects, such as by setting one to a property of a long-lived object. If the C++ delegate object destructs, its handle becomes invalid. If the delegate is invoked, C# will call into C++ with the invalid handle of a dead C++ delegate and a crash will likely result.
To work around this, we need a couple of things. First, we need a way for C++ to tell C# that a delegate handle is no longer valid and it shouldn’t ever be called again when the delegate is invoked. That can be easily slotted into the C++ class’ destructor. Second, we need a way to flag a C# delegate so that it’s no longer invoked. Unfortunately, unlike in C++, we don’t have access to the fields, properties, and methods of a C# delegate other than basic functions like Invoke
. To work around this, we create a class to contain the delegate and the delegate handle:
class SystemAction { // Delegate handle from C++ public int CppHandle; // The C# delegate public System.Action Delegate; // Construct with the handle public SystemAction(int cppHandle) { CppHandle = cppHandle; // Create the delegate // This is implicitly like "new System.Action(Invoke)" Delegate = Invoke; } // Delegate function public void Invoke() { // If the handle is still valid, we can call C++ if (CppHandle != 0) { SystemActionCppInvoke(CppHandle); } } }
Having this class means that we can later set CppHandle
to 0
when the C++ class destructs. Even though the C# delegate lives on because C++ code never removed all references to it, C++ code won’t be called when the delegate is invoked due to the if (CppHandle != 0)
check.
With the addition of these two techniques—C++ delegate handles and C# delegate classes—we can now safely use delegates from C++ without worrying about lifecycle issues.
In the miscellelaneous category, Unity 2017.2 was released this week. One of the changes split the Unity API from one assembly (UnityEngine.dll
) into a whole bunch of “module” DLLs. This means that different types in the Unity API are now in different assemblies. That, in turn, impacts the code generator because it looks up string type names from the JSON config file using Assembly.GetType
. This wasn’t a critical problem as we could always specify more DLLs in the JSON config file by adding UNITY_DLLS/UnityEngine.AudioModule.dll
, for example, but that’s quite inconvenient. Instead, the code generator has been upgraded to automatically provide access to all 36 non-obsolete Unity modules. Only the “legacy particles” module is omitted since it’s been obsolete since Unity 4.0 and causes a compiler warning. To get access to it, add UNITY_DLLS/UnityEngine.ParticlesLegacyModule.dll
to the JSON. Unity versions prior to 2017.2 are still supported via a #if
check.
That’s all for today. Next week we’ll continue by making delegates much more useful by supporting events. In the meantime, check out the GitHub project for all the nitty-gritty details about how delegates were implemented or just to try it out.
Questions about the project? Want to provide feedback? Leave a comment to let me know.