-
Notifications
You must be signed in to change notification settings - Fork 79
RFC: Export Keyword for Values #42
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
base: master
Are you sure you want to change the base?
Changes from all commits
8ce7955
3b26c60
d3eb442
48b3b2d
55be89f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
bradsharp marked this conversation as resolved.
Show resolved
Hide resolved
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| # Export Keyword | ||
|
|
||
| ## Summary | ||
|
|
||
| Extend the `export` keyword to support values and functions. | ||
|
|
||
| ## Motivation | ||
|
|
||
| Today, it is possible to export a type from a module with the `export` keyword: | ||
|
|
||
| ```luau | ||
| export type Point = { x: number, y: number } | ||
| ``` | ||
|
|
||
| However, this mechanism is currently limited to types. Extending `export` to values and functions would provide a consistent way to expose a stable API from a module, while avoiding the extra boilerplate of returning a table. | ||
|
|
||
| ## Design | ||
|
|
||
| ### Syntax | ||
|
|
||
| Allow `export` in declarations anywhere that defines a name (`local`s, `function`s, and `type`s), but only at the top-level of a module: | ||
|
|
||
| ```luau | ||
| export version = "5.1" | ||
|
|
||
| export function init() | ||
| -- do a thing | ||
| end | ||
|
|
||
| export type Point = { x: number, y: number } | ||
| ``` | ||
|
|
||
| `export` for values is not permitted inside function bodies or other non–top-level scopes. Doing so results in a parse error. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe you should still be able to export values from within do
local count = 0
export function increment(): number
count += 1
return count
end
end
-- count and increment not visible here
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is what local modules does, if we had some sort of module M is
local count = 0
export function increment(): number
count += 1
return count
end
end
export increment = M.incrementWhich is equivalent to a closure, modulo (heh) namespacing/qualified paths/etc. local function makeM(): () -> number
local count = 0
return function() -- ignoring the _actual_ exporting machinery as red herring
count += 1
return count
end
end
export increment = makeM()You could even instantiate it multiple times. Module functors. module Make(n: number)
local count = 0
export function next(): number
count += n
return count
end
end
module Counter = Make(1)
export increment = Counter.nextexport incrementX = makeM() -- has its own independent `count` state
export incrementY = makeM() -- and its own independent `count` state |
||
|
|
||
| ### Semantics | ||
|
|
||
| #### Const By-Default | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alternative framing: this is not necessarily a export foo = 7
print(foo)
foo = 8After elaboration, all occurrences of local mod = {}
mod.foo = 7
table.freeze(mod)
print(mod.foo)
mod.foo = 8And since a field projection There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It doesn't work if we try to desugar several declarations this way: If
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Elaboration is not desugaring and is allowed to fail. Consider: export a = 1
print(a)
export a = 2
print(a)If we desugar to its There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would consider that code snipped valid. It outputs 1 and 2 and exports |
||
|
|
||
| Exported values and functions are implicitly [`const`](https://github.com/luau-lang/rfcs/pull/166). This means that exports must be initialized at the point of declaration and cannot be reassigned after initialization. | ||
|
|
||
| ```luau | ||
| export foo = 5 | ||
| foo = 6 -- error: exported bindings are const | ||
| ``` | ||
|
|
||
| Without this, it would be possible to mutate the variable without changing to the exported table, leading to the following foot-gun: | ||
|
|
||
| ```luau | ||
| export foo = 5 | ||
|
|
||
| export function increment() | ||
| foo += 1 -- 'foo' won't change in the exported table | ||
| end | ||
| ``` | ||
|
Comment on lines
+48
to
+54
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is quite awkward. What is the value of local foo = 5
export function increment()
foo += 1
end
export foo -- ???Currently, I know that this RFC says local foo = 5
export function increment()
foo += 1
end
export foo
export function get()
return foo -- this will always be 5
endAnd we can't even have a linter fire a warning on this (doesn't make sense). A fix is to be isomorphic to something akin to this, using local foo = 5
local function increment()
foo += 1
end
-- this is an empty table because of `for k, v in mod do` which conflicts with aliasing
return table.freeze(setmetatable({}, {
__index = function(_, k)
return if k == "foo" then foo
else if k == "increment" then increment
else nil
end,
}))There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree this approach would be more flexible and beneficial for some code patterns, but in this case we will loose ability to statically reason about exported values during loading. It will impede some potential optimizations for cross module inlining and constant folding. As soon as Luau doesn't allow to control readonlyness with property level granularity we will need to make some compromises here.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I mean, an optimization you can do is literally just move some of the exported symbols into the table part for all exported bindings that have not been mutated (or rather, not mutated through functions). From that you have a guarantee that The reason why I didn't do that was because of local order = {"foo", "increment"} -- sorted by hash, just like hashmaps
return table.freeze(setmetatable({ increment = increment }, {
__index = function(_, k)
return if k == "foo" then foo
else nil
end,
__iter = function(self)
local i = 0
return function()
i += 1
local k = order[i]
return k, self[k]
end
end,
}))Problem solved, the compiler or VM can still see that Module systems are complicated for a reason. You can't hand-wave all the issues away in an RFC with 165 lines that barely even talks about edge cases.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I say if you plan on reintroducing assignability through shorthand exports, don't even bother making exported values unassignable in the first place. I'm actually leaning more in the camp of not even supporting shorthand exports because of this issue. In my personal opinion, exporting should just be pure syntax sugar that maps exactly to returning a plain frozen table. There should be no special metatable mechanics like the proposed aliasing rules in the desugared form.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I think just remove the shorthand |
||
|
|
||
| #### Export Table Construction | ||
|
|
||
| Rather than populating an export table incrementally, the set of exported bindings is collected during parsing. When module execution completes, the module implicitly returns a frozen table containing the exported bindings. | ||
|
|
||
| For example, the following module: | ||
|
|
||
| ```luau | ||
| export type Point = { x: number, y: number } | ||
|
|
||
| export function distance(a: Point, b: Point) | ||
| local x, y = a.X - b.X, a.Y - b.Y | ||
| return math.sqrt(x * x + y * y) | ||
| end | ||
| ``` | ||
|
|
||
| Can be considered sugar for: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure the RFC should show the desugared representation with any constructs that doesn't exist. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it is acceptable as soon as dependency on other RFC explicitly stated and |
||
|
|
||
| ```luau | ||
| export type Point = { x: number, y: number } | ||
|
|
||
| const function distance(a: Point, b: Point) | ||
| local x, y = a.X - b.X, a.Y - b.Y | ||
| return math.sqrt(x * x + y * y) | ||
| end | ||
|
|
||
| return table.freeze({ | ||
| distance = distance, | ||
| }) | ||
| ``` | ||
|
|
||
| #### Export Shorthand | ||
|
|
||
| For convenience, we provide a shorthand form of `export` which will export an existing binding with the same name: | ||
|
|
||
| ```luau | ||
| const foo = computeFoo() | ||
| export foo | ||
| ``` | ||
|
|
||
| If `foo` was declared as `local` instead of `const` then subsequent uses of it here will be treated as if it were declared as `const`. | ||
|
|
||
| #### Order of Declarations and Mutual Dependencies | ||
|
|
||
| As with `local` and `const` exports are evaluated in source order. Using the shorthand from above, mutually recursive or dependent functions can be declared before they are exported. | ||
|
|
||
| ```luau | ||
| function a() | ||
| return b() | ||
| end | ||
|
|
||
| function b() | ||
| return 42 | ||
| end | ||
|
|
||
| export a | ||
| export b | ||
| ``` | ||
|
|
||
| Exporting before a binding is initialized is an error. Supporting hoisted exports or forward declarations is out of scope for this proposal and may be explored in a future RFC. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Agreed.
Disagree. This is not backward compatible to retrofit and as such it is not a decision we can defer until later, and mutually dependent functions are fundamental in nontrivial programs. Pre-existing idiom allows mutual dependencies already, so this RFC is more or less a downgrade in its current form. In short, this is a design choice we have to make now or never, and deferring a backward-incompatible design choice is the same as choosing to never do it. If we could resolve names in function bodies after parsing the body itself rather than immediately, then we already support mutual dependencies. This is sort of easy from a conceptual point of view: after parsing the module, all occurrences of export type t<a> =
| { type: "leaf", value: a }
| { type: "node", children: {t<a>} }
export function walk_node<a, b>(
node: t<a>,
leaf_fn: (a) -> b,
node_fn: ({b}) -> b
): b
return if node.type == "leaf"
then leaf_fn(node.value)
else node_fn(walk_children(node.children, leaf_fn, node_fn))
end
export function walk_children<a, b>(
children: {t<a>},
leaf_fn: (a) -> b,
node_fn: ({b}) -> b
): {b}
local result = table.create(#children)
for _, node in children do
table.insert(result, walk_node(node, leaf_fn, node_fn))
end
return result
endAs you can see, it is not possible to reorder either This approach will require changes in everything else though, since if With that said, I will be contrarian for a minute because there are problems on the other side of this beyond the existing implementation assumptions that needs to be solved if we decided to support mutual dependencies out of the box, because there's a critical caveat to note: we don't want to rebind any print(nasty()) -- nil
local x = 5
export function nasty()
return x
endWhich is precisely equivalent to JavaScript's hoisting bug: console.log(nasty()); // undefined
var x = 5; // change to `let` or `const` for TDZ semantics
function nasty() {
return x;
}ES6 added export function hella_nasty()
return nasty()
end
print(hella_nasty())
local x = 5
export function nasty()
return x
endUnfortunately it is not possible to turn this class of bugs into a compile error because the analysis is nontrivial, and, per Rice's theorem, is undecidable in the general case (hence why TDZ was punted onto the runtime), so if we did support mutual dependencies, then we have these choices:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think we need to introduce hoisting for this purpose. export type t<a> =
| { type: "leaf", value: a }
| { type: "node", children: {t<a>} }
local walk_children: <a, b> (
children: {t<a>},
leaf_fn: (a) -> b,
node_fn: ({b}) -> b
) -> {b}
export function walk_node<a, b>(
node: t<a>,
leaf_fn: (a) -> b,
node_fn: ({b}) -> b
): b
return if node.type == "leaf"
then leaf_fn(node.value)
else node_fn(walk_children(node.children, leaf_fn, node_fn))
end
walk_children = function<a, b>(
children: {t<a>},
leaf_fn: (a) -> b,
node_fn: ({b}) -> b
): {b}
local result = table.create(#children)
for _, node in children do
table.insert(result, walk_node(node, leaf_fn, node_fn))
end
return result
end
export walk_childrenI also like OCaml style approach. In this case my snippet will be the desugaring of it. |
||
|
|
||
| #### Calling Exported Functions Internally | ||
|
|
||
| Exported functions are still available within the module and can be called normally: | ||
|
|
||
| ```luau | ||
| export function distance(a: Point, b: Point) | ||
| return math.sqrt((a.X - b.X)^2 + (a.Y - b.Y)^2) | ||
| end | ||
|
|
||
| distance({0, 0}, {1, 1}) | ||
| ``` | ||
|
|
||
| #### Nested Tables | ||
|
|
||
| Exporting a table exports the binding, not the contents of the table: | ||
|
|
||
| ```luau | ||
| export triangle = {} | ||
|
|
||
| function triangle.draw() | ||
| -- ... | ||
| end | ||
| ``` | ||
|
|
||
| The `triangle` binding is immutable, but the table itself is mutable unless explicitly frozen. | ||
|
|
||
| #### Returns | ||
|
|
||
| A module that uses `export` for values or functions may not also return a custom value. Attempting to do so is an error: | ||
|
|
||
| ```luau | ||
| export function distance(a: Point, b: Point) | ||
| return math.sqrt((a.X - b.X)^2) | ||
| end | ||
|
|
||
| return { distance = distance } -- error | ||
| ``` | ||
|
|
||
| Type-only exports may continue to coexist with an explicit return value, as they do today. | ||
|
|
||
| ## Drawbacks | ||
|
|
||
| * Introduces another way to define module exports alongside explicit return tables. | ||
| * Requires exported bindings to be immutable, which may require restructuring some existing patterns. | ||
| * Extends the surface area of the `export` keyword beyond types. | ||
|
|
||
| ## Alternatives | ||
|
|
||
| - Modules could implicitly export all top-level bindings. This is not backwards compatible, reduces explicitness, and complicates reasoning about module APIs. | ||
| - Modules can already export values by returning tables explicitly. This proposal is a quality-of-life and consistency improvement but is not strictly necessary. | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One thing I noticed not mentioned here is how this would work with two mutually dependent exported functions. If you replace
exportwithlocalin this snippet, it doesn't do what you want it to do.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think my personal expectation here is that this is going to behave the same way as locals, and we can perhaps provide a plain
export fexport g(though I suspect syntactic ambiguity will be an issue here) to let you export a global.I would be open to hearing reasons why it should go the other way instead and behave like globals, but I think regardless of the choice, we don't want to introduce a new third semantics here,
exportshould behave like one of them.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree with @aatxe on this one.
If we want to support the described behavior I think we should look into hoisting but that would be a separate RFC.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given that
exportprobably only makes sense at module level, it stands to reason they should work like globals.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've adopted the
localsemantics since they seem like the best place to start, with the option to forward-declare usingconstorlocal. In the future we may consider hoisting functions on the form<keyword> function <ident>()