-
Notifications
You must be signed in to change notification settings - Fork 95
Class Constructors #210
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?
Class Constructors #210
Changes from all commits
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,111 @@ | ||
| # Constructors | ||
|
|
||
| ## Summary | ||
|
|
||
| Add user-definable constructors to Luau classes. | ||
|
|
||
| ## Motivation | ||
|
|
||
| The initial class RFC specifies that all classes can be constructed by invoking the class object as a function with a mapping from fields to values as its sole argument. | ||
|
|
||
| This is great for "plain old data" classes and easily allows users to write their own static `.new()` method if they have more exotic construction requirements, but this falls apart in the face of inheritance because a `.new()` factory necessarily couples the actual class instance construction with the field initialization. | ||
|
|
||
| Concretely: | ||
|
|
||
| ```luau | ||
| class BasePoint | ||
| public x: number | ||
| public y: number | ||
|
|
||
| function new(): BasePoint | ||
| return BasePoint { x=0, y=0 } | ||
| end | ||
| end | ||
|
|
||
| class DerivedPoint extends BasePoint | ||
| public name: string | ||
|
|
||
| function new(): DerivedPoint | ||
| -- We're stuck! We cannot implement this function in terms of BasePoint.new() | ||
| end | ||
| end | ||
| ``` | ||
|
|
||
| Implementation inheritance requires some mechanism by which a class constructor can do the work only to initialize its part of the object for some class that is at some indeterminate point in an inheritance hierarchy. | ||
|
|
||
| This is a very well-known problem. The well-known solution is constructors. | ||
|
|
||
| ## Design | ||
|
|
||
| We draw inspiration from Python and allow classes to replace the builtin constructor by defining an `__init` method: | ||
|
|
||
| ```luau | ||
| class A extends B | ||
| public x: number | ||
|
|
||
| function __init(self, x, y) | ||
|
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 so much like Python's 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. As in a dataclasses
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. Sorry, |
||
| B.__init(self, x) | ||
| self.x = x * y | ||
| end | ||
| end | ||
| ``` | ||
|
|
||
| If a class defines an `__init` function, it is understood to be a constructor. Classes with constructors follow different rules. We define class construction as follows: | ||
|
|
||
| Classes that have constructors cannot be constructed via `T()` syntax. Instead, the static method `.new` must be invoked. Classes that do not define constructors are still initialized via `T {...}` syntax. | ||
|
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. Oof. :( |
||
|
|
||
| Let `T` be a class object that defines a constructor. When `T.new(...args)` is invoked with any arguments, the following happens: | ||
|
|
||
| 1. A fresh, uninitialized instance of `T` is allocated. We'll call it `t` here. All of its fields are initially `nil` irrespective of any type annotations. | ||
| 2. `T.__init(t, ...args)` is invoked. | ||
| 3. `t` is produced as the result of the expression | ||
|
|
||
| Classes are forbidden from expressly defining a `.new` method. It is reserved by the language. | ||
|
|
||
| ### Rules of Constructors | ||
|
|
||
| In order to make uninitialized data unobservable, constructors are required to follow some strict rules: | ||
|
|
||
| * The first argument of a constructor must be `self`. | ||
| * A class must define a constructor if its base class defines one | ||
| * If the base class defines a constructor, the class constructor must invoke it before reading or writing to `self` or any of its properties | ||
| * A constructor must unconditionally initialize all of its fields before it can pass `self` to any function. | ||
| * The delegating call to the base class constructor is of course exempt from this. `BaseClass.__init(self, args)` | ||
| * This restriction also includes all method calls (eg `self:something()`) | ||
| * For v1, type inference does not attempt to track fields that are initialized within conditional constructs like `if` statements or loops. Fields must be initialized directly at the function scope. | ||
| * As a special exception, fields whose types are supertypes of `nil` are exempt from this requirement and are always considered to have been implicitly initialized with `nil`. Explicit initialization of such fields is of course permitted. | ||
| * Additionally, any subexpression where `self` is explicitly cast is exempt from this. (eg `foo(self :: any)` or even `foo(self :: FooType)`) We offer this as an intentional way for a developer to override the type system if they need to. | ||
| * A constructor must not refer to any field before it has been initialized. | ||
|
|
||
| Once the base class has been called and all fields are initialized, constructors can do anything. | ||
|
|
||
| These rules are all enforced only by type checking. When the program is run, uninitialized fields will have the value `nil` no matter what their types might say. | ||
|
|
||
| ## Drawbacks | ||
|
|
||
| Constructors add more complexity to the language. Some classes can be initialized via `T {x=x, y=y}` syntax and others must be initialized with different kinds of arguments. | ||
|
|
||
| We intentionally only check that fields are initialized in type checking. This means that our runtime still has to be able to cope with fields that have been left uninitialized. | ||
|
|
||
| Some developers will feel inconvenienced by the restrictions on uninitialized class fields. The current rules do not, for instance, permit a developer to write a helper function that partially (or completely) initializes a new class instance. | ||
|
|
||
| ## Alternatives | ||
|
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. Another alternative (that I already liked, but am starting to really like it) is "Do not support inheritance" and now this entire category of problems are gone. |
||
|
|
||
| We could follow in Python's footsteps and do away with the default `T{}` constructor, but this means that developers have to write a lot of dull code in the typical "plain old data" case: | ||
|
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 would actually prefer this even more, as the way "default table constructors" for classes currently work really irritates me. First, as I've said before, the current design conflates the idea that the fields of a class are also its constructor. This poses awkward questions regarding how private and static fields are treated and is just a big mismatch with the current syntax. Languages that lay out their class fields in a spreadsheet manner make the programmer write out the constructor by hand, but Luau doesn't and generates one automatically, which is weird. Second, the table constructor protocol requires at a bare minimum to allocate a throwaway table that is then passed to a C function that then sanitizes and allocates the real object from it. This implies that baseline performance for construction will always be worse than just allocating a regular table. The justification that future compiler heroics will cut back on the performance hit feels more like a band-aid solution, and I'd expect a stronger foundation for the runtime side than that. Third, this RFC is proposing an alternative constructor protocol that supersedes most if not all uses for the default table constructor. If that's the case, why even support table construction to begin with? If someone feels like the constructor boilerplate is "pointless ceremony" for a POD datatype, then maybe the syntax for classes needs to be revisited to incorporate the idea of default constructors better. 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. Honestly, I think a C++ like syntax would be more ideal, where the class object can still be called like a function, however you do not allocate a table for initializing the fields. Instead, you pass arguments to this function, which if defined, will pass these arguments to a constructor. If there is no constructor, then the fields will not be initialized, and therefore nil. class Foo
protected x: number
public function Foo(self)
self.x = 51
end
end
class Bar extends Foo
public function Bar(self)
Foo(self)
end
end
local newFoo = Foo()
local newBar = Bar()
However, this constructor can also be called with another object which is made within a subclass, in which the constructor's One problem with this could be that there is an ambiguity between the argument types. To solve that problem, Luau could check whether or not the first argument to the class function is an object which is made from a subclass. class Foo
protected x: number
public function Foo(self, x: number)
self.x = x
end
end
class Bar extends Foo
private y: string
public function Bar(self, x: number, y: string)
Foo(self, x)
self.y = y
end
end
local newFoo = Foo(51)
local newBar = Bar(51, "Hello")
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, that's sort of what I had in mind as well, minus the inheritance bit. Anything but a default table constructor so that the class can be truly encapsulated should private and static fields be added. And for POD cases, "record"-style syntax sugar could be added which generates a constructor automatically, i.e. 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 feels similar to the proposal in this RFC, but with better semantics and you can still use |
||
|
|
||
| ```luau | ||
| class Point | ||
| public x: number | ||
| public y: number | ||
|
|
||
| -- This whole function is pointless ceremony | ||
| function __init(self, x, y) | ||
| self.x = x | ||
| self.y = y | ||
| end | ||
| end | ||
| ``` | ||
|
|
||
| We could make field initialization more logical by adding C++-style initializer syntax. We're already adding a lot of syntax and I don't think it's worth it for us. | ||
|
|
||
| The choice to construct instances via a `.new` static function is motivated by Lua libraries that effect objects. We sacrifice the ability for classes to define their own `.new` method but in exchange, code using classes looks more consistent with what came before. | ||
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.
I don't think so?
DerivedPoint { x = 1, y = 2, name = "hello" }. The difference is that if the superclass has some fieldxand the subclass also has some fieldx, then that subclass must shadow that fieldx. This is only a problem if you need the side effects fromBasePoint.new()or it has private fields. And in both cases, the problem stems entirely from inheritance in the first place.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.
This argument only stands with "closed universe" assumption when whole hierarchy defined in the same code base. You can image some
LibBaseClass(e.g. ReactElement) andUserExtensionClass extends LibBaseClass. In this case every update of library code with addition of fields toLibBaseClasswill breakUserExtensionClassdefinition.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.
Yeah, but I'd also argue that the open world assumption is problematic, hence why I suggested this.
Consider a superclass with a bunch of fields and methods with default implementation. If someone extends from it, and adds a field that shadows the field in the superclass, then all methods defined in the superclass that depends on that field are now logically broken.
If there's no open world assumption, which is what inheritance implies, then this entire category of problems disappears.