Fusion: `possiblyOutlives`, scopes and you [video + slides]

This video assumes intermediate knowledge with Fusion. Beginner friendly tutorials can be found in the official Fusion documentation. Below is a transcript with slides.


If you’re getting up to date on Fusion, you might have seen the new possiblyOutlives warning. At first, it might seem mystical, but in reality, it’s perfectly understandable, so in this video, I’ll show you why this warning exists, where it comes from, and how you can fix it.

Object oriented programming

Let’s start by talking about how most Luau libraries implement object oriented programming. To create an object, you call a constructor function. To destroy an object, you call it’s :destroy() method. The object starts living when you construct it, and stops living when you destroy it.

Now, let’s talk about how Fusion implements object oriented programming. As before, to create an object, you call a constructor function. However, to destroy an object, you don’t call a :destroy() method. Instead, you append the object to a list of objects you’ve made so far. When you’re done with this list of objects, you can use the doCleanup function, which will destroy everything in the list. These lists are called scopes, and let you destroy groups of objects all at once.

For your convenience, Fusion appends every newly created object for you. All you need to do is pass the scope into the constructor. This is why every constructor requires you to pass in a scope.

Destruction order

Now that we understand how Fusion generally deals with objects, let’s talk about destruction order.

Suppose I create a value object, and a computed object that depends on the value object. For this piece of code to work correctly at all times, the value object must ‘stay alive’ while the computed object is using it.

It can only be destroyed once the computed has been destroyed, because if the value object is destroyed while the computed object is still alive, then the computed object is depending on an object that no longer exists, which will raise a useAfterDestroy error, indicating that the required object is no longer available, because they were destroyed in the wrong order.

Because of this, we can say that the computed object must not outlive the value object. If the computed object is alive while the value object is dead, it will raise a useAfterDestroy error.

To speak more generally, you create newer objects using older objects: in this example, we create a computed using a value object we made earlier. For this reason, objects should be destroyed from newest to oldest, to ensure that the newer objects are destroyed, before the older objects that they depend on.

Destruction order

Now that we understand that, let’s talk about how scopes manage destruction order.

Because new objects are appended to the scope, we know that the oldest objects will appear at the start of the list, while the newest objects will appear at the end. This is why the doCleanup() function works backwards from the end of the list - doing that means it’s destroying the newest objects first. This is the most simple way in which Fusion prevents useAfterDestroy errors.

However, it’s still possible to use things in the wrong order, especially if your program is highly dynamic. Consider this example where we have two value objects inside of each other, and a computed object trying to access the innermost value. As written, the code is fine; both value objects are created before the computed object.

However, if we later create a new value object to replace the innermost value object, the computed object is now depending on a value object that’s newer, which is dangerous because it can raise a useAfterDestroy error.

This is where the possiblyOutlives message comes from. When one thing depends on another thing in Fusion, the library will look at the scopes to figure out the ordering of the objects. If it detects a case where an older object depends on a newer object, you will see the possiblyOutlives message. When you see this message, it means you’ve introduced one of these subtle ordering problems, where an object can depend on something that no longer exists, causing a useAfterDestroy error. So if you see this warning, you will need to change your code.

So how can we fix this problem? Generally, the solution is to make sure your objects appear in the right order. This can be as simple as swapping lines of code around. For example, if we moved this new value object before the computed, the error would go away, because the computed will always be destroyed before anything that it depends upon.

However, this isn’t always possible. If you have to create this new value object at a later point in time, then we’ll need to use some other features of scopes, to ensure that the destruction order is correct.

Inner scopes

A nice feature of scopes is that they can contain more than just objects. They can contain other things, like functions to run, instances to destroy, or event connections to disconnect. These are all very useful for when you’re writing custom destruction logic.

But of particular interest to us, scopes can contain other scopes. When the doCleanup() method encounters an ‘inner’ scope, it will start cleaning up that inner scope from the end. Once it’s done, it continues progressing through the ‘outer’ scope as usual.

This means, if we add objects to the inner scope, we can destroy objects at that point, rather than appending them to the end of the whole outer scope. We can use this fact to model the destruction ordering that we want.

So let’s return to the example with the nested value objects. To make this work, we’ll use the innerScope function, so that we can add objects at that point in the destruction order. Then, when we’re creating our innermost state objects, we’ll create them using that inner scope, which means they’re destroyed at that older point in time. However, we’ll continue to use the outer scope for the computed object and outermost value object, because those objects depend on the objects we’re creating inside of the inner scope, so we want to make sure the computed and outermost value are destroyed first.

With that, the code now works perfectly. No warnings are emitted, and the objects are destroyed in the correct order.

Addendum - dynamic destruction

