A new object-oriented programming idiom, and why you should ditch the old one!

There is a common idiom followed by developers when implementing the object-oriented paradigm in Luau, it looks as follows:

local class = {}
class.__index = class

function class.new(x: number, y: number)
    local self = setmetatable({}, class)
    self.x = x
    self.y = y
    return self
end

function class:addXY()
    return self.x + self.y
end

return class

This is a standard example of a class in Luau. For the purpose of the example, the class takes 2 numbers as arguments upon initialization and exposes a method to add those 2 numbers together. You would use the class like so:

local object = class.new(5, 34)
local result = object:addXY()
print(result) --> 39 (5 + 34)

This is how we should be creating classes:

local class = {}
class.interface = {}
class.schema = {}
class.metatable = {__index = class.schema}

function class.interface.new(x: number, y: number)
    local self = setmetatable({}, class.metatable)
    self.x = x
    self.y = y
    return self
end

function class.schema.addXY(self: class)
    return self.x + self.y
end

type class = typeof(class.interface.new(table.unpack(...)))

return class.interface

The problem with the previous idiom

There are several flaws I have identified with this idiom, I’m going to go over each individually then I will explain how I’ve overcame these issues. You will notice that the object initialized from the class has several useless type inferences that really should not be exposed to the programmer.

I will briefly explain each issue below, then I will go over how to fix them.

Issue 1 - .new().new()

.new() is the constructor and should not be listed as a member of the object!

Issue 2 - .__index

It is not obvious to me why the developer would ever need type suggestions for the existence of .__index in their objects. To remove this issue, it is as simple as explicitely defining the __index in the metatable rather than in the main class’s table. This will be shown in later examples.

image

Issue 3 - No type inference when writing methods of class

When writing the functionality of your class, you are offered no type suggestions about the object itself.

It only shows me the attributes and methods its inferred from the __index, leaving me in the dark about what self actually contains. In the case of this example, it should also show me the existance of the attributes x and y, but it does not!

So how do we solve these issues

local class = {}
class.interface = {}
class.schema = {}
class.metatable = {__index = class.schema}

As you can see my class definition is split into three subcategories:

interface - the table which is exposed to the end-user of the class. It will often include the constructor of the class

schema - the __index of the initialized objects, often containing methods/common values/etc. The constructor included in the interface will return an object which has it’s __index set to this table. Meaning the object will have access to all members of this table.

metatable - In my original post, the metatable was defined in the constructor as local self = setmetatable({}, {__index = class.schema}). Someone mentioned there was a performance overhead to creating a new metatable on each construct. To solve that issue I’ve just described the metatable in the main scope, so it is created once, and re-used for all object instantiations. This still gets rid of Issue #2!

Because the interface and schema are two seperate tables, this solves the issue where the constructor was inferred as part of the object.

function class.interface.new(x: number, y: number)
    local self = setmetatable({}, class.metatable) --> defining __index rather than in the class solves issue #2
    self.x = x
    self.y = y
    return self
end

Due to the fact that the constructor now exists in the interface, the object returned from said constructor will have an __index that has no knowledge of the existence of interface as it is it’s own unique table, effectively removing the .new().new() issue.

Now let’s define our method:

function class.schema.addXY(self: class)
    return self.x + num
end

Now, you will notice a few things here. One, I did not use the typical : operator when defining a method. Instead, I used . and explicitly defined self as the first argument. This is precisely what : was already doing, except in my case I want to do it manually in order to assign the class type to self. This is what gives me type inference when writing the functionality of my class.

As you can see, when writing code inside of a method, I can see exactly what methods and values exist within the object. This is because I’ve constructed a type called class which you can see being assigned to the first parameter of the method self. You’ll also notice the autocomplete suggestions don’t include the previously listed code smells. Here is how it is created:

type class = typeof(class.interface.new(table.unpack(...)))

You might be confused as to why I included table.unpack(...). This is because you will get a warning in your script editor when you do not explicitly define each individual required argument expected to be passed to the constructor. You can manually define these by hand, or use table.unpack(...) as a shortcut to handle the error. Keep in mind that if your constructor has no required arguments, you should not include any arguments in the definition of the type at all otherwise you will also get an error.

Voila!

We’ve fixed these issues of the original idiom and developed a new one that is even more powerful!

