Skip to content
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

RFC: Export Keyword for Values #42

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open

Conversation

bradsharp
Copy link
Contributor

Allow users to export methods and values from their libraries directly through the use of the export keyword.

Rendered View

@bradsharp bradsharp marked this pull request as ready for review June 11, 2024 16:13
Comment on lines +104 to +115
Today, you can use the export keyword along with a return statement at the end of your module. If you use the export keyword with a value however we will throw an error if you also attempt to return.

```luau
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

return table.freeze {
distance = distance
}
```
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps worth mentioning that the future change that would enable
type _EXT.Point = Point -- note: this doesn't actually work today but one day it should would also likely necessitate this restriction on explicit returning as well, since the way we'd likely be able to make that possible is by giving tables the ability to have (phantom, i.e. non-existent at runtime) types associated with them as a value.

Copy link
Contributor Author

@bradsharp bradsharp Jun 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me that implies we need to make this functional (even if it's bad). We can't silently drop the second return, and we can't let it override the returned table. If modules supported multiple returns we might be able to get around this by making sure the export table is always the last value returned (e.g. return <user-defined>, _EXT), not sure how others would feel about this.

Copy link
Contributor

@aatxe aatxe Jun 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, maybe I wasn't clear. I think the correct behavior here, as you're suggesting in the RFC, is that we should throw an error on any explicit return in a module with export function or export id, and I also think that even though this may seem a bit odd right now since the restriction is specific to exporting values, it's also a restriction we will have for types if we do the work to enable assigning a type into a "field" (not one that exists at runtime) as you had written in the earlier example.

## Alternatives

### Do Nothing
It's already possible to export values from modules using a return statement. We don't actually need to do this, it's more of a nice-to-have.
Copy link
Contributor

@alexmccord alexmccord Jun 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that it's not entirely syntactic sugar. There are some subtle but useful runtime semantics we can squeeze out.

local mod = {}

function mod.distance(p1: Point, p2: Point)
  local x, y = p1.X - p2.X, p1.Y - p2.Y
  return math.sqrt(x * x + y * y)
end

If you wanted to call mod.distance somewhere else in this module, it will not get inlined because in theory, mod.distance could have been transmuted to a different function with a different implementation.

With export function distance, it's equivalent to local function distance + exports.distance = distance.

Copy link
Contributor Author

@bradsharp bradsharp Jun 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is true, and also worth calling out. There's an additional question here around whether we should expose _EXT to users at all. It might be worthwhile if there are legitimate use-cases with metatables but otherwise we might want to make it innaccessible.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I don't want to expose _EXT. It's trivial for the VM to not expose it too, it already does this with loops which introduces 3 locals you can't even use.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also question the value of adding metatable to the export table. I've never seen anyone want to do this, and if they do they can use return with no exports.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, in my mind, the initial goal should be to support the primary way people write modules today well, and other use cases can clearly continue to use explicit return. We can always consider adding additional things (like exposing that internal table as an identifier) in the future if we see sufficient motivation for doing so, but we cannot ever take it away if we add it now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with this so let's keep it locked down and if legitimate use-cases come up we can discuss as part of another RFC.

Copy link
Contributor

@alexmccord alexmccord Jun 12, 2024

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 export with local in this snippet, it doesn't do what you want it to do.

export function f()
  g()
end

export function g()
  f()
end

Copy link
Contributor

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 f export 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, export should behave like one of them.

Copy link
Contributor Author

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that export probably only makes sense at module level, it stands to reason they should work like globals.

Copy link
Contributor

@alexmccord alexmccord Jun 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thing: what happens if you use export in a scope that isn't the module scope?

function set(x)
  export function f() return x end -- ????
  return f
end

-- ????
print(set(5)())
print(set("hello")())

We have this problem already, but it's isolated to the type system currently which is easy to break compatibility, but this is runtime stuff.

function f()
  export type Foo = number
end

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've actually been wondering how this should work. My feeling is that we either don't allow it or we make it work exactly how it does in the modules function scope. In other words, using export in any function scope creates a module table and returns it as that functions return value. It would be a weird language quirk so I'm leaning towards "it's not allowed" but maybe that's done through linting and not something we actually enforce.

Something important to note here is that the nested table, `triangle` is not frozen.

#### Multiple Returns
Today, you can use the export keyword along with a return statement at the end of your module. If you use the export keyword with a value however we will throw an error if you also attempt to return.
Copy link
Contributor

@alexmccord alexmccord Jun 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throw an error

What kind of error? Parse error? Compile-time error?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd imagine compile-time.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought so too, but wanted this to be specified in the RFC.

Also, note that compile-time errors are not shown to the user at edit time currently, and has a problem of only throwing one compiler error. We'll need a linter to warn on mixed use of export/return.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like it should be a parse error similar to

return "Foo"
return "Bar"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I suppose it's not hard to make it a parsing error, you just keep a little bit of state to record if you've already seen an export/return and then whichever you see second can produce an error about it.

docs/export-keyword.md Outdated Show resolved Hide resolved
Co-authored-by: Alexander McCord <[email protected]>
@nezuo
Copy link

nezuo commented Jun 14, 2024

I think this is a great UX improvement! I do have one issue with it:

When you require a module with no return in Roblox, it gives the error: "Module code did not return exactly one value."

Having to specify a return by default and then remove it once you add something you want to export would be a bit annoying. I'm not sure what the right solution would be. Is it possible to change the behavior of modules that don't return anything?

@aatxe
Copy link
Contributor

aatxe commented Jun 14, 2024

Having to specify a return by default and then remove it once you add something you want to export would be a bit annoying. I'm not sure what the right solution would be. Is it possible to change the behavior of modules that don't return anything?

I think it would be reasonable to have an implicit return {} if no return statement is present since I think that's more-or-less what this RFC is proposing as the meaning of a module (i.e. it returns a table almost always).

@bradsharp
Copy link
Contributor Author

Having to specify a return by default and then remove it once you add something you want to export would be a bit annoying. I'm not sure what the right solution would be. Is it possible to change the behavior of modules that don't return anything?

I think it would be reasonable to have an implicit return {} if no return statement is present since I think that's more-or-less what this RFC is proposing as the meaning of a module (i.e. it returns a table almost always).

Not sure I agree with return {} being the default. I think it should be return; since you didn't return or export anything.

Copy link

@ccuser44 ccuser44 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I very much would like this feature in Luau for many reasons (expecially easier AST analysis and standardization), but because it's a syntax change I don't think it meets the bar to be added as a syntax feature.

Copy link

@ccuser44 ccuser44 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With further introspection I'm leaning fully on the side of not accepting this RFC. While nice in theory the practical gains of the feature are marginal and (in my opinion) don't justifu the increased lexer/parser complexity.

@jackdotink
Copy link
Contributor

As someone who has written a luau parser in luau, and contributed to other luau parsers, the added parsing complexity is just about 0.

@aatxe
Copy link
Contributor

aatxe commented Dec 4, 2024

Having to specify a return by default and then remove it once you add something you want to export would be a bit annoying. I'm not sure what the right solution would be. Is it possible to change the behavior of modules that don't return anything?

I think it would be reasonable to have an implicit return {} if no return statement is present since I think that's more-or-less what this RFC is proposing as the meaning of a module (i.e. it returns a table almost always).

Not sure I agree with return {} being the default. I think it should be return; since you didn't return or export anything.

This is already what the behavior is (leaving nothing on the stack) though, and what's why you get an error saying it has to return exactly one value. Returning nothing is returning zero values! There's a question of whether ModuleScript could potentially just be okay with returning zero values as well, but that is technically out of scope for the RFC itself.

@alexmccord
Copy link
Contributor

alexmccord commented Dec 4, 2024

There's an opportunity to talk about the possibility of adding re-exports here that I think this misses:

export M = require("./foo")

Which can re-export both types and values.

@ccuser44
Copy link

ccuser44 commented Dec 4, 2024

As someone who has written a luau parser in luau, and contributed to other luau parsers, the added parsing complexity is just about 0.

Yeah I don't know why I just said that. Adding the export keyword would only require adding a new lex token type and having it generate a SETTABLE instruction that adds the var to the export table.

My point was is that I don't think I don't think this RFC meets the high bar of usefulness that's required for new syntax features. I was originally for approving this RFC but now due to further introsoection I'm againts the idea.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

6 participants