Closed timsgardner closed 7 years ago
On the other hand, why stop there? If the whole purpose of this is to provide access to state, maybe we should just pass the state in directly every time. We could make that really fast (as fast as indexing into an array).
Then the user could put any compound data to represent the state in something like a Record, accessing the data of which is just a field lookup. Now access to state costs an array jump and a field lookup, two of the fastest things possible.
We'd have to come up with some way of updating that state in the containing object from inside the hook callback though. Perhaps the return value of the hook?
Also should all our hooks on a given GameObject and update cycle be working against a snapshot of state and queuing updates?
thoughts on this proposal:
The hook keys/map has become a store of state in itself, so there is now two approaches to controlling the logic of game objects.
The "traditional" control of attaching message entry points into clojure systems
(defn behave [o]
(if (state o :patrolling)
:patrol
:retreat))
(hook+ player :update :behaviour #'behave)
And changing hook configuratoins at runtime
(defn patrol [o k]
(when done
(hook- player :update k)
(hook+ player :update k #'retreat)))
(hook+ player :update :behaviour #'patrol)
This does mirror design patterns for unity's components, where it's possible to change behavior by checking component properties or by deactivating/removing components.
This proposal improves options for the second pattern when keys are not known (i.e. for fns in a third party lib), or when a fn is to be used multiple times.
Personally, I believe any control system could be implemented via the first pattern, and that the reconfiguration of hooks in general is a complication. I also wonder how new users will percieve the API, now that hook keys have become a visible factor on both sides.
I do recommend the adoption of this proposal.
I do not recommend further design changes that would tie hook keys to ArcadiaState
(i.e. passing a substate to hook fns based on their key)
The advantage to passing the state based on key is that it seems likely we could make it quite fast (much faster than a map lookup), and performance is very important in this context, to the point that it could greatly change the kind of programs people write.
Another question arises of whether we should continue to consider ArcadiaState an atom holding immutable values, or whether, given Unity's stringent restrictions on concurrent access to the scene graph; the overwhelming pattern of synchronous changes to state via Unity's event system; and the problems posed by a representation of change that involves a lot of allocation and garbage production, we wouldn't be better served by a hidden, mutable datastructure, only accessible from the main thread, at least for now?
Then people wouldn't build systems assuming an atom holding a map, for instance, and limit our ability to come up with a more nuanced or performant policy in the future. In other words, should we consider our concrete representation of state a private implementation detail for now?
To further motivate doing so, here's an optimization possible if we do not expose state as a public atom and assume user interactions with it will occur on the main thread (same as any other component field). Say we decide we do want to pass hook functions their keys. If we maintain arcadia state as an array that we iterate through along with the hook functions, with each step corresponding to a key, we can optimize for the user calling state
, set-state!
, or update-state!
using that key for the duration of the hook call. If the user does call state
, set-state!
, or update-state!
during the hook call, before going to a hashmap lookup we can just check whether the key being passed into those functions ReferenceEquals
the current key of the hook, and, if so, jump directly to its corresponding state (ReferenceEquals
is fast: < 10ns, if my measurements are correct). If it doesn't, we'd fallback to the usual hashmap lookup.
This would make both reads and writes (but especially reads, writes involving immutable data are going to be slower anyway) much faster. Aside from that, the best the user could do is somehow put a custom datastructure such as a record into the state atom as the topmost container, with optimized lookup for a few statically-specified fields. I guess this is ok, but not great, since it's a less dynamic optimization of state and behavior: the user has to know ahead of time which fields they want to make fast. This restriction also limits the ability to compose libraries, since only one will get to determine the data structure that goes into topmost state. That kind of optimization makes much more sense one level down, for data held at a given key: different libraries and concerns can user their own optimized (and potentially mutable) data structures and strategies there without tripping over each other.
The downside, again, is that this optimization would either have to assume synchronous, main-thread access, or use something like a dynamic var - which I think are not the fastest things (need to benchmark). Assuming main-thread access isn't so strange: at the moment state
, set-state!
, etc only work on the main thread anyway, since they look up the state component of the GameObject. One could imagine a way to make these work off-thread as well, though, say via a global registry of state atoms. If dynamic vars are fast enough they would offer a cleaner solution.
In either case, we get more freedom to optimize if we don't expose the state atom as such right off the bat.
Hrm, the snapshot case is made problematic in a concurent context because these hook functions are going to be side-effecting. So if we view a message cycle as something semantically analogous to
(let [callbacks @update-hook-callbacks]
(letfn [(message-cycle [state-snapshot]
(reduce-kv
(fn [next-state k callback]
(callback game-object k next-state)) ;; or whatever
state-snapshot
callbacks))]
(swap! state-atom message-cycle)))
we have to be prepared to rerun all the callbacks if the atom has changed state during the run of the swap!
. Since we're going to be side-effecting the scene graph, that's not feasible.
The pass-keys branch is the solution we've settled on. Will merge into develop.
Hook callback functions should be passed their keys.
Experimental implementation: https://github.com/arcadia-unity/Arcadia/tree/pass-keys
Summary of advantages:
Summary of disadvantages:
_
) is sort of annoying for hook functions that don't interact with hooks or state.State is meaningless without behavior (small 'b'). If we put something into ArcadiaState, we usually expect to use it in some hook on the same object. The proposed change provides a clear, clean mechanism for hooks to access the state they need.
Large programs full of maps holding anonymous functions are a huge headache. You can't introspect the data closed over by the anonymous function, so you can only guess at the behavior of the function by running the particular instance and seeing what happens - especially painful given that, in this context, the function is almost certainly impure. If there were some way of serializing anonymous functions (via a heap dump, for example), there could easily be no codepath in the current codebase that generated the function instance, making the task of untangling its provenance impossible. This would be less problematic if the data closed over by the function were themselves inspectable, but then I can't think of a graceful way of sharing data between related functions that doesn't introduce reference spaghetti, converge on classical OOP, or recreate the currently proposed system. And in any event we can't serialize anonymous functions.
Serialization is vitally important. It means we can use the editor, save levels, work with prefabs. Generating everything at playtime is very powerful, but:
On
pass-keys
, if we just use vars for hooks and express all other parameters via keyed state, serialization of GameObjects with Arcadia hooks and state, including prefabs, works great, right now, with no further mechanisms needed.