bevy_reflect: Function reflection
Objective
We're able to reflect types sooooooo... why not functions?
The goal of this PR is to make functions callable within a dynamic context, where type information is not readily available at compile time.
For example, if we have a function:
fn add(left: i32, right: i32) -> i32 {
left + right
}
And two Reflect values we've already validated are i32 types:
let left: Box<dyn Reflect> = Box::new(2_i32);
let right: Box<dyn Reflect> = Box::new(2_i32);
We should be able to call add with these values:
// ?????
let result: Box<dyn Reflect> = add.call_dynamic(left, right);
And ideally this wouldn't just work for functions, but methods and closures too!
Right now, users have two options:
- Manually parse the reflected data and call the function themselves
- Rely on registered type data to handle the conversions for them
For a small function like add, this isn't too bad. But what about for more complex functions? What about for many functions?
At worst, this process is error-prone. At best, it's simply tedious.
And this is assuming we know the function at compile time. What if we want to accept a function dynamically and call it with our own arguments?
It would be much nicer if bevy_reflect could alleviate some of the problems here.
Solution
Added function reflection!
This adds a Function type to wrap a function dynamically. This can be called with an ArgList, which is a dynamic list of Reflect-containing Arg arguments. It returns a FunctionResult which indicates whether or not the function call succeeded, returning a Reflect-containing Return type if it did succeed.
Many functions can be converted into this Function type thanks to the IntoFunction trait.
Taking our previous add example, this might look something like (explicit types added for readability):
fn add(left: i32, right: i32) -> i32 {
left + right
}
let mut function: Function = add.into_function();
let args: ArgList = ArgList::new().push_owned(2_i32).push_owned(2_i32);
let result: Return = function.call(args).unwrap();
let value: Box<dyn Reflect> = result.unwrap_owned();
assert_eq!(value.take::<i32>().unwrap(), 4);
And it also works on closures:
let add = |left: i32, right: i32| left + right;
let mut function: Function = add.into_function();
let args: ArgList = ArgList::new().push_owned(2_i32).push_owned(2_i32);
let result: Return = function.call(args).unwrap();
let value: Box<dyn Reflect> = result.unwrap_owned();
assert_eq!(value.take::<i32>().unwrap(), 4);
As well as methods:
#[derive(Reflect)]
struct Foo(i32);
impl Foo {
fn add(&mut self, value: i32) {
self.0 += value;
}
}
let mut foo = Foo(2);
let mut function: Function = Foo::add.into_function();
let args: ArgList = ArgList::new().push_mut(&mut foo).push_owned(2_i32);
function.call(args).unwrap();
assert_eq!(foo.0, 4);
Limitations
While this does cover many functions, it is far from a perfect system and has quite a few limitations. Here are a few of the limitations when using IntoFunction:
- The lifetime of the return value is only tied to the lifetime of the first argument (useful for methods). This means you can't have a function like
(a: i32, b: &i32) -> &i32without creating theFunctionmanually. - Only 15 arguments are currently supported. If the first argument is a (mutable) reference, this number increases to 16.
- Manual implementations of
Reflectwill need to implement the newFromArg,GetOwnership, andIntoReturntraits in order to be used as arguments/return types.
And some limitations of Function itself:
- All arguments share the same lifetime, or rather, they will shrink to the shortest lifetime.
- Closures that capture their environment may need to have their
Functiondropped before accessing those variables again (there is aFunction::call_onceto make this a bit easier) - All arguments and return types must implement
Reflect. While not a big surprise coming frombevy_reflect, this implementation could actually still work by swappingReflectout withAny. Of course, that makes working with the arguments and return values a bit harder. - Generic functions are not support (unless they have been manually monomorphized)
And general, reflection gotchas:
&strdoes not implementReflect. Rather,&'static strimplementsReflect(the same is true for&Pathand similar types). This means that&'static stris considered an "owned" value for the sake of generating arguments. Additionally, arguments and return types containing&strwill assume it's&'static str, which is almost never the desired behavior. In these cases, the only solution (I believe) is to use&Stringinstead.
Followup Work
This PR is the first of two PRs I intend to work on. The second PR will aim to integrate this new function reflection system into the existing reflection traits and TypeInfo. The goal would be to register and call a reflected type's methods dynamically.
I chose not to do that in this PR since the diff is already quite large. I also want the discussion for both PRs to be focused on their own implementation.
Another followup I'd like to do is investigate allowing common container types as a return type, such as Option<&[mut] T> and Result<&[mut] T, E>. This would allow even more functions to opt into this system. I chose to not include it in this one, though, for the same reasoning as previously mentioned.
Alternatives
One alternative I had considered was adding a macro to convert any function into a reflection-based counterpart. The idea would be that a struct that wraps the function would be created and users could specify which arguments and return values should be Reflect. It could then be called via a new Function trait.
I think that could still work, but it will be a fair bit more involved, requiring some slightly more complex parsing. And it of course is a bit more work for the user, since they need to create the type via macro invocation.
It also makes registering these functions onto a type a bit more complicated (depending on how it's implemented).
For now, I think this is a fairly simple, yet powerful solution that provides the least amount of friction for users.
Showcase
Bevy now adds support for storing and calling functions dynamically using reflection!
// 1. Take a standard Rust function
fn add(left: i32, right: i32) -> i32 {
left + right
}
// 2. Convert it into a type-erased `Function` using the `IntoFunction` trait
let mut function: Function = add.into_function();
// 3. Define your arguments from reflected values
let args: ArgList = ArgList::new().push_owned(2_i32).push_owned(2_i32);
// 4. Call the function with your arguments
let result: Return = function.call(args).unwrap();
// 5. Extract the return value
let value: Box<dyn Reflect> = result.unwrap_owned();
assert_eq!(value.take::<i32>().unwrap(), 4);
Changelog
TL;DR
- Added support for function reflection
- Added a new
Function Reflectionexample: https://github.com/bevyengine/bevy/blob/7af7456bd9b9d5a4e6fa455dc0c7d8960440eb6c/examples/reflection/function_reflection.rs#L1-L156
Details
Added the following items:
ArgErrorenumArgIdenumArgInfostructArgListstructArgenumFromArgtrait (derived withderive(Reflect))FuncErrorenumFunctionInfostructFunctionResultaliasFunctionstructGetOwnershiptrait (derived withderive(Reflect))IntoFunctiontrait (with blanket implementation)IntoReturntrait (derived withderive(Reflect))OwnershipenumReturnInfostructReturnenum
Hm, I'm debating on whether or not I should rename Function to Func. If we ever want to add a Function trait, then it might be good to do it now in order to avoid a rename. Or perhaps DynamicFunction would be a clearer name? 🤔
Hm, I'm debating on whether or not I should rename
FunctiontoFunc. If we ever want to add aFunctiontrait, then it might be good to do it now in order to avoid a rename. Or perhapsDynamicFunctionwould be a clearer name? 🤔
I think I'm leaning towards DynamicFunction. Then the traits and methods would be renamed to things like IntoDynamicFunction::into_dynamic_function, etc.
Thoughts?
Hm, I'm debating on whether or not I should rename
FunctiontoFunc. If we ever want to add aFunctiontrait, then it might be good to do it now in order to avoid a rename. Or perhapsDynamicFunctionwould be a clearer name? 🤔I think I'm leaning towards
DynamicFunction. Then the traits and methods would be renamed to things likeIntoDynamicFunction::into_dynamic_function, etc.Thoughts?
I just pushed the rename changes. I went with DynamicFunction but kept IntoFunction::into_function. My reasoning for keeping the latter was mainly because I think it still gets the point across while not being as verbose. And we may also change the return value to be a Box<dyn Function> or something else in the future idk haha.
Updated the PR description with the new DynamicFunction naming.
Two updates:
- I realized that
std::any::type_namecould be used to automatically infer the function name, so I went ahead and made that the default DynamicFunctionnow implementsIntoFunction, allowing it to be used interchangeably with an actual function pointer or closure whereverIntoFunctionis expected
Talked about this on Discord with @NthTensor, but I think I may explore an IntoClosure/DynamicClosure split in a followup PR. This will make using a function registry a lot easier as we won't have to require a mutable reference in order to call purely-static functions.