Class Constructors#210
Conversation
|
Honestly I'm kind of torn by this RFC, it defines a lot of new rules and restrictions just to work around the problem that comes with inheritance, can't a |
Could you explain how a The restrictions exist to ensure that you can't interact with an object that isn't fully constructed yet. If you write a field |
|
This adds too much complexity to the language, comes with a bunch of restrictions and rules you need to know to use properly, and isn't obvious. An Inheritance is a controversial feature that's discouraged in modern language design, and I don't think that supporting it like this is the right choice for Luau. Has a different approach like composition been investigated? |
My comment on A Assuming functions with class Base
public foo: string
static function new(newObj, str: string)
if not newObj then
return Base { foo = str }
end
newObj.foo = str
end
end
class SubFromBase extends Base
static function new()
local newObj = SubFromBase { } -- Assuming this is allowed, you move the magic from __init to here
Base.new(newObj)
-- Add and initialize other properties as you would
end
endFor every child of |
|
|
||
| ## Alternatives | ||
|
|
||
| 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: |
There was a problem hiding this comment.
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.
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()Foo() now applies magic (the same in the proposed __init) and constructs a new, but uninitialized instance of the class. This instance is then passed to the constructor, in which the function can use to initialize its fields.
However, this constructor can also be called with another object which is made within a subclass, in which the constructor's self parameter points to this object, rather than a new instance of Foo.
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")There was a problem hiding this comment.
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. class Point(x: number, y: number) end.
There was a problem hiding this comment.
It feels similar to the proposal in this RFC, but with better semantics and you can still use .new() however you please. I wonder if this is worthy of an amendment RFC, or should be included in this RFC instead.
|
I don't understand the rationale with It feels like in the battle between two options (
Something worth noting is Python relatively quickly (in the scheme of python development times) realised this wasn't ideal, and ended up with
Are we walking a slippery road with starting to make variable names formally required? If the first argument to a function must be named
This seems hard to enforce? Is there a reason it's required? The base class would be assuming an empty table, so isn't going to be reading from fields anyway? If default field values happen, it feels like we have added value by allowing a subclass to overwrite the already-initialised defaults before calling a base constructor? I do agree most uses would invoke the superclass constructor as the first step of their constructor, but is it a required restriction?
This almost points towards "we have default values, but the only default you can assign is I only raise this because this is the sort of exception that shouldn't really be walked back once released (though as I understand it this enforcement is only via types, so absolutely could have "breaking" changes). -- In terms of general implementation inheritance, how does Also, if a subclass defines no new fields and does not need additional initialization, must it still define a constructor that simply delegates to the parent constructor? |
I don't think the first parameter must always be named I pretty much agree on the other points, incredibly weird and complex workaround just to achieve constructors and support inheritence. Semantics could've been so much better if table initialization did not exist and instead of |
|
I also think that mirroring Python isn't the way to go. -- Has constructor:
T.new(...)
-- Doesn't have constructor:
T { x = 1, y = 2 }I'm also not sure why |
|
I've noticed that the class instance initialization method in POD does not work well with constructors, especially with inheritance. Because of this, I'd like to propose alternative syntax and semantics for constructors and class instance initialization. While this requires possibly amending the already existing class syntax, it could be better for the feature overall. Instead of
If there is no constructor (
|
This feels like really unintended behaviour. Regarding inheritance, the first example given is imo untenable because of the exact issue raised there: constructor arguments. I think having to call I also agree with the addition of super, but how it would be done I'm not sure. I think super, in whatever shape it takes, should refer to the parent class, not directly to its constructor, because then it can also be used to call parent implementations of methods that have been overridden in a child. |
Honestly this could be avoided if we had default values for fields. As mentioned in the RFC, in certain cases types may not match the values, especially when the
|
|
One challenge with super that just came to mind is the types. Given I don't think the plan is to have some magic base local sup = super()
if sup then sup.__init() endI unfortunately don't have a good solution for that in mind. The base -- In terms of |
|
In my own class system, That aside, now that I'm thinking about it, if we had const super = class.super
class Base
function __init(self)
end
end
class Derived extends Base
function __init(self)
super(Derived).__init(self)
end
endStill unsure though. |
This is a very confussion restriction to impose with no justification presented. I would instead propose that using -- T { ... }
function class_mt.__call(class, ...)
local self = {}
class.__init(self, ...)
return self
end
-- T.__init(self, args)
function class.__init(self, args)
self.x = args.x
self.y = args.y
endThis maintains the current behaviour of using a table call to create a new instance of a class while permitting new implimentions to extend behaviour. There is no requirement for this implimentation to use table calls as one may instead use In terms of typing for super classes, I think Assuming the above, I want to suggest that any replacement init method must refine the original self argument into a form which fulfils the class type, or else results in a lint error. There is no requirement that the parent init is called; however, if it is not, then the child init is now responsible for completing the initialisation of the member fields. Additionly, there is no restriction on reading or writing of any member fields, but they will be typed as nil-able if they have not been refined by the parent init. Apologies for the use of typescript, luau has no "assert type predicate", but I would imagine it as the following: function __init<
Self extends Partial<{ x: number; y: number }>
> (
self: Self,
args: { x: number; y: number },
): asserts self is Self & { x: number; y: number };I saw there was another RFC which suggested the addition of type predicate functions, and so that may need to be revisted to enable this kind of type refinement on arguments. But anyway, it would work something like so within lua: class Point2D
public x: number
public y: number
-- no __init implimented, the default is used, which matches the metatable example above
end
class Point3D extends Point2D
public z: number
function __init(self, args: { x: number, y: number, z: number })
-- self: { x: number | nil, y: number | nil, z: number | nil }
Point2D.__init(self, args) -- It is still callable as a default is generated or inlined
-- self: { x: number, y: number, z: number | nil }
self.z = args.z
-- self: { x: number, y: number, z: number }
-- because self is the correct type, no lint error is rasied about incomplete field init
end
endAs an extension, it is possible that Unoptimised bytecode where __init is never inlinedOptimised bytecode where calling the parent requires a custom implimentionFully optimised bytecode where calling the parent is "default" |
|
|
||
| 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. |
| class A extends B | ||
| public x: number | ||
|
|
||
| function __init(self, x, y) |
There was a problem hiding this comment.
This is so much like Python's __postinit. Then you'd only write the last line there, and the constructor call probably has to invoke this metamethod anyway. Then there's no need for T.new being sugar for __init (not to mention what happens if the function new exists along with __init).
There was a problem hiding this comment.
As in a dataclasses __post_init__? It feels much more akin to __init__ given a DC's __post_init__ only runs after initialisation, where you'd do (in Luau class terms) A { x = 1, ... }, rather than a custom A(some, arguments, here) constructor, then the post-init gets called on an already-populated object. This proposal seems to be focusing on self being entirely uninitialised at the start of __init, which would just be a normal __init__ on a python class.
There was a problem hiding this comment.
Sorry, __post_init__ from Python's @dataclass, yeah. But re-reading this RFC, this specific class is better done with __post_init__ but the general problem is still not solvable with that one. Constructors vs new really does need to exist (kinda).
| class A extends B | ||
| public x: number | ||
|
|
||
| function __init(self, x, y) |
There was a problem hiding this comment.
Sorry, __post_init__ from Python's @dataclass, yeah. But re-reading this RFC, this specific class is better done with __post_init__ but the general problem is still not solvable with that one. Constructors vs new really does need to exist (kinda).
| public name: string | ||
|
|
||
| function new(): DerivedPoint | ||
| -- We're stuck! We cannot implement this function in terms of BasePoint.new() |
There was a problem hiding this comment.
I don't think so? DerivedPoint { x = 1, y = 2, name = "hello" }. The difference is that if the superclass has some field x and the subclass also has some field x, then that subclass must shadow that field x. This is only a problem if you need the side effects from BasePoint.new() or it has private fields. And in both cases, the problem stems entirely from inheritance in the first place.
|
|
||
| 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 |
There was a problem hiding this comment.
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.
Introduce class constructors via special .__init and .new() methods.
Rendered