Auto-binding C++ functions for Lua
One of my C++ side-projects uses Lua for scripting.
I wanted to integrate a scripting language into my project, and Lua has a strong reputation as a language for games and also for being easy to embed. I wanted to learn how Lua bindings work and then created a system to simplify using Lua in this project.
With a bit of macros and template metaprogramming, exposing new functions to Lua can be done with minimal effort. This approach also supports bridging complicated types across the boundary as well as custom smart pointer types.
Overview
Lua is super powerful as an embedded scripting language. With not much effort you can hook up configuration for your program through or it, or with a little bit of management, you can create sandboxed VMs with only the necessary functions available, or create scripts which use Lua coroutines to allow scripts to pause and resume behavior. Overall, my experience using Lua has been overwhelmingly positive.
lua_State represents an individual Lua VM in your program, and you can use multiple at the same time. Each VM has its own state of execution and its own set of functions which are attached to it. The salient point is that the Lua C API lets you make C/C++ functions available for calling from Lua. When doing this, you need to pass parameters and return values across the boundary between the Lua VM and the native side. This data transfer happens via Lua's stack.
When binding functions to Lua, you need to be careful to push and pop off the appropriate values from that stack as arguments for the C/C++ function being called. This gets tedious and error-prone to use the correct functions to shovel data across and also keep track of the appropriate index of the associated data.
Binding with Macros
I wrote a little library for my project to bind a function to Lua which transfers the data across the Lua/C++ boundary in a type-safe and automatic way. It handles static functions, but also member functions and custom handle (non-pointer) types.
There's a little bit of duplication, but it hides a lot of manual handling of parameters.
A macro creates the appropriate wrapper function to call the desired function.
A little bit of glue code is needed to feed to Lua to describe the binding.
This gets associated with the appropriate lua_State using luaL_requiref to make it available for use within Lua.
And that's it.
This provides dot notation Type.function calls from Lua.
There's other flavors to allow Object:function to automatically pass the original object type, but both of these things work roughly the same way under the hood.
Macros inside of macros
CLion lets you expand macros inline. So let's unwrap what actually gets generated from this line:
After one substitution you see the internals.
The wrapping function name gets stitched together from PS_LUA_DEFINE_LIB_STATIC_FN after a couple expands. PS_LUA_FN_IDENTIFIER does some fancy stitching together a unique name for the call.
This is the final result.
Other than the logic for profiling, there really isn't much here except some template magic from aparams.call().
Hold onto your hats, we're about to go metaprogramming
Template metaprogramming is very powerful, but can be difficult to write and furthermore, to read.
AutoBind is a class to do the work of automatically passing and returning parameters to functions.
This member function of AutoBind is the start of the fun:
Let's walk through this.
The template needs to match against the class T containing the function. That function is going to have a return type (Result), which is potentially void. It could also have more than one argument (typename... Args). We're passing not just a function pointer here, but a member function pointer, which is associated with a class (T::*fn). Put together that gives us the parameter received by the call: Result (T::*fn)(Args...).
There's also a version used for
constmember functions. It has the same internals but needed a separate definition sinceconstand non-constmember function pointers are treated differently by the typechecker. That version addsconstto the end of the type here, so the parameter type would beResult (T::*fn)(Args...) const.
checkNumArguments here is debug code that ignores the top argument which is the object that this function is being called upon.
call returns the result of another template function callMemberFunction, where the meat of how this works actually comes together. We have to prime the function with appropriate types to get everything to resolve across all the platforms I build this on (MSVC on Windows, GCC on Linux, and Clang on Apple and Linux). Let's look at a stripped down version of that function without any error handling to see the main flow.
There's a hidden parameter when calling member functions in C++, which is the target object. That's the only required parameter. I wrote some safety elements around this and parameters which I'll go into after we look at how parameters are automatically passed.
The important part of the setup here is the funneling of parameters from the Lua data stack into a C++-side std::tuple used to call the function. Eventually, we end up calling std::apply(fn, params) which calls our bound member function fn, with our accumulated parameters (params) which have been marshalled across the Lua/C++ boundary.
We use a recursive template call to pull out all the parameters themselves, provided any additional parameters exist.
gatherParams expands at compile-time to generate code to loop through all the indexes and pull each parameter off the Lua data stack. Each iteration increases the index being assigned, and a constexpr unrolls another iteration until all parameters are handled.
Since the fold uses the appropriate type as given in the function parameter tuple, the appropriate LuaTransfer<> specialization is used to transfer the data between Lua and C++.
This is what gets the element from the Lua stack using an appropriate conversion function and then stuffs it into the parameter tuple for the call. This critical element is the bridge between the data that Lua is providing for the call and the C++ code being executed.
Shoveling Data across the Boundary
LuaTransfer defines the transfer of data to Lua ("push") and from Lua ("pull"). We want to avoid excessive copying when calling our functions to push values to Lua, so there's some compile-time logic to pass large values to the push function by const reference. We might need to copy a large amount of data onto the stack, but that doesn't mean we need to copy even more when doing the call to push that data.
That template gets specialized for every type that needs to cross the boundary between Lua and C++. This allows usage of those types as both parameter and return types. This also permits efficient built-in Lua C functions for types if available, as well as allowing to change the semantics of how types work. A 2D vector could allow passing as a table with named X and Y coordinates like exampleFunction ({x = 10, y = 20}).
Here's an example for bool:
Composibility of LuaTransfer<>
Some types are composed of other C++ types, like an axis-aligned box with a min and max point. LuaTransfer<> implementations get reused by some helper types to pass these values within other types.
If there's a result from the function, we can return it using appropriate specialized LuaTransfer::push function.