Old:

New:

Not only does the object look much cleaner to the end-user, but I also have the added benefit of type inference when writing inside of my methods as shown above. In my opinion this as far more powerful to design classes with this idiom.

Inheritence:

38 Likes

You should put this in #resources:community-tutorials, and because they show __index in autocomplete, it makes your class look weird. I just hate having to assign __index to the table every time the constructor is called, rather than having it referenced once.

The issue with this method is that you are going to pay for the overhead of creating a new table with {__index = class.scheme}, and you won’t get any type inferences inside the constructors/methods.

What I do is this clever way of getting Type Inference to work. I did make a tutorial about doing this another way, but this is superior. The only reason I haven’t updated it is because of this bug.

local Class = {}
Class.__index = Class

type self = {
	X: number,
	Y: number
}

export type Class = typeof(setmetatable({} :: self, Class))

function Class.new(X: number, Y: number): Class
	local self = setmetatable({} :: self, Class)

	self.X = X
	self.Y = Y

	return self
end

function Class.AddXY(self: Class): number
	return self.X + self.Y
end

Theoretically you could do some table redirecting to get rid of the obsolete recommendations, but you’ll just be shooting yourself in the foot if you go in that direction.

1 Like

I guess if you are in need of the extra performance, you can include the __index in the class.scheme . It won’t make much of a difference to my idiom other than __index being inferred as a member of the object. But if you must worry about such micro-optimizations, you’re probably best not taking a class-based approach to begin with.

Edit: I’ve updated the post, and defined the metatable in the main scope rather than in the constructor so only one metatable is created, rather than one each time the constructor is called. This solves your concerns in regard to performance, while also getting rid of __index as part of the type inference.

Both your method and mine are achieving the end result of type inference in the constructor and methods.

Thanks, I’ve updated the category of the post. Also, to your point about __index showing in the autocomplete being a nuisance, I agree, but fortunately it’s not that inconvenient to fix. Surely the solution doesn’t make that big of a difference unless you’re working on something that is performance intensive in which case, I would advise against a class-based approach entirely. I’m not sure if they will ever blacklist __index from type inference because it’s not technically wrong in displaying it as a member of the object as you explicitly set the __index to itself.

Edit: The “solution” stated previously has been updated, the metatable is now defined in the main scope rather than in the scope of the constructor. Meaning only one metatable is instantiated rather than one each time the constructor is called, resolving your concerns about assigning __index each time the constrcutor is called.

I think I’m going to start writing my classes like this from now on. I really like the naming conventions afforded here (interface and schema) and the thread makes very valid remarks about the common idiom and why, especially if you’re big on using types, it’s no good. Ultimately not the hugest problem and the current idiom is fine but this can go a fair way in writing readable code.

The problem of the constructor being listed as a member of an object always set off a red alarm in my head that I ignored most of the time. Sometimes I would try and sidestep the issue by keeping a single table dedicated to the constructor and a separate one where I would put my class members so the constructor wouldn’t carry over but those weren’t the best so whenever I had to use OOP I would write it the common way. Less tables, less thinking.

I can afford to bite a couple more small tables. After all, my major projects are almost entirely comprised of huge amounts of tables due to using a single-script architecture approach so pretty much the number of tables in memory equals the number of scripts I have (over 2.5k).

4 Likes

Clever and revolutionary way of OOP. I actually like this. But I wont switch yet until many people had good experiences with this method.

1 Like

The only problem with this method is that you create a new table every time.

2 Likes

There is only 1 table being created each time you initialize an object. This is the same as the old idiom.

1 Like

So, I’ve been testing with both methods, and both have their pros and cons.

Mine

  • Pros:
    Optimized (Less Allocations)
    Pre-defined prototype type
    Inheritance support (Sort of)

  • Cons:
    Slightly overcomplicated
    Exposed internals

Your’s

  • Pros:
    No internals exposed
    Slightly more readable

  • Cons:
    Tripled dictionary allocation
    You have to use assertions to apply types inside the constructor
    Inheritance is going to be a pain to make it work. Variadic arguments freak out with combined metatables.
    Cyclic types form frequently, while mine just infers it as Class

In all, I don’t think there is a way to get the best of both worlds. All we can do is wait until proper OO comes to Luau (@zeuxcg :wink:).

