Mutable before return Export-by-Value Semantics#179
Mutable before return Export-by-Value Semantics#179MagmaBurnsV wants to merge 4 commits intoluau-lang:masterfrom
Conversation
|
The following cases are currently unclear in their behaviour, root causing being a lack of definition for the interaction of export statements with existing variables. I.e. you currently only explain that two exports cannot share an identifier. -- This is mentioned in alternatives, but not in body
local function foo() end
export foo -- syntax error?
local bar = 0
export bar = 1 -- syntax error?
print(bar) -- 0 or 1?
fruit = "apple" -- global
export fruit -- shadowing or reference?
fruit = "pear" -- is fruit now local?
print(fruit) -- pear?
print(_G.fruit) -- apple?
export animal
animal = "dog"
local animal = "cat" -- syntax error?
print(animal) -- cat or dog? |
|
I've clarified that the restriction only applies to exported variables, and exports follow conventional shadowing rules in regard to non-exported variables, so your example would desugar into: local _EXP = {}
local function foo() end
_EXP.foo = nil
local bar = 0
_EXP.bar = 1
print(_EXP.bar) -- 1
fruit = "apple"
_EXP.fruit = nil
_EXP.fruit = "pear"
print(_EXP.fruit) -- pear
print(_G.fruit) -- apple
_EXP.animal = nil
_EXP.animal = "dog"
local animal = "cat"
print(animal) -- cat
-- {foo = nil, bar = 1, fruit = "pear", animal = "dog"}
return table.freeze(_EXP)Or alternatively if optimized into locals by the compiler: local function foo() end
local foo
local bar = 0
local bar = 1
print(bar)
fruit = "apple"
local fruit
fruit = "pear"
print(fruit)
print(_G.fruit)
local _animal = nil
_animal = "dog"
local animal = "cat"
print(animal)
return table.freeze({foo = foo, bar = bar, fruit = fruit, animal = _animal})(Uninitialized exports looking like shorthand exports may be a good reason not to support them. The only reason I allowed them in the design was to obtain parity with how the |
While this is true, it should be really easy to detect most cases through linting because export follows existing shadowing rules. In fact the same lint would catch another common error which could result from mistypes or copy paste errors. I would propose "Uninitiated export is never assigned to" as a lint rule. local function foo() end
export foo -- Uninitiated export is never assigned to
-- The above also catches the incorrect assumption of exporting locals
-- A common lint rule "no shadowing" would also catch this error
export bar -- No lint because bar is used in an assignment
if condition then
bar = function() end
end
export f, g -- No lint because f and g is used in an assignment
function f() g() end
function g() f() end
export user -- Uninitiated export is never assigned to
User = {
}
export fruit = nil -- Uninitiated export is never assigned to
-- Although assigned nil explictly, there would never be a reason to export nilLint rules are not necessarily part of the langauge, but the possibilty of it being easily lintable helps to remove confussion for users and maintains feature parity with the local keyword as you described. |
|
We discussed internally different approaches resolving mutual recursive functions and related issues. I think we all agreed mutable-before-return semantic will be least evil here. I intend to merge this RFC at the end of the week if no strong evidence against it will be provided. |
| Exported variables will count towards the local variable limit, as they can be optimized into locals by the compiler. | ||
|
|
||
| ```luau | ||
| export version = "5.1" |
There was a problem hiding this comment.
Also we can think about export const version = "5.1" syntax to make assignment to version a syntax error(or even make it default). In this case export local x can be symmetric syntax explicitly signaling x will be modified later.
There was a problem hiding this comment.
Honestly, since I made this RFC with the expectation that const would be rolled back (because the whole rationale for const was to fill the "can't reassign" gap immutable exports brought), I never had any plans for introducing const-ness to exported tokens. Now that const is here but immutable exports aren't, things are awkward.
I guess I can try to retrofit everything with const in mind, but my fear is the result is going to be a little "sore" since the features here are going to added in backwards.
There was a problem hiding this comment.
I guess the only rational way to do this is to make export a keyword that goes before declarations like you've suggested. Functions/classes might have to be special-cased where they are always exported const, but that should be fine.
| local function foo(x) return x * 2 end | ||
| export foo -- exports nil, shadows foo | ||
| ``` | ||
|
|
There was a problem hiding this comment.
One additional drawback is, in large Luau files, reassignable exports can introduce a potential footgun. That is the tradeoff to enable cleaner mutual recursion, but I think it should be mentioned at least.
| return table.freeze({tau = tau}) | ||
| ``` | ||
|
|
||
| Furthermore, static exports that are never reassigned to are capable of being inlined and constant folded across modules, assuming future support for static imports. |
There was a problem hiding this comment.
The "staticness" of the export is not required for potential optimizations. Because export statements are top-level statements, we can determine it's final exported value at compile time.
There was a problem hiding this comment.
I guess a more correct way to phrase this would be "first-class exports allow for the compiler to assume exported tokens cannot be reassigned after module return, unlocking the ability to bring constant-folding and inlining across module boundaries."
Changes syntax in lieu of const with different implications for drawbacks/alternatives.
| ## Design | ||
| The `export` contextual keyword will now be allowed anywhere before variable and function declarations at the top level of a module, including `local`, `const` and `function` declarations. | ||
|
|
||
| Exporting declarations that are in nested `do end` blocks under the top scope are also permitted. Attempting to export a declaration outside the module scope will result in a parse error. |
There was a problem hiding this comment.
Can I have an example where you would want to hide exported values from the rest of the module they are defined in? I'm struggling to see why this would be desired behavior.
There was a problem hiding this comment.
The first codeblock in the doc shows an example where an exported function references a scoped upvalue for encapsulation.
do
local counter = 0
export function increment(): number
counter += 1
return counter
end
end
-- counter and increment not visible hereIt's less about hiding the exported bindings and more about hiding their dependencies.
There was a problem hiding this comment.
Hiding the dependencies makes sense, however, I do think it will be nicely covered by the classes RFC, plus any additional RFC extensions to add a private keyword.
This proposes alternative semantics for #42, which treats exports as syntax sugar for assigning keys to an export table before sealing.
This allows reassigning exported values before the module is returned, solving the original RFC's issues with mutually dependent functions and shorthand aliasing. Furthermore, it introduces export semantics that are already representable in the language, dismissing the need for introducing
const-ness to the language.Rendered