RFC: Support cyclic requires.#205
Conversation
We augment the require() function, the module interface, and the type checker to allow modules to cyclically require one another in many cases.
| 2. Look up the requested module in the cache to see if it has already been loaded or begun loading | ||
| 3. If a module is in the cache, return it immediately. Otherwise, | ||
| 4. Populate the cache with an empty table. | ||
| 5. Pass this new table to the target script as its sole argument and evaluate it. This table can be accessed within the script via `...` at the top level. |
There was a problem hiding this comment.
Are there any backwards compatibility issues here?
There was a problem hiding this comment.
how would hot-reloading a module affect everything, since it talks about cache
| For instance, the following code will fail: | ||
|
|
||
| ```luau | ||
| --- A.luau | ||
|
|
||
| local B = require("B") | ||
|
|
||
| class ClassAOne extends B.ClassOne ... end | ||
| class ClassATwo ... end | ||
|
|
||
| --- B.luau | ||
|
|
||
| local A = require("A") | ||
|
|
||
| class ClassBOne ... end | ||
| class ClassBTwo extends A.ClassATwo ... end | ||
| ``` |
There was a problem hiding this comment.
Food for thought, I think this would get tricker with a (sketched out) import syntax:
--- A.luau
import ClassBOne from B
export class ClassAOne extends ClassBOne ... end
export class ClassATwo ... end
--- B.luau
import ClassATwo from A
class ClassBOne ... end
class ClassBTwo extends ClassATwo ... end
... if we're considering this a serious drawback, this seems like the real danger.
There was a problem hiding this comment.
Does this materially change the problem? Such a syntax would probably desugar like so:
local _unnamed = require("./B')
local ClassBOne = _unnamed.ClassBOne
type ClassBOne = _unnamed.ClassBOneIt certainly fails in exactly the same way as the example in the RFC, but I think that's acceptable. You'd get a readable error message, after all.
|
|
||
| Today, typechecking is driven by a class called `Frontend`. It accepts a set of modules that need checking, builds a DAG from that, and checks modules one after another. | ||
|
|
||
| We will augment this class to instead work on one strongly-connected component\* at a time. All modules within an SCC use the same arena and are typechecked together in a single pass through the solver. |
There was a problem hiding this comment.
You could also keep them separate, and instead dispatch one constraint solver in the same component, and dispatch the next one if that one is stuck, and so on, until either all of them completes or all of them are stuck.
This would avoid a constraint from module A making its way into some random metavariable's bounds in module B.
|
The ceremony required to make modules that don't use static exports participate in cycles seems silly to me. As the RFC mentions, cycles are already rare for projects prior to classes, and since classes go hand-in-hand with static exports, I see no sense in adding an escape hatch for this edge case. Just support cycles only for modules that use static exports. That way you don't even have to worry about if a module returns a different value since it's impossible. |
| In the absence of `export`, a script must be updated to support cyclic requires by making a small edit: Instead of creating an export table directly with `{}`, the script should accept it from `...` like so: | ||
|
|
||
| ```luau | ||
| local exports = ... |
There was a problem hiding this comment.
So, this is GETVARARGS. Now, the thing I know about it, is that it's often used within function bodies.
Doing local var = ... would mean that it's taking 1 element from the var arg alone.
If I do local a,b,c = ... it would take 3 and not more.
What would the design have to change here? Or what would this syntax mean if it is within function bodies?
Or does nothing change about GETVARARGS and it will stay 1 element, it's just to accept the export table
|
Will there be a new kind of type? I know there's modules that do the following -- now we could require("Main.luau") for the varargs?
--- A.luau
return function (varargs)
print(varargs.OurEnvironment) -- inheritance from upper module, I guess.
endbut I guess cyclic requires would solve this too, e.g. for Require Tracer? -- Main.luau
local PortionA = require("A.luau")
local Main = {
OurEnvironment = {}
}
PortionA(Main) |
|
Wouldn't it be better if a separate keyword was used instead (i.e., A second benefit to this is that it handles the case where |
|
|
||
| ## Runtime Design | ||
|
|
||
| For modules that return tables, we can solve this issue by having `require` tie the knot: When it encounters a cyclic import, `require` will instead return an empty table that will later be populated with the export surface of the module. As long as the requesting module doesn't access it at the topmost global scope, that table will eventually be populated and everything will work out. The system will temporarily attach a metatable to surface these issues and produce a clear error message. |
There was a problem hiding this comment.
I may not be following all the details here correctly, but would it be problematic if the ModuleScript tries to set a metatable on the export table? i.e. would it clobber the metatable that was already set by the system?
There was a problem hiding this comment.
Yes! This is a pretty bad situation to be in, however: In order to trigger this, you would have to set the metatable on some other module different from the current one. eg
local A = require("./A")
setmetatable(A, ...) -- !!!This sounds like extremely bad practice to me!
I can look into what it would take to disallow this.
There was a problem hiding this comment.
I was actually thinking of the case where a ModuleScript sets its own export table's metatable:
local exports = ...
setmetatable(exports, {})My understanding is that this would then replace any metatable that the system had already set on the table passed to the ModuleScript.
There was a problem hiding this comment.
This is why I think cycles should only be supported between modules that use the static export system. This issue isn't possible with static exports since the export table isn't directly accessible until after the module has returned.
There was a problem hiding this comment.
Got it.
This isn't good programming style at all, but the adaptation is simple enough: Instead of setting and unsetting the metatable, we'll just save and restore whatever metatable it had.
There was a problem hiding this comment.
This is why I think cycles should only be supported between modules that use the static export system. This issue isn't possible with static exports since the export table isn't directly accessible until after the module has returned.
I don't think the export table should be any kind of secret.
You can ask module B to require you and pass your export table back to you.
My understanding is that this would then replace any metatable that the system had already set on the table passed to the ModuleScript.
You can lock the metatable so that attempt will fail.
There was a problem hiding this comment.
I don't think the export table should be any kind of secret.
You can ask module B to require you and pass your export table back to you.
Since that's only possible after module A has frozen and returned the table, that's fine. It's only a problem if the module can access the export table while it's being populated, since that allows user code to clobber it like showcased.
My thought was that the metatable attached to the pre-populated table would have a __metatable that locks the requiring module from setting a new metatable, which is then stripped by the runtime when that table is passed to the required module to populate. Would this not work?
There was a problem hiding this comment.
It will not be frozen, in A -> B -> A chain, B receives incomplete table of A that it can return to A for the same observation and modification.
* Offer a bit more verbiage to describe what a SCC is. * Elaborate require() process a little more to cover another edge case.
This is another possibility that we talked about, but the implementation of this design is very simple and doesn't really compromise the I'll add it to the RFC as an alternative. |
This proposal doesn't propose any errors that would not be raised today. It simply proposes a different mechanism for deciding when to raise that error.
I don't see how this would be a problem under this proposal. If |
|
@andyfriesen My concern with supporting non-static-export scenarios is that it means the system has to assume the worst case and do things like save metatables, double check returned values, etc. With the limitations of static exports, these cases aren't possible and the resolution steps can be simplified. Also, I just think onboarding for learners is going to suck with these mechanics. Nobody is going to understand "you have to grab a proxy table from |
|
I don't see a point of bifurcating how modules can perform import and export and adding extra checks on what kind of module is being required. Feels largely unnecessary. |
Rendered
We augment the
require()function, the module interface, and the type checker to allow modules to cyclically require one another in many cases.