bevy icon indicating copy to clipboard operation
bevy copied to clipboard

bevy_reflect: Function reflection

Open MrGVSV opened this issue 1 year ago • 1 comments

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:

  1. Manually parse the reflected data and call the function themselves
  2. 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:

  1. 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) -> &i32 without creating the Function manually.
  2. Only 15 arguments are currently supported. If the first argument is a (mutable) reference, this number increases to 16.
  3. Manual implementations of Reflect will need to implement the new FromArg, GetOwnership, and IntoReturn traits in order to be used as arguments/return types.

And some limitations of Function itself:

  1. All arguments share the same lifetime, or rather, they will shrink to the shortest lifetime.
  2. Closures that capture their environment may need to have their Function dropped before accessing those variables again (there is a Function::call_once to make this a bit easier)
  3. All arguments and return types must implement Reflect. While not a big surprise coming from bevy_reflect, this implementation could actually still work by swapping Reflect out with Any. Of course, that makes working with the arguments and return values a bit harder.
  4. Generic functions are not support (unless they have been manually monomorphized)

And general, reflection gotchas:

  1. &str does not implement Reflect. Rather, &'static str implements Reflect (the same is true for &Path and similar types). This means that &'static str is considered an "owned" value for the sake of generating arguments. Additionally, arguments and return types containing &str will assume it's &'static str, which is almost never the desired behavior. In these cases, the only solution (I believe) is to use &String instead.

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 Reflection example: https://github.com/bevyengine/bevy/blob/7af7456bd9b9d5a4e6fa455dc0c7d8960440eb6c/examples/reflection/function_reflection.rs#L1-L156

Details

Added the following items:

  • ArgError enum
  • ArgId enum
  • ArgInfo struct
  • ArgList struct
  • Arg enum
  • FromArg trait (derived with derive(Reflect))
  • FuncError enum
  • FunctionInfo struct
  • FunctionResult alias
  • Function struct
  • GetOwnership trait (derived with derive(Reflect))
  • IntoFunction trait (with blanket implementation)
  • IntoReturn trait (derived with derive(Reflect))
  • Ownership enum
  • ReturnInfo struct
  • Return enum

MrGVSV avatar Apr 30 '24 20:04 MrGVSV

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? 🤔

MrGVSV avatar Apr 30 '24 20:04 MrGVSV

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? 🤔

I think I'm leaning towards DynamicFunction. Then the traits and methods would be renamed to things like IntoDynamicFunction::into_dynamic_function, etc.

Thoughts?

MrGVSV avatar May 23 '24 00:05 MrGVSV

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? 🤔

I think I'm leaning towards DynamicFunction. Then the traits and methods would be renamed to things like IntoDynamicFunction::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.

MrGVSV avatar Jun 26 '24 02:06 MrGVSV

Updated the PR description with the new DynamicFunction naming.

MrGVSV avatar Jun 29 '24 05:06 MrGVSV

Two updates:

  1. I realized that std::any::type_name could be used to automatically infer the function name, so I went ahead and made that the default
  2. DynamicFunction now implements IntoFunction, allowing it to be used interchangeably with an actual function pointer or closure wherever IntoFunction is expected

MrGVSV avatar Jun 29 '24 23:06 MrGVSV

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.

MrGVSV avatar Jun 30 '24 00:06 MrGVSV