From fb7cddadaadee46be9334a15e0f35fe1b9c1aebb Mon Sep 17 00:00:00 2001 From: Daniel P H Fox Date: Thu, 18 Apr 2024 15:30:49 +0100 Subject: [PATCH] innerScope() --- docs/api-reference/index.md | 4 +- .../memory/members/derivescope.md | 5 +- .../memory/members/innerscope.md | 64 ++++++++ docs/tutorials/fundamentals/scopes.md | 143 ++++++++++++++---- src/Memory/innerScope.luau | 31 ++++ src/init.luau | 1 + 6 files changed, 214 insertions(+), 34 deletions(-) create mode 100644 docs/api-reference/memory/members/innerscope.md create mode 100644 src/Memory/innerScope.luau diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index d58f7ab19..c93083a01 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -34,9 +34,9 @@ For a beginner-friendly experience, [try the tutorials.](../tutorials/) Scope :octicons-chevron-right-24: - + :octicons-workflow-24: - deriveScope + innerScope :octicons-chevron-right-24: diff --git a/docs/api-reference/memory/members/derivescope.md b/docs/api-reference/memory/members/derivescope.md index 3fd25b52b..a4ee184b5 100644 --- a/docs/api-reference/memory/members/derivescope.md +++ b/docs/api-reference/memory/members/derivescope.md @@ -21,6 +21,9 @@ function Fusion.deriveScope( Returns a blank [scope](../../types/scope) with the same methods as an existing scope. +Unlike [innerScope](../derivescope), the returned scope has a completely +independent lifecycle from the original scope. + !!! warning "Scopes are not unique" Fusion can recycle old unused scopes. This helps make scopes more lightweight, but it also means they don't uniquely belong to any part of @@ -51,7 +54,7 @@ An existing scope, whose methods should be re-used for the new scope. -A blank scope with the same methods as the existing scope. +A blank (non-inner) scope with the same methods as the existing scope. ----- diff --git a/docs/api-reference/memory/members/innerscope.md b/docs/api-reference/memory/members/innerscope.md new file mode 100644 index 000000000..ca5abec6a --- /dev/null +++ b/docs/api-reference/memory/members/innerscope.md @@ -0,0 +1,64 @@ + + +

+ :octicons-workflow-24: + innerScope + + -> Scope<T> + +

+ +```Lua +function Fusion.innerScope( + existing: Scope +): Scope +``` + +Returns a blank [scope](../../types/scope) with the same methods as an existing +scope. + +Unlike [deriveScope](../derivescope), the returned scope is an inner scope of +the original scope. It exists until either the user calls `doCleanup` on it, or +the original scope is cleaned up. + +!!! warning "Scopes are not unique" + Fusion can recycle old unused scopes. This helps make scopes more + lightweight, but it also means they don't uniquely belong to any part of + your program. + + As a result, you shouldn't hold on to scopes after they've been cleaned up, + and you shouldn't use them as unique identifiers anywhere. + +----- + +## Parameters + +

+ existing + + : Scope<T> + +

+ +An existing scope, whose methods should be re-used for the new scope. + +----- + +

+ Returns + + -> Scope<T> + +

+ +A blank inner scope with the same methods as the existing scope. + +----- + +## Learn More + +- [Scopes tutorial](../../../../tutorials/fundamentals/scopes) \ No newline at end of file diff --git a/docs/tutorials/fundamentals/scopes.md b/docs/tutorials/fundamentals/scopes.md index 100f14f58..732e9dfe0 100644 --- a/docs/tutorials/fundamentals/scopes.md +++ b/docs/tutorials/fundamentals/scopes.md @@ -60,16 +60,15 @@ print(scope[3] == thing3) --> true Later, destroy the scope by using the `doCleanup()` function. The contents are destroyed in reverse order. -```Lua linenums="2" hl_lines="2 9" +```Lua linenums="2" hl_lines="8" local Fusion = require(ReplicatedStorage.Fusion) -local doCleanup = Fusion.doCleanup local scope = {} local thing1 = Fusion.Value(scope, "i am thing 1") local thing2 = Fusion.Value(scope, "i am thing 2") local thing3 = Fusion.Value(scope, "i am thing 3") -doCleanup(scope) +Fusion.doCleanup(scope) -- Using `doCleanup` is the same as: -- thing3:destroy() -- thing2:destroy() @@ -104,14 +103,14 @@ You can call `scoped()` to obtain a new scope. ```Lua linenums="2" hl_lines="2 4" local Fusion = require(ReplicatedStorage.Fusion) -local doCleanup, scoped = Fusion.doCleanup, Fusion.scoped +local scoped = Fusion.scoped local scope = scoped() local thing1 = Fusion.Value(scope, "i am thing 1") local thing2 = Fusion.Value(scope, "i am thing 2") local thing3 = Fusion.Value(scope, "i am thing 3") -doCleanup(scope) +Fusion.doCleanup(scope) ``` Unlike `{}` (which always creates a new array), `scoped` can re-use old arrays. @@ -120,36 +119,39 @@ This helps keep your program running smoothly. Beyond making your code more efficient, you can also use `scoped` for convenient syntax. -You can pass a table of constructor functions into `scoped`: +You can pass a table of functions into `scoped`: -```Lua linenums="2" hl_lines="4-6" +```Lua linenums="2" hl_lines="4-7" local Fusion = require(ReplicatedStorage.Fusion) -local doCleanup, scoped = Fusion.doCleanup, Fusion.scoped +local scoped = Fusion.scoped local scope = scoped({ - Value = Fusion.Value + Value = Fusion.Value, + doCleanup = Fusion.doCleanup }) local thing1 = Fusion.Value(scope, "i am thing 1") local thing2 = Fusion.Value(scope, "i am thing 2") local thing3 = Fusion.Value(scope, "i am thing 3") -doCleanup(scope) +Fusion.doCleanup(scope) ``` -You can now use those constructors as methods on `scope`. +If those functions take `scope` as their first argument, you can use them as +methods directly on the scope: -```Lua linenums="2" hl_lines="7-9" +```Lua linenums="2" hl_lines="8-10 12" local Fusion = require(ReplicatedStorage.Fusion) -local doCleanup, scoped = Fusion.doCleanup, Fusion.scoped +local scoped = Fusion.scoped local scope = scoped({ - Value = Fusion.Value + Value = Fusion.Value, + doCleanup = Fusion.doCleanup }) local thing1 = scope:Value("i am thing 1") local thing2 = scope:Value("i am thing 2") local thing3 = scope:Value("i am thing 3") -doCleanup(scope) +scope:doCleanup() ``` This makes it harder to mess up writing scopes. Your code reads more naturally, @@ -157,21 +159,20 @@ too. ### Adding Methods In Bulk -Try passing `Fusion` to `scoped()` - it's a table with functions. - -```Lua linenums="2" hl_lines="4" -local Fusion = require(ReplicatedStorage.Fusion) -local doCleanup, scoped = Fusion.doCleanup, Fusion.scoped +Try passing `Fusion` to `scoped()` - it's a table with functions, too. +```Lua hl_lines="1" local scope = scoped(Fusion) + +-- all still works! local thing1 = scope:Value("i am thing 1") local thing2 = scope:Value("i am thing 2") local thing3 = scope:Value("i am thing 3") -doCleanup(scope) +scope:doCleanup() ``` -This gives you access to all of Fusion's constructors without having to import +This gives you access to all of Fusion's functions without having to import each one manually. If you need to mix in other things, you can pass in another table. @@ -201,45 +202,125 @@ local foo = scoped({ Baz = Baz }) +-- `bar` should have the same methods as `foo` -- it'd be nice to define this once only... local bar = scoped({ Foo = Foo, Bar = Bar, Baz = Baz }) + +print(foo.Baz == bar.Baz) --> true + +bar:doCleanup() +foo:doCleanup() ``` To do this, Fusion provides a `deriveScope` function. It behaves like `scoped` but lets you skip defining the methods. Instead, you give it an example of what the scope should look like. -```Lua linenums="2" hl_lines="2 11" -local Fusion = require(ReplicatedStorage.Fusion) -local scoped, deriveScope = Fusion.scoped, Fusion.deriveScope -local doCleanup = Fusion.doCleanup - +```Lua hl_lines="8" local foo = scoped({ Foo = Foo, Bar = Bar, Baz = Baz }) -local bar = deriveScope(foo) +-- `bar` should have the same methods as `foo` +-- now, it's only defined once! +local bar = foo:deriveScope() -doCleanup(bar) -doCleanup(foo) +print(foo.Baz == bar.Baz) --> true + +bar:doCleanup() +foo:doCleanup() ``` *Deriving* scopes like this is highly efficient because Fusion can re-use the same information for both scopes. It also helps keep your definitions all in one place. +### Inner Scopes + +The main reason you would want to create a new scope is to create things that +get destroyed at different times. + +```Lua +local longLivedScope = scoped(Fusion) +local longLivedNumber = longLivedScope:Value(5) + +for countdown = 1, 10 do + local shortLivedScope = longScope:deriveScope() + local shortLivedNumber = shortLivedScope:Value(2) + task.wait(1) + shortLivedScope:doCleanup() +end + +longLivedScope:doCleanup() +``` + +In the above example, the `shortLivedScope` only exists for one second before +getting destroyed. However, the `longLivedScope` exists for the entire duration +of the countdown. + +But what if you forgot to destroy those `shortLivedScope`s? + +```Lua hl_lines="8" +local longLivedScope = scoped(Fusion) +local longLivedNumber = longLivedScope:Value(5) + +for countdown = 1, 10 do + local shortLivedScope = longLivedScope:deriveScope() + local shortLivedNumber = shortLivedScope:Value(2) + task.wait(1) + -- shortLivedScope:doCleanup() +end + +longLivedScope:doCleanup() +``` + +Now, every `shortLivedScope` will exist forever, *beyond* the `longLivedScope` +they came from. This is often not desirable. + +In addition to this sort of bug, there's other times this happens. For example, +imagine if the `shortLivedScope` was used for some objects in a pop-up menu, but +the pop-up's surrounding UI (the `longLivedScope`) got destroyed. + +To help with this, Fusion provides an `innerScope` method that makes sure the +`shortLivedScopes` don't 'outlive' the `longLivedScope`, limiting the impact of +the bug. + +```Lua hl_lines="5" +local longLivedScope = scoped(Fusion) +local longLivedNumber = longLivedScope:Value(5) + +for countdown = 1, 10 do + local shortLivedScope = longLivedScope:innerScope() + local shortLivedNumber = shortLivedScope:Value(2) + task.wait(1) + -- shortLivedScope:doCleanup() +end + +longLivedScope:doCleanup() +``` + +'Inner scopes' exist until either: + +- the 'outer scope' is cleaned up +- the 'inner scope' itself is cleaned up + +This means that inner scopes are often the safest choice for creating new +scopes. They let you call `doCleanup` whenever you like, but guarantee that they +won't stick around beyond the rest of the code they're in. + ----- ## When You'll Use This Scopes might sound like a lot of upfront work. However, you'll find in practice -that Fusion manages a lot of this for you. +that Fusion manages a lot of this for you, and it makes your code much more +resilient to memory leaks and other kinds of memory management issues. You'll need to create and destroy your own scopes manually sometimes. For example, you'll need to create a scope in your main code file to start using diff --git a/src/Memory/innerScope.luau b/src/Memory/innerScope.luau new file mode 100644 index 000000000..71d7a3413 --- /dev/null +++ b/src/Memory/innerScope.luau @@ -0,0 +1,31 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + Derives a new scope that's destroyed exactly once, whether by the user or by + the scope that it's inside of. +]] +local Package = script.Parent.Parent +local Types = require(Package.Types) +local deriveScope = require(Package.Memory.deriveScope) + +local function innerScope( + existing: Types.Scope +): Types.Scope + local new = deriveScope(existing) + table.insert(existing, new) + table.insert( + new, + function() + local index = table.find(existing, new) + if index ~= nil then + table.remove(existing, new) + end + end + ) + return new +end + +return innerScope \ No newline at end of file diff --git a/src/init.luau b/src/init.luau index 5d11fd014..dd4a991d1 100644 --- a/src/init.luau +++ b/src/init.luau @@ -46,6 +46,7 @@ local Fusion: Types.Fusion = { cleanup = require(script.Memory.legacyCleanup), deriveScope = require(script.Memory.deriveScope), doCleanup = require(script.Memory.doCleanup), + innerScope = require(script.Memory.innerScope), scoped = require(script.Memory.scoped), -- State