-
-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Portable Callback objects #10582
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
Comments
Running systems manually was recently merged. Wouldn't your |
Yeah, that would probably work. |
@UkoeHB One problem with using registered systems is ownership and dropping. A closure used as a one-shot system can be dropped at any point, and can be wrapped in an Arc. A registered system can only be destructed with a reference to World, and you can't keep a World reference around because of lifetimes. This means that a manually-run system has to have an "owner" that is responsible for de-registering it from the world. This defeats the purpose of Arc, which is to have a more flexible, distributed ownership. In web apps, callbacks are often threaded through multiple levels of UI hierarchy, both up and down the visual hierarchy as well as connecting to and from asynchronous data stores. This means that there is no clear ownership - something easy to do in a GC language. Part of what I am looking for here, is the decoupling of UI widgets: being able to build widgets that can be composed and re-used in different contexts. In the current architecture, this is already true on the rendering side - you can build a sub-tree of child entities and attach them to a parent entity without the parent having any special knowledge about the nature of its children or vice versa. But that's the easy part - once you start defining the command-and-control aspects, where signals and/or events are being transmitted back and forth between entities, that loose coupling goes away, and widgets now need to have intimate knowledge of the lifetimes of the widgets they are connected to, and the lifetimes of the data being passed back and forth. This results in an insurmountable reduction in the degree of modularity and reusability. |
Yep this is a problem. My solution, which I am right now in the middle of implementing, is to add a custom entity garbage collector.
The inconvenience here is you need to the |
I have implemented Advantages of not using
Advantages of using and internal
Advantages of using
|
Closing in favor of the observer pattern in #10839 as our "reactivity" solution. We can revisit patterns here in the future as we see how those play out. |
What problem does this solve or what need does it fill?
Building a hierarchical UI is much easier with callbacks. Having to poll the state of each individual widget makes it difficult to create components that are truly modular.
For example, suppose you have something like an audio preferences dialog with a volume slider. The slider doesn't know anything about audio, it's just a generic slider. It's possible to build such a dialog by polling the slider state directly, but that requires injecting the internal state of the slider into the parent dialog, breaking encapsulation. This gets even more difficult if the dialog itself is a generic widget, which means it can no longer depend on dependency injection, but must receive context from its own caller. If the slider can accept a callback parameter to notify its parent whenever the slider value changes, however, then its relatively straightforward to modify the state of the world in response to the call.
In older UI frameworks (JavaSwing, Gtk) this was done with events rather than callbacks. The problem with events is that the parent has to examine each event and decide which widget it game from. Modern frameworks like React/Solid/Svelte are organized around passing callbacks to each child widget, which produces code that is simpler and more modular. It has a better architectural separation of concerns.
However, passing closures around in Rust is problematic for a number of reasons. First, there is the problem of closure variable lifetimes - a button that has a
.click()
method is probably going to live longer than the setup function that creates it. (Move semantics can help, but often you will have several closures that all want to capture the same variable).Second, in a complex UI, callbacks are often passed down through multiple layers of the widget hierarchy, which means that every widget now has to be generic on the type of the closures being passed through it.
Third, in a reactive framework, callbacks are often used as dependencies to other derivations, such as "computed" or "effects". But this leads to a lot of extra churn unless the callbacks are memoized. Memoization requires both equality-comparison and a way to copy the value, neither of which are supported by bare closures.
Finally, it would be nice for callbacks to participate in dependency injection the way that systems do. Dependency injection can, in some cases, substitute for closure variables in a callback. For example, in the case of the audio preferences dialog, the callback which is attached to the volume slider could get a handle to the audio volume resource by capturing it from the parent, but alternatively it could inject it directly with something like
Res<AudioSettings>
.What solution would you like?
I propose some trait,
Callback<In>
which represents a generic callback that accepts some parameter, much like a one-shot system. However,Callback
is actually a wrapper which gives us a number of advantages over one-shot systems:Internally,
Callbacks
might be implemented as one-shot systems, or they might be implemented some other way. The actual closure is wrapped in anArc
, allowing the wrapper to be cloned without duplicating the closure.The equals comparison can be very simple, just a pointer comparison: we don't actually care if two callbacks have the same closure values, all we care about is whether the callback was constructed via a distinct call. Two separate calls to
create_callback
should always compare as unequal.Callback
objects can be passed around freely as parameters to systems, widgets, event handlers and so on. For example:The Callback trait has one method,
.call(world, args)
. Yes, callbacks run exclusively like one-shot systems, but so do most event handlers (look at bevy_mod_picking). There's a discussion around this somewhere, but I generally believe that it's OK to have low-frequency "command and control" code run in an exclusive system, so long as the high-frequency code, the stuff that's CPU intensive, is non-exclusive.Memoization is outside of the scope of this proposal, as it would be handled by the third-party UI framework (or any other framework using callbacks). A framework can provide a
create_callback_memo(func, deps)
, for example, which always returns the same object as long asdeps
is the same every time. Other APIs are possible, but it's up to the framework to decide how that should work.Callbacks are meant to be synchronous - there's no delay between the time the callback is sent and the time it's received. For asynchronous communication, use other methods.
What alternative(s) have you considered?
A bunch, too many to list here.
If we jettison the idea that
Callback
supports dependency injection, then the implementation ofCallback
is simpler since it no longer requires aWorld
to call it. However, the downside is that you now have to rely much more heavily on capturing closure variables. Dependency injection can substitute for variable capture in some cases (where the value being captured is also available as an injection) but not others (such as when the value being captured is locally scoped). Unfortunately, closure captures introduce a lot of complexity around lifetime bounds, which the person defining the callback will have to deal with.Additional context
This idea is part of a general research project to see how well we can adapt ideas of reactivity in an ECS world. One of the tensions is that UIs are inherently hierarchical, not just in structure but in execution scope, and ECS architectures tend to atomize hierarchies and flatten everything. The idea of callbacks is to try and bridge those two paradigms.
Calling a
Callback
requires aWorld
, because otherwise dependency injection doesn't work. (You can't store aWorld
inside the callback object because of lifetime issues). My assumption is that most uses of callbacks will have a world available at the point where it's called, such as an event handler. For callbacks which call other callbacks (a fairly common case in UI code, often widgets elevate the level of abstraction when forwarding events), the callback will need to inject a World.I know that a lot of folks will object to the fact that
Callbacks
only make sense in an exclusive context. However, many kinds of "command and control" logic only make sense in an exclusive context. UI hierarchies are often quite deep, with multiple layers of widgets, and it's not uncommon in UI code for a message originating at the bottom of the stack to proceed upward in multiple "hops", with each hop transforming the message to a higher-level, more abstract form. It would be unfortunate if each hop incurred a one-frame delay.The text was updated successfully, but these errors were encountered: