Skip to content

bevy_reflect: Add reflect_fn! macro #16754

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

MrGVSV
Copy link
Member

@MrGVSV MrGVSV commented Dec 10, 2024

Objective

The IntoFunction trait can convert a fair number of functions into DynamicFunction instances. However, it doesn't work for all functions. Due to limitations with how we're able to blanket implement the trait, it doesn't work for certain configurations. For example, it only allows for 15 or fewer arguments. Additionally, any reference return types need to be bound to the first argument's lifetime.

This makes it impossible to do stuff like this:

fn insert_at(index: usize, value: i32, list: &mut Vec<i32>) -> &i32 {
    list.insert(index, value);
    &list[index]
}

// This will fail to compile since `IntoFunction` expects return values
// to have a lifetime tied to the first argument, but this function
// returns a reference tied to the third argument.
let func = insert_at.into_function();

For these cases, users must instead define their DynamicFunction manually:

let func = DynamicFunction::new(
    |mut args| {
        let index = args.take::<usize>()?;
        let value = args.take::<i32>()?;
        let list = args.take::<&mut Vec<i32>>()?;

        let result = insert_at(index, value, list);

        Ok(result.into_return())
    },
    SignatureInfo::named("insert_at")
        .with_arg::<usize>("index")
        .with_arg::<i32>("value")
        .with_arg::<&mut Vec<i32>>("list")
        .with_return::<&i32>(),
);

This, however, is tedious and prone to errors. For example, there's nothing stopping a user from forgetting to specify the return type. It's also easy to forget to update the type of an argument in the SignatureInfo if the actual expected type ever changes, which can cause problems with overloaded functions.

Solution

This PR introduces the reflect_fn! macro.

With this macro, we can automatically and safely generate our DynamicFunctions much easier:

let func = reflect_fn!(
    fn insert_at(index: usize, value: i32, list: &mut Vec<i32>) -> &i32 {
        insert_at(index, value, list)
    }
);

The macro also allows for special configuration as well, such as:

  • Generating a DynamicFunctionMut
  • Generating anonymous SignatureInfo
  • Automatically using functions in scope
  • Custom function names:
    • Identifiers are automatically stringified
    • String literals allow defining names that would otherwise be invalid in Rust
    • Block expressions are evaluated, allowing for more complex naming scenarios
    • Expression paths return their full type name

Now, there will still be cases where DynamicFunction will need to be manually created, but with this macro hopefully those cases are much rarer.

Testing

You can test locally by running the doc tests:

cargo test --package bevy_reflect --doc --all-features

Showcase

A new reflect_fn macro allows for generating DynamicFunction and DynamicFunctionMut instances in a cleaner and more robust way.

We can take this code:

let func = DynamicFunction::new(
    |mut args| {
        let index = args.take::<usize>()?;
        let value = args.take::<i32>()?;
        let list = args.take::<&mut Vec<i32>>()?;

        let result = insert_at(index, value, list);

        Ok(result.into_return())
    },
    SignatureInfo::named("insert_at")
        .with_arg::<usize>("index")
        .with_arg::<i32>("value")
        .with_arg::<&mut Vec<i32>>("list")
        .with_return::<&i32>(),
);

and condense it down into this:

let func = reflect_fn!(
    fn insert_at(index: usize, value: i32, list: &mut Vec<i32>) -> &i32 {
        insert_at(index, value, list)
    }
);

And since insert_at is a real function that's already in our current scope, we can use it directly:

let func = reflect_fn!(
    fn [insert_at](index: usize, value: i32, list: &mut Vec<i32>) -> &i32
);

@MrGVSV MrGVSV added C-Feature A new feature, making something new possible A-Reflection Runtime information about types D-Straightforward Simple bug fixes and API improvements, docs, test and examples S-Needs-Review Needs reviewer attention (from anyone!) to move forward D-Macros Code that generates Rust code labels Dec 10, 2024
@MrGVSV MrGVSV requested a review from Copilot December 10, 2024 20:52
Copy link

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 1 out of 1 changed files in this pull request and generated no suggestions.

@SpecificProtagonist
Copy link
Contributor

This solution looks sensible. Was thinking about making this an attribute macro, but that's both impossible and wouldn't allow reflecting foreign functions.

Would it make sense to also allow an alternate syntax where one doesn't write a function body and the macro generates code to call a function of the provided name and arguments?

let func = reflect_fn!(
    fn insert_at(index: usize, value: i32, list: &mut Vec<i32>) -> &i32
);

@MrGVSV
Copy link
Member Author

MrGVSV commented Dec 12, 2024

Would it make sense to also allow an alternate syntax where one doesn't write a function body and the macro generates code to call a function of the provided name and arguments?

Ooh good idea! I'll try and add that!

@MrGVSV
Copy link
Member Author

MrGVSV commented Dec 13, 2024

I tried to also get arbitrary paths to work but it's not as easy in macro_rules land (though, I think proc macros would struggle in a similar way). I ran into two problems:

  1. macro_rules can only parse very specific kinds of paths, and those all allow for (, so we'd have to disambiguate with [] or something like fn [path::to::func](a: i32, b: i32) -> i32.
  2. macro_rules doesn't maintain the original whitespace, which means a function with a path like <i32 as Default>::default gets a name like "<i32asDefault>::default" which is really undesirable.

So I don't think I'll be able to get that working unfortunately.

@MrGVSV
Copy link
Member Author

MrGVSV commented Dec 13, 2024

So I don't think I'll be able to get that working unfortunately.

And to be clear, this isn't a blocker at all. Just an unfortunate limitation haha

@SpecificProtagonist
Copy link
Contributor

macro_rules can only parse very specific kinds of paths,

You could still do at least $($path:ident)::*, but only accepting unqualified paths should be fine.

@MrGVSV MrGVSV force-pushed the mrgvsv/reflect/reflect_fn branch from 64a585b to ef8a505 Compare December 13, 2024 05:08
@MrGVSV
Copy link
Member Author

MrGVSV commented Dec 13, 2024

macro_rules can only parse very specific kinds of paths,

You could still do at least $($path:ident)::*, but only accepting unqualified paths should be fine.

Yeah I thought about it but decided against it since if it's going to support paths, I'd want it to support generic paths (e.g. foo::bar::<i32>) as well as qualified self paths (i.e. <i32 as Default>::default).

But maybe that's too strict? Should we only allow for non-generic use-style paths? (i.e. foo::bar::baz)?

@MrGVSV
Copy link
Member Author

MrGVSV commented Dec 13, 2024

As pointed out by @laundmo on Discord, this may actually work if I instead use std::any::type_name_of_val to get the type name rather than stringify!.

So I'll give that a try as well!

@laundmo
Copy link
Contributor

laundmo commented Dec 13, 2024

if I instead use std::any::type_name_of_val

Oh, they added that utility to the stdlib, neat. i only knew about std::any:type_name and how easy it is to DIY type_name_of_val using that

@MrGVSV
Copy link
Member Author

MrGVSV commented Dec 13, 2024

Okay updated the PR to support full paths to functions in scope! This still relies on wrapping it in [], but I actually think that might be better since it makes it very clear that we're generating the name and possibly the functionality of the DynamicFunction based on a function in scope.

It also ensures that the macro code is more legible and easier to maintain since we don't have to do any complex parsing.

@MrGVSV MrGVSV force-pushed the mrgvsv/reflect/reflect_fn branch from f1dac90 to 2283b95 Compare December 13, 2024 19:34
@MrGVSV
Copy link
Member Author

MrGVSV commented Dec 13, 2024

Another thing we could consider is prepending the function ident (if not referencing a function in scope) with the module_path!() so it more closely resembles std::any::type_name (which is used internally by IntoFunction and friends).

let func = reflect_fn!(
    fn add(a: i32, b: i32) -> &i32 {
        a + b
    }
);

// From this:
// assert_eq!(func.name().unwrap(), "add");

// To this:
assert_eq!(func.name().unwrap(), "my_crate::math::add");

Alternatively, we could make that feature opt-in with a separate syntax like:

  • fn ~add()
  • fn ::add()
  • fn #add()
  • Etc.

Again, not a blocker for this PR, but something extra we could consider adding—either to this PR or in a followup.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-Reflection Runtime information about types C-Feature A new feature, making something new possible D-Macros Code that generates Rust code D-Straightforward Simple bug fixes and API improvements, docs, test and examples S-Needs-Review Needs reviewer attention (from anyone!) to move forward
Projects
Status: In Progress
Development

Successfully merging this pull request may close these issues.

3 participants