-
-
Notifications
You must be signed in to change notification settings - Fork 103
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
Should peek() deep copy by default? #78
Comments
One other consequence of this approach is that we would also retain the same redundancy problems we currently have with |
An interesting gotcha here - while reference-type tables are not useful for dealing with most things, they are useful when dealing with some things like objects where reference-style equality is useful. Consider, for example, storing a Value inside a Value; in this case, it makes sense that you get back the same Value you put in: local subValue = Value()
local superValue = Value(subValue)
print(superValue:get() == subValue) -- true - makes sense! This is a useful case to support, because you can use it to dynamically switch out dependencies and values: -- healths are Values because they can change at any time
local player1Health = Value(100)
local player2Health = Value(50)
local player3Health = Value(50)
-- we want to select one player's health to show
local selectedHealth = Value(player1Health)
-- process the selected player's health into a message
local message = Computed(function()
local healthObject = selectedHealth:get()
return "This player has " .. healthObject:get() .. " HP."
end) This is a specific example of a general truth - if we are going to implement some kind of deep clone, we will need to support both objects and tables in the same data structures. Consider this data structure - if we were to deep copy this, we want to keep the objects intact, but the tables containing them should be cloned: local myData = {
player1 = {
health = Value(100),
armour = {head = Value(100), torso = Value(100), legs = Value(100), feet = Value(100)}
},
player2 = {
health = Value(100),
armour = {head = Value(100), torso = Value(100), legs = Value(100), feet = Value(100)}
}
} Something that this has led me to realise is that 'share-ability' is a property of tables - we will need some way of marking a table as 'cloneable' or 'shareable'. I propose that we should make 'cloneable' the default so that tables have value-like semantics by default and have the most intuitive behaviour, with the more performant 'shareable' behaviour being opt-in. This rules out using So far, I see a few ways of marking a table as 'shareable':
In my mind, the metatable option makes most sense - after all, we want to add metadata to a table, not data. Metatables are useful because they describe a table without requiring modifying their contents. It seems like the most logical place to put a field which influences the behaviour of the table, even if it's a custom field. (if we do this though, we definitely should not use double underscores, since this is meant to be reserved for Luau fields) To help out the DX, we could expose a utility function for making a table shareable: local function shareable(t)
getmetatable(t).shareable = true
return t
end
local willBeCopied = {1, 2, 3, 4, 5}
local willBeShared = shareable {1, 2, 3, 4, 5} I'd definitely like further thoughts on this. I have approximately zero idea what I'm doing here lol |
To be clear - the goal here is making table data act more like a value-type, while not breaking objects. |
I think that this is a good idea, but we'll need to test it out in the real world to evaluate things like the performance impact of doing this for most tables by default. |
Here's a crazy and bad idea, but maybe worth thinking about: |
I see you are a Swift user ;) Jokes aside, copy-on-edit is certainly something you could do, but I don't think that's a very elegant solution to the problem. I think we could maybe get away with a shallow copy. |
@Elttob I have a fairly big proposal here to solve this issue, I tried to remain mostly faithful to existing Fusion features, and still supporting all use cases (but without too much hassle). I'd actually somewhat prefer the current behaviour, despite the footgun in the example. The reason being that it supports all use cases out of the box, with minimal changes. From the referenced issue #44, if I want to implement the copy behaviour as an end user it's easy to do: local array = {1, 2, 3, 4, 5}
local state = State(array)
table.insert(array, 6)
state:set(table.clone(array)) However, I believe mutations sort of go against the nature of Fusion anyways. Perhaps it would be better to provide the user with a way to mutate data in a Fusion-managed form, with the behaviour being an extra. ProposalI would propose a state wrapper similar to Tween, Spring, etc which is used for states that control mutable data: local array = {1, 2, 3, 4, 5}
local immutableState = Value(array) -- Naming aside, immutableState's value is still mutable (currently*)
local mutableState = Mutable(immutableState) -- Wraps immutableState, which maintains a mutable copy of the underlying data.
local mutableArray = mutableState:get() -- We grab our table
table.insert(mutableArray, 6) -- Make some changes
mutableState:set(mutableArray) -- Update the mutable version of the array Behaviour
This maintains the ability for the user to do arbitrary mutations, but enforces Fusion's control over said mutations. This means the user code stays happy, and it solves the original footgun in a way that's consistent with the existing Fusion patterns. Final justificationsI believe the ability to perform mutations is useful, even if it is a bad pattern. Similar to Hydrate, it allows bridging "bad" (for lack of a better word) code with "good" Fusion code that follows best practices by offering a compromise between the two. In contrast to some other solutions, old code can be easily adapted like so and remain fully compatible: -- Old
local myTable = Value({1, 2, 3})
myTable:get()[3] = 4
myTable:set(myTable:get(), true)
-- New
local myTable = Mutable(Value({1, 2, 3}))
myTable:get()[3] = 4
myTable:set(myTable:get()) The only exceptions here are that |
@Hexcede you make some valuable points about preserving existing use cases here. We should definitely look into some way of ensuring both immutable and mutable use cases are supported pretty optimally. Thanks for the detailed post! Using an intermediate object is an interesting approach but the problem is that there is no standardised API for setting values on state objects, because different state objects derive their values from different sources and methodologies by design. I think perhaps it'd be ultimately simpler to implement and simpler to extend if state objects provided their own routines for handling both paths. We could possibly look at splitting Value's set method into two separate methods that each optimise for immutable and mutable sets respectively. We would need to name them according to verbs that intuitively represent the kind of use cases they should be used for so that newer users can grasp the concept. I think that this is probably a good approach, though I'll need a little more time to become confident in that conviction. |
This might be redundant due to #216 being written. |
Here's an interesting proposal; in the spirit of #291, we could conceivably use a table's frozen status to decide whether it should be cloned or not. |
This is now implemented, by the way. |
We will probably not do this. Changing the behaviour of peek in this way would likely be more confusing, and we have better handling of mutation these days. |
This is proposed as an alternate solution to the problems set out in #44.
It may be more straightforward to make :get() (on all state objects) perform a deep copy if the state object is holding a table value.
This would achieve the same results that
force = true
achieves now, except without the possibility of mutating the state object's current value.For cases where the exact reference is important, we could also introduce an option to return the raw (uncloned) value.
Considerations:
I think that in order to make the right call here we just have to test these two approaches out with real code - both seem like valid approaches for different and often incomparable reasons. It's worth noting however that this approach is not a complete solution to the problem, but rather a 'good-enough' approach that allows users to get the results they probably wanted.
The text was updated successfully, but these errors were encountered: