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
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions docs/export-keyword.md
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

@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.

Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Export Keyword

## Summary

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

## Motivation

Users can export types from their libraries through the use of the export keyword.

```luau
export type Point = { x: number, y: number }
```

Consumers of these libraries are able to access exported types by indexing the libraries identifier.

```luau
local Library = require("Library")
local point : Library.Point = { x = 0, y = 0 }
```

Right now, this is the only use of the export keyword but it would be great if we let users export more with it to offer more utility and make exporting library methods and values easier.

## Design

### Syntax
You can prefix any non-local declaration with the export keyword in the top level scope of a module. For values this looks like:

```luau
export version = "5.1"
```

While methods use:

```luau
export function init()
-- do a thing
end
```

### Behavior

When a value or method is prefixed with export, it is automatically added to a hidden export table which is frozen as soon as the module returns.

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

This essentially becomes sugar for:

```luau
local _EXT = {}

type Point = { x: number, y: number }
type _EXT.Point = Point -- note: this doesn't actually work today but one day it should

local 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
_EXT.distance = distance

return table.freeze(_EXT)
```

If the user attempts to assign to an exported identifier then we would throw an error explaining that the interface cannot be changed once it has been exported.

### Nuances
Due to the implementation, most things should "just-work". Here are some examples to consider:

#### Calling an Exported Function
You can call an exported function as it's registered as a local before being added to the export table.

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

distance({0, 0}, {1, 1})
```

#### Nested Tables
You can export tables with additional values inside of them.

```luau
export triangle = {}

function triangle.draw()

end
```

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

#### 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.


```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
}
```
Comment on lines +104 to +115
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.


## Drawbacks
The keyword already exists for types so there's not much cost in us adding support to values. The main drawback is that it's another way to do module exports. We already have a way to do that, do we really need another?

## 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 = a.X - b.X, a.Y - b.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.


### Automatically Export Globals
Luau has a separate global scope for each module rather than a shared global scope across the entire program. We could automatically export any values that are stored in the global scope. This isn't backwards compatible though, we'd likely need a new import mechanism to resolve this.