-
Notifications
You must be signed in to change notification settings - Fork 89
Classes!! #191
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?
Classes!! #191
Changes from all commits
f584e90
9a65cd3
9e17a74
abaa555
f65f47f
483287a
7ae771b
3a00473
35238f1
d0d9dc0
cfabb49
77fd475
0cf7e88
3d24cd7
c3d5463
1704e74
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,290 @@ | ||
| # Classes | ||
|
|
||
| ## Summary | ||
|
|
||
| ```luau | ||
| class Point | ||
| public x: number | ||
|
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. Is there a way for me to define a static member such as
Collaborator
Author
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 yet. It's a good idea for an extension though. |
||
| public y | ||
|
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. Can class fields have default values?
Collaborator
Author
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 yet. Sounds like a nice extension though.
Comment on lines
+7
to
+8
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. What if we do the crazy thing and said that fields need commas or semicolons? Then the bare fields like these just doesn't look so strange after all. So for this class: class Point
x: number,
y,
public function new(x, y)
return Point { x = x, y = y }
end
public function components(self)
return self.x, self.y
end
public function length(self)
local x, y = self:components()
return math.sqrt(x*x + y*y)
end
public function __add(self, rhs: Point)
local lx, ly = self:components()
local rx, ry = self:components()
return Point.new(lx+rx, ly+ry)
end
end |
||
|
|
||
| function length(self) | ||
| return math.sqrt(self.x * self.x + self.y * self.y) | ||
| end | ||
|
|
||
| function __add(self, other: Point) | ||
|
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. Do we need to do the C++ thing and say identifiers in classes with two leading underscores are not valid methods, fields, nor are they metamethods, except for all existing metamethods that makes sense for an
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. We would have to exclude
Collaborator
Author
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 had been leaning "no," but it would be a nice way to catch some simple errors. (eg writing
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. For me, it was about ensuring forward compatibility with any new metamethods Luau may have in the future. I think because of that, I'd rather flip from a denylist to an allowlist? |
||
| return Point { x = self.x + other.x, y = self.y + other.y } | ||
| end | ||
|
|
||
| function __tostring(self) | ||
|
andyfriesen marked this conversation as resolved.
|
||
| return `Point \{ x = {self.x}, y = {self.y} \}` | ||
| end | ||
|
|
||
| function new(x, y) | ||
| return Point { x = x, y = y } | ||
|
andyfriesen marked this conversation as resolved.
|
||
| end | ||
| end | ||
|
|
||
| local p = Point.new(3, 4) | ||
| print(`Check out my cool point: {p} length = {p:length()}`) | ||
| ``` | ||
|
|
||
| ## Motivation | ||
|
|
||
| * People write object-oriented code. We should afford it in a polished way. | ||
| * Accurate type inference of `setmetatable` has proven to be very difficult to get right. Because of this, the quality of our autocomplete isn't what it could be. | ||
| * A construct with a fixed shape and a completely locked-down metatable will open up optimization opportunities that could improve performance: | ||
| * If classes can only be declared at the top scope, then we know that each method of each class has exactly one instance. This makes it simple for the compiler to know the exact function that will be invoked for any method call expression. | ||
| * If a value is known to be an instance of a particular class, the bytecode compiler should be able optimize method calls to skip the whole `__index` metamethod process and instead generate code to directly call the correct method. | ||
| * By the same token, method calls can be inlined more aggressively. Particularly self-method calls eg `self:SomeOtherMethod()` | ||
| * Field accesses can compile to a simple integral table offset so that the VM doesn't need to do a hashtable lookup as the program runs. | ||
| * Since every instance of a class has the same set of properties, we can split the hash table: The set of fields can be associated with the class and instances only need to carry the values of those fields. We think this can improve performance by improving cache locality. | ||
|
|
||
| ## Design | ||
|
|
||
| ### Syntax | ||
|
|
||
| Class definitions are a block construct. They can only be written at the topmost scope. `export class X` is allowed. | ||
|
|
||
| Defining two classes with the same name in the same module is forbidden. | ||
|
|
||
| Within a class block, two declarations are allowed: Fields and methods. | ||
|
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. What would happen if one makes a field, such as in the old method, you'd probably have And what about initializing functions? Some would initialize things, mid running .new() function new(x, y)
local newPoint = Point { x = x, y = y }
newPoint:PrepareThings()
return newPoint
endas in function Point.new(x, y)
local newPoint = setmetatable({ x = x, y = y }, Point)
newPoint:PrepareThings()
return newPoint
end
And what would happen in this case here? function new(x, y)
local newPoint = Point { data = {} }
newPoint.data.entry = "test"
return newPoint
endWould it know about |
||
|
|
||
| Fields are introduced with the new `public` keyword. We also plan to eventually offer `private`, but is sufficiently complex that it merits its own RFC. | ||
|
|
||
| Methods are introduced with the familiar `function` keyword. `public function f()` is also permitted. | ||
|
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.
Right now, all |
||
|
|
||
| Methods defined on class objects can be accessed either via `Class.method()` or `instance:method()` syntax. | ||
|
|
||
| If a method's first argument is named `self`, it should be invoked with the familiar `instance:method()` call syntax. This is not strictly required, but the compiler and optimizers may deoptimize code that doesn't. Type annotations on the `self` parameter are not allowed. | ||
|
|
||
| If a method accepts no arguments or if its first argument is not named `self`, it should be invoked via `Class.method()` syntax. This is the same as "static methods" from other languages. | ||
|
Comment on lines
+52
to
+60
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 haven't seen any explicit mentions of whether or not fields can be defined as functions. I can therefore only assume by the fact there isn't a good enough distinction between methods and fields, that defining fields as functions would currently be impossible—at least from outside constructor functions... ...but what about inside constructors? You can define callbacks in JavaScript classes like so: class Foo {
constructor(bar) {
this.bar = bar;
// Callback attribute:
this.onJarp = () => {
console.log(`${this.bar} yarb!`);
};
}
jarp() {
if (typeof this.onClick === 'function') {
// Invoke the callback:
this.onJarp();
}
}
}
// Usage example
const myFoo = new Foo('Bar');
// Custom callback assigned later
myFoo.onJarp = () => {
console.log('This is a custom callback!');
};
// Trigger the callback
myButton.jarp();Just wondering whether or not Luau could support this type of functionality. I'd prefer not to be forced to define callbacks through a method, as the use of fields and methods should convey intention about what exactly is changing about a class's instance; fields are used to house data that can be used either internally, or externally; methods are the actions that change, or are shown to "do something" to the instance. Setting a callback through a method isn't really "doing something" to the object, which is why I am not really a fan of that approach.
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. Fields aren't immutable as far as I understand so the following should work, especially considering classes are meant to be a replacement for metatable classes. class Foo
public onJarp: ((foo: Foo) -> ())?
function create(bar: string)
local function onJarp(foo: Foo)
print(`{foo.bar} yarb!`)
end
return Foo { bar = bar, onJarp = onJarp }
end
function Jarp(self)
if self.onJarp then
self.onJarp(self)
end
end
end
const myFoo = Foo.create("Bar")
myFoo.onJarp = function(Foo)
print("This is a custom callback!")
end
myFoo:Jarp()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. The only awkward area I can still see is that there is no way to initialize functions as attributes without accidentally creating methods outside of constructors. It's annoying for one as it means that you may end up having to define the same logic multiple times throughout differing constructors (if you're going to use "static methods") that would otherwise be included in class definitions statements instead.
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 wonder how or how complex it will be for code editors to jump to the implementation of |
||
|
|
||
| To create a new instance of a class, invoke it as if it were a function. It accepts one argument: A table that describes the initial values of all its properties. If more customization is desired, static factory functions (frequently named `new()` or `create()`) are an easy, familiar way to accomplish this. | ||
|
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. Does anything happen if the table has excess properties that aren't present in the class?
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'd really like for that to be an 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. Also this should error too. Just to be explicit about it. local t = setmetatable({ x = 1 }, {
__index = function(self, k)
return if k == "y" then 2 else nil
end
})
class Point
x: number,
y: number,
end
local pt = Point(t)The question is whether this should also error, though: local t = setmetatable({ x = 1, y = 2 }, { --[[ any metatables at all ]] })
class Point
x: number,
y: number,
end
local pt = Point(t) |
||
|
|
||
| Classes can define the following Luau metamethods. They all work just like they do on a metatable: | ||
|
|
||
| * `__call` | ||
| * `__concat` | ||
| * `__unm` | ||
| * `__add` | ||
| * `__sub` | ||
| * `__mul` | ||
| * `__div` | ||
| * `__mod` | ||
| * `__pow` | ||
| * `__tostring` | ||
| * `__eq` | ||
| * `__lt` | ||
| * `__le` | ||
| * `__iter` | ||
| * `__len` | ||
| * `__idiv` | ||
|
|
||
| For forward-compatibility, it is a syntax error to define any other method whose | ||
| name starts with two underscores. | ||
|
Comment on lines
+81
to
+84
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 don't see Now, I am not suggesting that if someone would re-define But I was looking for what types of classes exist, and found this special one. It seems to have Unless you'd make a proxy, but a proxy, is just another hack, for these 2 things. Proxies maybe are useful for other debugging. But a proxy for these two things, doesn't feel like it. Details--[[
A general purpose n-dimensional vector library. mul and div support either scalar multiplication or componentwise with another vector
]]
local FloatVector = {}
type FloatVectorMembers = { data: { number } }
export type FloatVector = typeof(setmetatable({} :: FloatVectorMembers, FloatVector))
-- input: Another FloatVector or tuple of number values
function FloatVector.new(...): FloatVector
local new = setmetatable({} :: FloatVectorMembers, FloatVector)
new.data = {}
local inputTable = { ... } :: any
if #inputTable == 1 then
if typeof(inputTable[1]) == "table" then
if getmetatable(inputTable[1]) :: any == FloatVector then
inputTable = (inputTable[1] :: FloatVector).data
else
inputTable = inputTable[1] :: { number }
end
end
end
for _, component in inputTable do
if typeof(component) ~= "number" then
error("arg to FloatVector.new() not a number")
else
table.insert(new.data, component :: number)
end
end
return new
end
function FloatVector:getSize(): number
return #self.data
end
function FloatVector.checkCompatible(lhs: FloatVector, rhs: FloatVector): boolean
if typeof(lhs) ~= typeof(FloatVector) or typeof(rhs) ~= typeof(FloatVector) then
error("vector operation on non-vector type")
return false
end
if #lhs.data ~= #rhs.data then
error("operating on vectors of different dimension")
return false
end
return true
end
function FloatVector.__index(table, key: any)
if typeof(key) == "number" then
return table.data[key :: number]
end
return FloatVector[key]
end
function FloatVector.__newindex(table, key, value)
if typeof(key) == "number" then
table.data[key :: number] = value :: number
return
end
rawset(table, key, value)
end
function FloatVector.__add(lhs: FloatVector, rhs: FloatVector): FloatVector
FloatVector.checkCompatible(lhs, rhs)
local result = {}
for i = 1, #lhs.data, 1 do
table.insert(result, lhs.data[i] + rhs.data[i])
end
return FloatVector.new(result)
end
function FloatVector.__sub(lhs: FloatVector, rhs: FloatVector): FloatVector
FloatVector.checkCompatible(lhs, rhs)
local result = {}
for i = 1, #lhs.data, 1 do
table.insert(result, lhs.data[i] - rhs.data[i])
end
return FloatVector.new(result)
end
function FloatVector.__mul(lhs: FloatVector, rhs: FloatVector | number): FloatVector
if typeof(lhs) == typeof(FloatVector) and typeof(rhs) == "number" then
local result = {}
for _, component in lhs.data do
table.insert(result, component * rhs :: number)
end
return FloatVector.new(result)
else
FloatVector.checkCompatible(lhs, rhs)
local result = {}
for i = 1, #lhs.data, 1 do
table.insert(result, lhs.data[i] * rhs.data[i])
end
return FloatVector.new(result)
end
end
function FloatVector.__div(lhs: FloatVector, rhs: FloatVector | number): FloatVector
if typeof(lhs) == typeof(FloatVector) and typeof(rhs) == "number" then
local result = {}
for _, component in lhs.data do
table.insert(result, component / rhs :: number)
end
return FloatVector.new(result)
else
FloatVector.checkCompatible(lhs, rhs)
local result = {}
for i = 1, #lhs.data, 1 do
table.insert(result, lhs.data[i] / rhs.data[i])
end
return FloatVector.new(result)
end
end
function FloatVector:dot(rhs): number
FloatVector.checkCompatible(self, rhs)
local result = 0
for i = 1, #self.data, 1 do
result += self.data[i] * rhs.data[i]
end
return result
end
-- must be a 3 dimensional vector
function FloatVector:cross(rhs): FloatVector?
FloatVector.checkCompatible(self, rhs)
if #self.data ~= 3 or #rhs.data ~= 3 then
error("cross product is only defined for two 3 dimensional vectors")
return nil
end
return FloatVector.new(
self.data[2] * rhs.data[3] - self.data[3] * rhs.data[2],
self.data[3] * rhs.data[1] - self.data[1] * rhs.data[3],
self.data[1] * rhs.data[2] - self.data[2] * rhs.data[1]
)
end
function FloatVector:magnitude(): number
local sqSum = 0
for _, component in self.data do
sqSum += component ^ 2
end
return math.sqrt(sqSum)
end
function FloatVector:fuzzyEq(rhs: FloatVector, eps: number): boolean
if not FloatVector.checkCompatible(self, rhs) then
return false
end
eps = if eps then eps else 0.00001
for i = 1, #self.data, 1 do
if math.abs(self.data[i] - rhs.data[i]) > eps then
return false
end
end
return true
end
return FloatVectorThere 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 may be coming in the future as mentioned here: #191 (comment) |
||
|
|
||
| #### Class Objects | ||
|
|
||
| The action of evaluating a class definition statement introduces a *class object* in the module scope. A class object is a value that serves as a factory for instances of the class and as a namespace for any functions that are defined on the class. | ||
|
|
||
| Class objects behave like class instances in most ways, but are always `const` and frozen. | ||
|
|
||
| Taking references to class methods via `ClassName.method` syntax is allowed so that classes can easily compose with existing popular APIs: | ||
|
|
||
| ```luau | ||
| local n = pcall(SomeClass.getName, someClassInstance) | ||
| ``` | ||
|
|
||
| To construct an instance of a class, call the class object as though it were a function. It accepts a single argument: a table-like value that contains initial values for all the fields. While it will typically be most useful to pass a table literal to this function, that isn't the only use. For example, any class can be shallowly cloned by passing it to its class constructor: `local clone = MyClass(original)` | ||
|
andyfriesen marked this conversation as resolved.
|
||
|
|
||
| The top type of all class objects is named `class`. `type()` and `typeof()` return `"class"` when passed a class object. | ||
|
|
||
| #### Class Instances | ||
|
|
||
| Class instances are a new type of value in the VM. They are similar but not quite the same as tables. They have no array part, for instance. | ||
|
|
||
| `pairs`, `ipairs` , `getmetatable`, and `setmetatable` do not work on class instances. They also cannot be iterated over with the generic `for` loop. (unless the class implements `__iter`) | ||
|
|
||
| Reading or writing a nonexistent class property raises an exception. This makes it easy to disambiguate between a nonexistent property and a property whose value is nil. | ||
|
|
||
| We introduce a new top type for class instances: `object`. The builtin `type()` and `typeof()` functions return `"object"` for any class instance. We chose this over having them return the class name because class names do not have to be globally unique (they must only unique within a single module) and because we do not want to make it possible for classes to impersonate other types. | ||
|
|
||
| ```luau | ||
| class Cls end | ||
| local inst = Cls {} | ||
|
|
||
| type(Cls) == "class" | ||
| typeof(Cls) == "class" | ||
|
|
||
| type(inst) == "object" | ||
| typeof(inst) == "object" | ||
| ``` | ||
|
|
||
| Comparisons between object instances is the same as with tables: If `__eq` is not defined, object comparisons use physical (pointer) equality. `__eq` is only invoked if both operands are the same type. | ||
|
|
||
| #### The `class` library | ||
|
|
||
| We introduce a new global library `class`. Its contents are | ||
|
|
||
| ```luau | ||
| local class: { | ||
| isinstance: (o: object, C: class) -> boolean, | ||
| classof: (o: object) -> class?, | ||
| } | ||
| ``` | ||
|
|
||
| This library also serves as an obvious extension point for future features like reflection. | ||
|
|
||
| The function `class.isinstance(o, Class)` returns `true` if the object `o` is an instance of `Class`. At runtime, it raises an exception if the second argument is not a class object. If the first argument is not a class instance, `class.isinstance` returns false. (eg `class.isinstance(5, MyClass)`) | ||
|
|
||
| ### Type System | ||
|
|
||
| Class definitions also introduce a new type to the type environment. | ||
|
|
||
| Unlike tables, which are structurally typed, class types are nominal. Two different classes with identical fields are treated as distinct types. | ||
|
|
||
| Inferring the types of class fields is fraught with difficulty, so un-annotated fields are given the type `any`. | ||
|
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.
|
||
|
|
||
| The type introduced by a class definition is available anywhere in the source file. | ||
|
|
||
| The `class.isinstance` function participates in refinement: | ||
|
|
||
| ```luau | ||
| function foo(p: unknown) | ||
| if class.isinstance(p, Point) then | ||
| return {p.x, p.y} -- no error here | ||
| end | ||
| end | ||
| ``` | ||
|
|
||
| Each class object is a singleton instance of an unnamed type. If needed, it is easy to access via `typeof(TheClass)`. Class object types are all subtypes of the top `class` type. | ||
|
|
||
| ### Semantics | ||
|
|
||
| Class definitions are Luau statements just like function definitions. | ||
|
|
||
| The action of a class definition statement is to allocate the class object, define its functions and properties, and freeze it. Consequently, a class cannot be instantiated before this statement is executed. | ||
|
|
||
| We do, however, *hoist* the class identifier's binding to the top of the script so that it can be referred to within functions or classes that lexically appear before the class definition. This makes it easy and straightforward for developers to write classes or functions that mutually refer to one another. | ||
|
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. Class hoisting implies the following code is valid: local function getFoo()
local f = Multiple.new(2)
return f
end
print(getFoo()) -- ???
local function getData(factor: number)
return 123 * factor
end
class Multiple
public factor: number
function length(self)
return getData(factor)
end
endWhat is the expected behavior where getFoo() is called? A runtime error?
Collaborator
Author
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. It's a runtime 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 am completely against introducing any hoisting to the language, with this case being a perfect example of why. If someone needs to reference a class before it is defined, they should use a forward-declared variable as a surrogate to explicitly show that they are using a token that may or may not be defined yet.
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. Also, the presence of hoisting seems to be part of the reason why class definitions aren't allowed outside the top scope, since hoisting scoped variables can get messy. I would wager everyone would prefer to have scoped classes over class hoisting if they had to choose between the two.
Collaborator
Author
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. Without hoisting, there is no way at all to write two classes that mutually refer to one another. The top-level constraint is about establishing that there is exactly on instance of every method of every class. This makes it significantly easier for us to do static method dispatch. I'll update the RFC to clarify this. Thanks!
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. There's nothing wrong here. Classes as a statement has zero code to execute outside of the initialization. Fields do not have an initializer. Just don't let functions or locals reference any classes that are defined later. local function foo()
return Foo.new()
-- ^^^ `Foo` is not bound in scope here
end
class Foo -- now `Foo` is bound in scope
bar: Bar
function new()
return Foo { bar = Bar { x = 5 } }
end
end
print(Foo.new().bar.x) -- prints 5. It's fine.
class Bar -- now `Bar` is bound in scope
x: number
end
-- ...the rest of the module...The right way to do the hoisting is by doing something like this: class <unutterable-1>
bar: <unutterable-2>
function new()
return <unutterable-1> { bar = <unutterable-2> { x = 5 } }
end
end
class <unutterable-2>
x: number
end
local function foo()
return Foo.new()
-- ^^^ `Foo` is not bound in scope here
end
const Foo = <unutterable-1>
print(Foo.new().bar.x) -- prints 5. It's fine.
const Bar = <unutterable-2>That is, what you do is group all classes into a mutually dependent block that can reference each other unconditionally, and then and only then do you incrementally bring the name of the individual class into scope. The reason why hoisting in JS gets so much hate is because it relies on TDZ which cannot be analyzed at compile time, that's literally why TDZ exists at runtime. Here, we can warn when attempting to reference
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. That said, the RFC as is does phrase it like the solution is either-or: 1. no way for two classes to be defined out-of-order or mutually dependent in some way, or 2. do TDZ and say all class identifiers are hoisted to the top. The solution I suggested avoids this problem, as long as classes do not have any initializers.
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. Sigh. Flip the direction. Functions in class definitions can capture locals. TDZ. class Bar
function run_foo()
Foo.print_x()
end
end
Bar.run_foo()
local x = 5
class Foo
function print_x()
print(x)
end
end
Collaborator
Author
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. What you're describing is an explicit phrasing of exactly what the RFC proposes except that we lose the It's not the best but I don't see a viable alternative. I also don't know if it's really all that bad: It only arises when code is interleaving class definitions and executing imperative actions at the module scope.
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. ...So class variables are effectively globals? If that's the case, how do shadowing and global variables behave with class definitions? local A = 1 -- shadows A?
print(A) -- 1 or nil?
class A end
print(A) -- class A?
B = 2 -- global variable or parse error from reassignment?
print(B) -- 2 or nil?
class B end
print(getfenv()["B"]) -- 2?Or what if the module returns early before a class is initialized? class A
function f()
return B {} -- 50/50 chance always nil?
end
end
if math.random(0, 1) == 1 then return end
class B endEven if a set of consistent rules can be made, it just seems like a lot of footguns are going to be caused by this. |
||
|
|
||
| Static analysis also considers the class's type to be global to the whole module so that it can appear in any type annotation anywhere in the script. | ||
|
|
||
| An example: | ||
|
|
||
| ```luau | ||
| -- illegal: MyClass is not yet defined | ||
| local a = MyClass {} | ||
|
|
||
| -- OK: MyClass can appear in any type annotation anywhere | ||
| function use(c: MyClass) | ||
| end | ||
|
|
||
| function create() | ||
| -- OK as long as this function is invoked after the class definition statement | ||
| return MyClass {} | ||
| end | ||
|
|
||
| -- We can't statically catch this in the general case, but this will fail at runtime! | ||
| create() | ||
|
|
||
| class MyClass | ||
| end | ||
|
|
||
| local b = MyClass {} -- OK | ||
| local c = create() -- OK | ||
| ``` | ||
|
|
||
| Because class definition is a statement, class methods can capture upvalues just like ordinary functions do. | ||
|
|
||
| ```luau | ||
| local globalCount = 0 | ||
|
|
||
| class Counter | ||
| public count: number | ||
|
|
||
| function new() | ||
| local count = globalCount | ||
| globalCount += 1 | ||
| return Counter {count=count} | ||
| end | ||
| end | ||
| ``` | ||
|
|
||
| ### Out of scope for now | ||
|
|
||
| #### Private fields, const fields | ||
|
|
||
| These are things we want to do, but integrating them with the existing structural type system is surprisingly tricky and will be tackled in separate RFCs. | ||
|
|
||
| #### Generic classes | ||
|
|
||
| We're very excited to support generic classes and plan to introduce a fresh RFC to deal with them specifically. | ||
|
|
||
| #### Inheritance | ||
|
|
||
| We're still evaluating whether or not implementation inheritance is something we want to support. | ||
|
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. Seems to not be possible to me. Suppose: There's nothing you can do to populate the field This tells me that inheritance is pretty much impossible to implement in a way that is actually sound in Luau. 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. Is this not a problem simply solvable with types? I would expect
Collaborator
Author
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. Shadowing is certainly a huge problem that we'd have to solve. Inheritance is complex enough that it merits an RFC all by itself. |
||
|
|
||
| Method inlining is something we'd like to try that is greatly complicated by inheritance. | ||
|
|
||
| Also, frankly, its worth as a programming technique is controversial: the [Fragile Base Class Problem](https://en.wikipedia.org/wiki/Fragile_base_class) can cause significant harm to a project. | ||
|
|
||
| Lastly, while Luau doesn't quite properly afford interface inheritance through its structural type system, this shortfall is relatively easy to fix. Because of this, implementation inheritance is judged to be lower priority. | ||
|
|
||
| ## Drawbacks | ||
|
|
||
| This is a really big feature that has lots of moving parts! | ||
|
|
||
| We need to introduce multiple new contextual keywords: `class` and `public` to start and `private` later. We also introduce two new top types `object` and `class`. | ||
|
|
||
| Allowing code to grab unbound method references (ie `local m = o.someMethod`) seems risky because it opens the doorway to a lot of difficult-to-optimize dynamism, but it also makes a bunch of nice things like `pcall` work exactly the way developers expect. We're making the bet here that this does not materially affect our ability to optimize more mundane attribute access or method calls. | ||
|
|
||
| The word `class` is doing triple duty under this RFC: It is a contextual keyword, the name of a top-level library, and the name of the top type for class objects. | ||
|
|
||
| Object oriented codebases tend to have far more cyclic dependencies between modules because every piece of data is also coupled to a whole bunch of functions that operate on that data. We are probably going to have to work out a way to relax the restrictions on cyclic module imports. | ||
|
|
||
| ## Alternatives | ||
|
|
||
| [Arseny's record proposal](https://github.com/luau-lang/luau/blob/7f790d3910bbfc2adf007da3551b0a13e42ebb7a/rfcs/records.md). This proposal is really quite similar, but looks a bit more familiar to users coming from other languages and affords the development of features like private fields. | ||
|
|
||
| [Shared Self Types](https://github.com/luau-lang/rfcs/blob/master/docs/shared-self-types.md). This proposal was intended to shore up table type inference in the case that the code was written in an OO style, but after significant work, it doesn't actually work all that well in practice. The resulting system was very brittle and tricky to work with. Trickier, in fact, than the pattern that developers are already writing today. | ||
|
|
||
| ### Field syntax | ||
|
|
||
| We considered a number of possibilities for field syntax before settling on `public` and `private`. | ||
|
|
||
| Type annotations must be optional, so the syntax we choose must work well in that situation. | ||
|
|
||
| We considered using the existing `local` keyword for class fields, but judged it to be a bridge too far: Fields are not locals! They do not inhabit the stack. | ||
|
|
||
| We also considered using no keyword at all and judged that to be unacceptable in the case that a field also has no type annotation. A single bare identifier on a line all by itself looks too weird and might make the grammar difficult to change later on. | ||
|
|
||
| So there must be a keyword and it cannot be `local`. Other keywords we considered were `var`, `let`, and `field`. | ||
|
|
||
| ```luau | ||
| class Test | ||
| local foo | ||
|
|
||
| public foo | ||
| public foo2: number | ||
|
|
||
| private local foo2: number | ||
| private foo3: number | ||
|
|
||
| var bar | ||
| var bar2: number | ||
|
|
||
| field bar | ||
| field bar2: number | ||
|
|
||
| quux | ||
| quux2: number | ||
| end | ||
| ``` | ||
|
|
||
| `let` is a great keyword to introduce a local binding, but does not make a ton of sense in a class declaration. (especially a declaration where we’re not providing an initializer!) | ||
|
|
||
| `var` is pretty good and also makes sense as a token to introduce a local, but is largely redundant. | ||
|
|
||
| `field` is spiritually aligned with the syntax sensibilities that drove the development of Lua, but has no precedent in any other language. | ||
|
|
||
| `public` and `private` work pretty well from a parsing perspective, have no historical precedent in Lua, and also encode ideas that we definitely want classes to support. | ||
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.
It'd be nice to be able to write
class MyClass: SomeSignature & SomeOtherSignature ... endas a way to make sureMyClasssatisfies these requirements (or to unify with any metavariables in the class fields/methods).