-
-
Notifications
You must be signed in to change notification settings - Fork 3.6k
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
bevy_reflect: Function Overloading (Generic & Variadic Functions) #15074
base: main
Are you sure you want to change the base?
bevy_reflect: Function Overloading (Generic & Variadic Functions) #15074
Conversation
This will be a foundational piece to enabling overloaded functions.
Allows for manually defining basic generic function reflection
Function reflection doesn't really support dynamic types currently so it makes more sense to start stricter with Reflect. This will make it easier to ensure all arguments have valid types for generic function reflection.
Since ArgumentSignature is just a wrapper around Box<[Type]>, it should already contain a "high-quality hash"
Turns out that NoOpHash works by only using the last u64 hash, making collisions very likely when used with ArgumentSignature
Mainly usage of HashMap::insert_unique_unchecked
Used to help reduce code duplication for overloaded functions and to give users the option to pretty-print FunctionInfo
Added the `simple_` qualifier
7d293ab
to
0ecda65
Compare
My first thought while reading the showcase code: let reflect_add = add::<i32>
.into_function()
.with_overload(add::<u32>)
.with_overload(add::<f32>); was that it’s a little strange to have the More generally, does the order of |
Yeah that makes sense. Unfortunately we need We could potentially solve this by moving the overload creation to a builder that can validate that at least one function is given. Would that be a better/clearer way of doing this? Something like: let reflect_add = DynamicFunctionBuilder::new()
.with(add::<i32>)
.with(add::<u32>)
.with(add::<f32>)
.unwrap();
Great question! No the order does not matter: the correct overload is chosen based on the arguments given to the function when calling it (and returns an error if none was found). |
So my initial thought was essentially
I'm sorry for just asking instead of testing it myself (I'll check out the code and play with it later I promise), but what happens if I overload two conflicting functions? Is it a static error? |
|
I Think I like the builder the most. But that depends: Do we want to support adding overloads at runtime? I think this is something useful to have for modding, so maybe this is the better way. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Really interesting change and very well documented!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a great API for Reflected functions that will greatly improve this feature. LGTM
@MrGVSV I'm down to merge this, but this needs merge conflict resolution :) |
Objective
Currently function reflection requires users to manually monomorphize their generic functions. For example:
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: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:
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.
A
DynamicFunction
can now be overloaded with otherDynamicFunction
s. We can rewrite the above code like so: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").
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.
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
My initial explorations involved a dedicated
GenericDynamicFunction
to contain and handle the mappings.This was initially started back when
DynamicFunction
was distinct fromDynamicClosure
. My goal was to not prevent us from being able to somehow makeDynamicFunction
implementCopy
. But once we reverted back to a singleDynamicFunction
, 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 aGenericDynamicFunction
. 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
andGenericDynamicFunction
. This might not be possible if, for example, I need to store the function in aHashMap
.The other concern is with
IntoFunction
. Right nowDynamicFunction
trivially implementsIntoFunction
since it can just return itself. But what shouldGenericDynamicFunction
do? It could return itself wrapped into aDynamicFunction
, but then the API forDynamicFunction
would have to account for this. So then what was the point of having a separateGenericDynamicFunction
anyways?And even apart from
IntoFunction
, there's nothing stopping someone from manually creating a genericDynamicFunction
through lying about itsFunctionInfo
and wrapping aGenericDynamicFunction
.That being said, this is probably the "best" alternative if we added a
Function
trait and stored functions asBox<dyn Function>
.However, I'm not convinced we gain much from this. Sure, we could keep the API for
DynamicFunction
the same, but consumers ofFunction
will need to account forGenericDynamicFunction
regardless (e.g. handling multipleFunctionInfo
, a ranged argument count, etc.). And for all cases, except where usingDynamicFunction
directly, you end up treating them all likeGenericDynamicFunction
.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.Using the
FunctionRegistry
TL;DR: Having overloads only exist in the
FunctionRegistry
unnecessarily splits and complicates the API.Details
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 acceptingIntoFunction
, modify it if needed, and so on.Like
GenericDynamicFunction
this introduces a split in the ecosystem: you either storeDynamicFunction
, store a string to look up the function, or forceDynamicFunction
to wrap your generic function anyways. Or worse yet: haveDynamicFunction
wrap the lookup function usingFunctionRegistryArc
.Generic
ArgInfo
TL;DR: Allowing
ArgInfo
andReturnInfo
to store the generic information introduces a footgun when interpretingFunctionInfo
.Details
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 moreFunctionInfo
values.Originally, I didn't do this. I had
ArgInfo
andReturnInfo
allow for generic types. This allowed us to have a singleFunctionInfo
to represent our function, but then I realized that it actually lies about our function.If we have two
ArgInfo
that both allow for eitheri32
oru32
, 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.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
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:
name
. While not enough to not do it, it doesn't really suggest we have to either.FunctionInfo
. For example, we may allow for documentation to be stored like we do forTypeInfo
. Consumers of this documentation may want access to the documentation of each overload as they may provide documentation specific to that overload.Testing
This PR adds lots of tests and benchmarks, and also adds to the example.
To run the tests:
To run the benchmarks:
To run the example:
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:main
into/function
with_overload/01_simple_overload
with_overload/01_complex_overload
with_overload/10_simple_overload
with_overload/10_complex_overload
call/function
call/01_simple_overload
call/01_complex_overload
call/10_simple_overload
call/10_complex_overload
For the overloaded function tests, the leading number indicates how many overloads there are:
01
indicates 1 overload,10
indicates 10 overloads. Thecomplex
cases have 10 unique generic types and 10 arguments, compared to thesimple
1 generic type and 2 arguments.I aimed to prioritize the performance of calling the functions over creating them, hence creation speed tends to be a bit slower.
There may be other optimizations we can look into but that's probably best saved for a future PR.
The important bit is that the standard
into/function
andcall/function
benchmarks show minimal regressions.Showcase
Function reflection now supports function overloading! This can be used to simulate generic functions:
You can also simulate variadic functions: