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

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.

1 Like

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.

1 Like

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.

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.

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.

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.

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

This is a great solution i really never found something this nice for inheritence, however i can’t seem to access methods in the super class from the sub class or the server script that creates an instance of the sub class:
Super Class:

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

local errorSignature = "{AI 2.0} - ERROR : "

-- Local Functions --
local function clearTable(table)
	for k in pairs (table) do
		table [k] = nil
	end
end

-- Constructor --
function AI.prototype(self)
	-- Physical Propieties --
	self.Rig = nil
	self.Humanoid = nil
	self.PrimaryPart = nil

	return self
end

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

-- Class Functions --
function AI.schema.isDead(self: AI)
	return self.Humanoid.Health <= 0
end

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

return AI

Sub Class:

local ServerScriptService = game:GetService("ServerScriptService")
local ServerPackage = ServerScriptService.ServerPackage
local TiltAt = require(ServerPackage.Entities.TiltAt)

local AI = require(script.Parent)
local E_AI = {}
E_AI.interface = {}
E_AI.schema = {}
E_AI.metatable = {__index = E_AI.schema}

local errorSignature = "{Entity AI 2.0} - ERROR : "
local warnSignature =  "{Entity AI 2.0} - WARN : "

function E_AI.prototype(self,Rig:Model,Stats)
	-- Physical Propieties --
	self.Rig = Rig or error(errorSignature.."A Rig is required to use the AI Class.")
	self.Humanoid = self.Rig:FindFirstChild("Humanoid") or error(errorSignature.."The Rig must have an Humanoid.")
	self.PrimaryPart = Rig.PrimaryPart
	self.Head = self.Rig:FindFirstChild("Head") or nil
	
	return self
end

function E_AI.interface.new(Rig:Model,Stats) 
	local self = setmetatable(AI.interface.new(),E_AI.metatable)
	E_AI.prototype(self,Rig,Stats)
	return self :: E_AI
end

function E_AI.schema.MoveTo(self: E_AI,position: Vector3)
	print(self:isDead())
	self.Humanoid:MoveTo(position)
end


type E_AI = AI.Type & typeof(E_AI.prototype()) & typeof(E_AI.schema)

return E_AI.interface

Script:

local AI = EntityAIClass.new(workspace.Entities.Joe,{})
print(AI:MoveTo(Vector3.new(0,0,0)))
print(AI.isDead())

Both method calls print out an error:
isDead

Sorry if this is such a goofy question but how does your text editor look so insane?

To me it looks like Microsoft Visual Studios IDE so I don’t think it is Studios script editor

you forgot to do setmetatable(E_AI.schema, AI.metatable) in class E_AI. and you must also call isDead with a colon print(AI:isDead()).

1 Like

Regarding the main topic, the problems you point out are clearly of the tools, the code editor, and not of the language or OOP itself.
The tools that support a language, usually have many more updates than a language, so their problems, in this case of autocompletion or suggestions, are usually solved over time.
So I don’t think “… you should ditch the old one!” is necessary. We should just wait for the tools to improve and support the most used OOP idiom.

1 Like