While there is no doubt my method has more allocations, they are all completely optional. If squeezing out that extra bit of performance is necessary, you can merge all 3 tables together (like the standard idiom). But that defeats the purpose of this idiom, which was to rid developers of the code smells that came with the standard method. Merging the tables together would bring back issue #1 and issue #2, which there is no other alternative to solve. But if in the name of performance you must, feel free to do it. Lastly, if you must make such micro-optimizations, I advise you to ditch OOP entirely.

Would you mind explaining what the benefits of this are? The only place I see it being used is inside the constructor, which leads me to my next question. Why predefine the properties of your object to then redefine them in the constructor? I think it’s better to let the constructor serve as your “prototype” and if you must separate the prototype from your schematic, use a function that returns the prototype only. You can use that function to construct your “prototype type” and inside of the constructor to be less redunant. But still, it’s not obvious to me why you would want to do this.

I’m not sure where I would have to use assertions in the constructor. What do you mean by this?

Perhaps it would be better if you shared your experiment. Here is mine, which proves inheritence is entirely possible with this idiom without the undesired effects you’ve described:

local superClass = {}
superClass.interface = {}
superClass.schema = {}
superClass.metatable = {__index = superClass.schema}

function superClass.interface.new(x: number, y: number)
	local self = setmetatable({}, superClass.metatable)
	self.x = x
	self.y = y
	self.z = 5
	return self
end

function superClass.schema.addXY(self: superClass)
	return self.x + self.y
end

type superClass = typeof(superClass.interface.new(table.unpack(...)))

return superClass
local parentClass = require(script.Parent)
local subClass = {}
subClass.interface = {}
subClass.schema = {}
subClass.metatable = {__index = subClass.schema}
setmetatable(subClass.schema, parentClass.metatable)

function subClass.interface.new(x: number, y: number)
	local self = setmetatable(parentClass.interface.new(x, y), subClass.metatable)
	self.a = 9
	return self
end

function subClass.schema.addZA(self: subClass)
    return self.z + self.a
end

type subClass = typeof(subClass.interface.new(table.unpack(...)))

return subClass.interface

image

I’ve just noticed Roblox script editor is not inferring the subclass types only the super class types. Roblox LSP handles this just fine. Going to look into this more and see if I can get it to work for both. However, the code is running properly, it’s just the type suggestions are slightly incorrect.

This is why I warned about this being a major footgun. Let’s go over the problems there are with it now:

The reason why having a dettached prototype type is desired is because it allows us to get add and propagate “dynamic” properties, i.e. in my implementation I can do

local Class = {}
Class.__index = Class

type self = {
	X: number,
	Y: number,
	Z: number?
}

export type Class = typeof(setmetatable({} :: self, Class))

function Class.new(X: number, Y: number): ()
	local self = setmetatable({} :: self, Class)
	
	self.X = X
	self.Y = Y
end

function Class.SetZ(self: Class, Z: number)
	self.Z = Z
end

function Class.AddXYZ(self: Class): number?
	if self.Z then
		return self.X + self.Y + self.Z
	end
	
	return nil
end

With inheritance, ParentClass now has to return all its dictionaries instead of its Interface for you to get its Metatable in SubClass, so SuperClass.new() now has to be written as SuperClass.Interface.new().

Relying solely off the Type-Inferer will give you wacky cyclic results. Not only are these bulgy when you’re typing but also laggy. Everytime you do Class.Interface.new(), the argument field gets populated with.

t1 where t1 = { @metatable {| __index: {| AddXY: (self: t1) -> number| } |}, {| X: number, Y: number |} }

The issue with methods from SubClass is something my implementation suffers too. See the bug report I linked in my first reply.

Also, what’s with the table.unpack(...)? ... will work just fine for types, unless it’s a Roblox LSP specific thing.

2 Likes

Cool fact; Luau dynamically allocates captures to the same memory location if it has no upvalues (except for the main function).

This basically wipes out the need for metatables since the declared functions under a new object will use the same function reference.

2 Likes

While this is true, it still needs to keep the reference itself which does take more memory than the idiomatic method- just not as much as before.

1 Like

I’m going to be perfectly honest- if you really want this, just use roblox-ts. This is pretty much just writing out how classes work, but in Luau.