Now, there’s something I’d like to note about this whole example. Given the way that we’ve written this code, it’s impossible to individually destroy our innermost value objects. They’ll only be destroyed once the program finishes running. This might be fine for your use case, but if you wanted to destroy the innermost value objects after they get replaced, then we’ll need to extend the code a little bit.

A nice feature of inner scopes is that you can clean them up at any time, and when you do, the inner scope removes itself from the outer scope. In this way, you can create temporary scopes, made exclusively to hold objects that only exist for a short period of time.

So let’s expand our code. I’m going to move the construction logic for the innermost value objects into a new function, and from now on, I’m going to use this function whenever I want to replace the innermost object.

We’re going to use our existing inner scope to permanently mark that point in time. We’ll store our temporary scopes inside of it, to make sure they’re destroyed in the right order if the entire outer scope is destroyed, just like before.

So to create our temporary scope, we call innerScope() on that existing permanent scope. In the temporary scope, we create our temporary value object. Instead of just storing the innermost value object, we’ll store both the value object and the temporary scope. Finally, just before overwriting the value, we’ll read out the previous temporary scope, and finish the function by destroying that previous scope just before we return.

With that, we have a complete implementation of a dynamic list of objects, that’s 100% absent of errors.

So hopefully, now you know a little bit more about those possiblyOutlives warning messages, and how you can use Fusion to guarantee that your code is memory safe.

6 Likes

I’m not a fan of Frameworks in fact I don’t use one at all and I also been a Software Engineer long enough to regret and hate using OOP

I have a lot of respect for you as you made a lot of the tools I use, (thank you for making them)

So with no intention to offend you, what was your motivation for this and what was your design process?

(I’m in no way offended by this because it doesn’t cater to me, that’s not the intention of my comment)

3 Likes

The earliest versions of Fusion depended on garbage collection to destroy objects for you automatically. However, it turned out that garbage collection was unpredictable, could cause spiky performance, and was hard to integrate with. So I wanted to look for a way of adapting manual memory management techniques (such as :destroy()) to work a little more ergonomically, while ensuring that it’s easy to predict how the program will behave just from looking at the structure of the source code. That’s where this concept of scopes was born from.

Scopes are somewhat inspired by the design of Maids, which let you ergonomically destroy groups of objects all at once. This is useful in Fusion because you often have a lot of things to destroy, and it can be easy to forget to destroy them. That inspired further changes such as having constructors add objects to the scope for you, meaning you can easily guarantee every object will be destroyed alongside some scope, meaning all you need to worry about is destroying scopes rather than individual objects, instances or event connections.

On top of this, listing objects in scopes like this means you can structure the way that destruction runs, to ensure that everything is destroyed in a well defined, correct order. Fusion can then analyse that order for you to double-check that your destruction order is error-free. That’s advantageous because it means you’re not waiting for disaster to know that your code isn’t correct - you get feedback immediately and unconditionally, which lets you iterate faster and deploy more confidently. In some sense, it’s code that tests itself for correct behaviour, reducing the burden on your manual testing.

1 Like

On top of this; to address your point about not liking OOP:

As with all other paradigms, objects are a tool. When used well, they will work fine. For example, Roblox’s Instances are all objects, but they work fine because the concept maps neatly to OOP. It’s understandable and makes sense - it’s the right tool to use.

Fusion’s own use of OOP is largely the same. Fusion could be written without any object-oriented patterns, but what you’d end up with would just be a less ergonomic version of functionally the same API surface. So in this case, OOP is used for the ergonomic benefit of the developer.

However, Fusion’s API surfaces tries not to mandate an object-oriented style from the developer who is using it. If you take a peek under the hood of any Studio Elttob projects using Fusion, you’ll see that I choose to use functions, not objects, as the basic unit of most code structure.

I’m a firm believer that functions and functional programming often makes more sense for expressing pure logic or pure reuse, and much of Fusion’s tooling is geared to be compatible across paradigms for that reason. (in fact, Fusion often uses a blend of procedural calling style and object-oriented calling style, for example how peek(object) is used for reading, while object:set() is used for writing - these are individual design decisions made to exploit each paradigm for its strengths rather than simply tolerating their weaknesses)

Some people do use OOP to structure their codebases - you have the flexibility to do so if you wish - but it’s far from the only way of doing things, and if anything, I personally don’t see the benefit of doing so, speaking as the person who designed Fusion’s API surface in the first place.

So it’s a ‘right tool for the right job’ type situation.

2 Likes

I see, thanks for taking the time to write, I appreciate it!

Personally my experience with OOP has been awful, with bugs, complex and complicated code so I try to stay away from it as much as possible especially with it being overrated and overused by the community

I like procedural programming and functional programming but most of all mixed paradigm is the best way for me, using whatever is right for the job and sometimes that could be OOP

thanks again, I really appreciate the thought process and I always enjoy discussions

1 Like