mirror of
https://github.com/bevyengine/bevy
synced 2024-12-23 19:43:07 +00:00
d21c7a1911
# Objective
Currently function reflection requires users to manually monomorphize
their generic functions. For example:
```rust
fn add<T: Add<Output=T>>(a: T, b: T) -> T {
a + b
}
// We have to specify the type of `T`:
let reflect_add = add::<i32>.into_function();
```
This PR doesn't aim to solve that problem—this is just a limitation in
Rust. However, it also means that reflected functions can only ever work
for a single monomorphization. If we wanted to support other types for
`T`, we'd have to create a separate function for each one:
```rust
let reflect_add_i32 = add::<i32>.into_function();
let reflect_add_u32 = add::<u32>.into_function();
let reflect_add_f32 = add::<f32>.into_function();
// ...
```
So in addition to requiring manual monomorphization, we also lose the
benefit of having a single function handle multiple argument types.
If a user wanted to create a small modding script that utilized function
reflection, they'd have to either:
- Store all sets of supported monomorphizations and require users to
call the correct one
- Write out some logic to find the correct function based on the given
arguments
While the first option would work, it wouldn't be very ergonomic. The
second option is better, but it adds additional complexity to the user's
logic—complexity that `bevy_reflect` could instead take on.
## Solution
Introduce [function
overloading](https://en.wikipedia.org/wiki/Function_overloading).
A `DynamicFunction` can now be overloaded with other `DynamicFunction`s.
We can rewrite the above code like so:
```rust
let reflect_add = add::<i32>
.into_function()
.with_overload(add::<u32>)
.with_overload(add::<f32>);
```
When invoked, the `DynamicFunction` will attempt to find a matching
overload for the given set of arguments.
And while I went into this PR only looking to improve generic function
reflection, I accidentally added support for variadic functions as well
(hence why I use the broader term "overload" over "generic").
```rust
// Supports 1 to 4 arguments
let multiply_all = (|a: i32| a)
.into_function()
.with_overload(|a: i32, b: i32| a * b)
.with_overload(|a: i32, b: i32, c: i32| a * b * c)
.with_overload(|a: i32, b: i32, c: i32, d: i32| a * b * c * d);
```
This is simply an added bonus to this particular implementation. ~~Full
variadic support (i.e. allowing for an indefinite number of arguments)
will be added in a later PR.~~ I actually decided to limit the maximum
number of arguments to 63 to supplement faster lookups, a reduced memory
footprint, and faster cloning.
### Alternatives & Rationale
I explored a few options for handling generic functions. This PR is the
one I feel the most confident in, but I feel I should mention the others
and why I ultimately didn't move forward with them.
#### Adding `GenericDynamicFunction`
**TL;DR:** Adding a distinct `GenericDynamicFunction` type unnecessarily
splits and complicates the API.
<details>
<summary>Details</summary>
My initial explorations involved a dedicated `GenericDynamicFunction` to
contain and handle the mappings.
This was initially started back when `DynamicFunction` was distinct from
`DynamicClosure`. My goal was to not prevent us from being able to
somehow make `DynamicFunction` implement `Copy`. But once we reverted
back to a single `DynamicFunction`, that became a non-issue.
But that aside, the real problem was that it created a split in the API.
If I'm using a third-party library that uses function reflection, I have
to know whether to request a `DynamicFunction` or a
`GenericDynamicFunction`. I might not even know ahead of time which one
I want. It might need to be determined at runtime.
And if I'm creating a library, I might want a type to contain both
`DynamicFunction` and `GenericDynamicFunction`. This might not be
possible if, for example, I need to store the function in a `HashMap`.
The other concern is with `IntoFunction`. Right now `DynamicFunction`
trivially implements `IntoFunction` since it can just return itself. But
what should `GenericDynamicFunction` do? It could return itself wrapped
into a `DynamicFunction`, but then the API for `DynamicFunction` would
have to account for this. So then what was the point of having a
separate `GenericDynamicFunction` anyways?
And even apart from `IntoFunction`, there's nothing stopping someone
from manually creating a generic `DynamicFunction` through lying about
its `FunctionInfo` and wrapping a `GenericDynamicFunction`.
That being said, this is probably the "best" alternative if we added a
`Function` trait and stored functions as `Box<dyn Function>`.
However, I'm not convinced we gain much from this. Sure, we could keep
the API for `DynamicFunction` the same, but consumers of `Function` will
need to account for `GenericDynamicFunction` regardless (e.g. handling
multiple `FunctionInfo`, a ranged argument count, etc.). And for all
cases, except where using `DynamicFunction` directly, you end up
treating them all like `GenericDynamicFunction`.
Right now, if we did go with `GenericDynamicFunction`, the only major
benefit we'd gain would be saving 24 bytes. If memory ever does become
an issue here, we could swap over. But I think for the time being it's
better for us to pursue a clearer mental model and end-user ergonomics
through unification.
</details>
##### Using the `FunctionRegistry`
**TL;DR:** Having overloads only exist in the `FunctionRegistry`
unnecessarily splits and complicates the API.
<details>
<summary>Details</summary>
Another idea was to store the overloads in the `FunctionRegistry`. Users
would then just call functions directly through the registry (i.e.
`registry.call("my_func", my_args)`).
I didn't go with this option because of how it specifically relies on
the functions being registered. You'd not only always need access to the
registry, but you'd need to ensure that the functions you want to call
are even registered.
It also means you can't just store a generic `DynamicFunction` on a
type. Instead, you'll need to store the function's name and use that to
look up the function in the registry—even if it's only ever used by that
type.
Doing so also removes all the benefits of `DynamicFunction`, such as the
ability to pass it to functions accepting `IntoFunction`, modify it if
needed, and so on.
Like `GenericDynamicFunction` this introduces a split in the ecosystem:
you either store `DynamicFunction`, store a string to look up the
function, or force `DynamicFunction` to wrap your generic function
anyways. Or worse yet: have `DynamicFunction` wrap the lookup function
using `FunctionRegistryArc`.
</details>
#### Generic `ArgInfo`
**TL;DR:** Allowing `ArgInfo` and `ReturnInfo` to store the generic
information introduces a footgun when interpreting `FunctionInfo`.
<details>
<summary>Details</summary>
Regardless of how we represent a generic function, one thing is clear:
we need to be able to represent the information for such a function.
This PR does so by introducing a `FunctionInfoType` enum to wrap one or
more `FunctionInfo` values.
Originally, I didn't do this. I had `ArgInfo` and `ReturnInfo` allow for
generic types. This allowed us to have a single `FunctionInfo` to
represent our function, but then I realized that it actually lies about
our function.
If we have two `ArgInfo` that both allow for either `i32` or `u32`, what
does this tell us about our function? It turns out: nothing! We can't
know whether our function takes `(i32, i32)`, `(u32, u32)`, `(i32,
u32)`, or `(u32, i32)`.
It therefore makes more sense to just represent a function with multiple
`FunctionInfo` since that's really what it's made up of.
</details>
#### Flatten `FunctionInfo`
**TL;DR:** Flattening removes additional per-overload information some
users may desire and prevents us from adding more information in the
future.
<details>
<summary>Details</summary>
Why don't we just flatten multiple `FunctionInfo` into just one that can
contain multiple signatures?
This is something we could do, but I decided against it for a few
reasons:
- The only thing we'd be able to get rid of for each signature would be
the `name`. While not enough to not do it, it doesn't really suggest we
*have* to either.
- Some consumers may want access to the names of the functions that make
up the overloaded function. For example, to track a bug where an
undesirable function is being added as an overload. Or to more easily
locate the original function of an overload.
- We may eventually allow for more information to be stored on
`FunctionInfo`. For example, we may allow for documentation to be stored
like we do for `TypeInfo`. Consumers of this documentation may want
access to the documentation of each overload as they may provide
documentation specific to that overload.
</details>
## Testing
This PR adds lots of tests and benchmarks, and also adds to the example.
To run the tests:
```
cargo test --package bevy_reflect --all-features
```
To run the benchmarks:
```
cargo bench --bench reflect_function --all-features
```
To run the example:
```
cargo run --package bevy --example function_reflection --all-features
```
### Benchmarks
One of my goals with this PR was to leave the typical case of
non-overloaded functions largely unaffected by the changes introduced in
this PR. ~~And while the static size of `DynamicFunction` has increased
by 17% (from 136 to 160 bytes), the performance has generally stayed the
same~~ The static size of `DynamicFunction` has decreased from 136 to
112 bytes, while calling performance has generally stayed the same:
| | `main` | 7d293ab |
|
||
---|---|---|
.. | ||
custom_attributes.rs | ||
dynamic_types.rs | ||
function_reflection.rs | ||
generic_reflection.rs | ||
reflection.rs | ||
reflection_types.rs | ||
type_data.rs |