My issue with this is the boilerplate. Personally, I do my best to avoid any boilerplate code. It does nothing but clutter- and this undeniably gives more clutter.

It is also a pain to change. All in all, great solution! However I don’t think that the idiomatic oo solution is changing.

1 Like

Thanks for your feedback. Regarding the excessive boilerplate, there is no doubt that it takes longer to write out and adds more clutter to your code. I’ve put all the boilerplate in a module that returns the class object, so I don’t need to write this out each time, significantly reducing the clutter. Also, you can look at it this way; A little extra clutter within your codebase for less clutter presented to the end-user of your class.

1 Like

table.unpack(…) is a Roblox LSP thing. Just doing ... assumes I’m only passing 1 argument and shows a warning if it expects more.

As for the dynamic properties, I often union a type to the class type which contains additional information that was not described in the constructor.

type class = typeof(class.new(table.unpack(...))) | {
    dynamicProperty: string?
}

To me this is a non-issue, although I understand this adds extra clutter to your code. I’ve just grown accustomed to it.

To me this looks like the exact definition of the object. In your case, you’ve given the object an alias, so it just appears as “Class”, but it’s still doing this internally, is it not?

Hopefully this gets fixed soon.

1 Like

Notice: If you use Roblox LSP then you don’t have to jump through these loopholes to pull it off. The bug that this is working around doesn’t exist in Roblox LSP.

For anyone who may have been wondering how to pull off inheritance with this paradigm, I’ve figured out how. Originally there was a bit of a struggle in doing so due to a bug with Luau. More on that HERE.

Anyways, in the meantime, you can follow this tutorial to pull off inheritance. Essentially, I’ve found a way to bypass this bug by individually constructing the types instead of relying on using typeof() on the object returned from interface.new() since the bug with Luau happens when you have multiple metatables. I’ve slightly restructured the paradigm for introducing inheritance. This was the ONLY way I could find to bypass it. There were several methods I tried that slightly fixed the issue but would still result in unexpected bugs. For example, one method fixed inheritance when working inside of the subclass, but for external scripts requiring the object, the type was only inferred from the super class. Some methods did the exact opposite. This one got it just right. Eventually, once they fix the bug, you will be able to implement inheritance the standard way, but for now, we have this, and to be fair, it’s not that bad.

local super = {}
super.interface = {}
super.schema = {}
super.metatable = {__index = super.schema}

function super.prototype(self)
    self.x = 5
    self.y = 2
    return self
end

function super.interface.new()
    return setmetatable(super.prototype({}), super.metatable)
end

function super.schema.addXY(self: super)
    return self.x + self.y
end

type super = typeof(super.prototype()) & typeof(super.schema)
export type Type = super

return super
local super = require(script.Parent)
local sub = {}
sub.interface = {}
sub.schema = {}
sub.metatable = {__index = sub.schema}

function sub.prototype(self)
    self.z = 20
    return self
end

function sub.interface.new() 
    local self = setmetatable(super.interface.new(), sub.metatable)
    sub.prototype(self)
    return self :: sub
end

function sub.schema.addXYZ(self: sub)
    return self:addXY() + self.z
end

type sub = super.Type & typeof(sub.prototype()) & typeof(sub.schema)

return sub.interface
print(require(script.test.sub).new():addXYZ()) --> 27
4 Likes

What exactly does this do?
I tried printing it out manually and only got “table”.
How does this get all the info about the class?

It converts the returned table into a type. Here is another example of how typeof() can be used:

local myTable = {}
myTable.x = 5 
myTable.y = 10

type myType = typeof(myTable)

-- or, to better suit the context of your question:

local function test()
    return myTable
end

type myType = typeof(test())

If you are wondering why I do table.unpack(...), here is why:

When constructing types this way, if your function has required arguments (arguments with explicitly defined types (ex: myFunction(test: number, test2: boolean) you will receive a warning if you don’t pass in the arguments with the correct type expected by the function. To counter this, In Roblox LSP, you can do (...) to silence a function call with 1 required argument, and table.unpack(...) to silence functions with multiple required arguments. If you are using Roblox Studio or Luau LSP to code, then you can use (_) to silence function calls with 1 required argument, and (...) to silence those with any amount of arguments (even 1).

Hope this helped :slight_smile:

1 Like