Strictly typed <<Object Oriented Programming>> (Better than metatable OOP)

Object Oriented Programming (Better than metatable OOP)

Tutorial difficulty: Advanced Begginer (no clue what does it mean)

This tutorial will be a mid-short summary about OOP method from: Tower - Roblox

Benefits:

  • More begginer friendly than metatable OOP.

  • faster than metatable OOP by like 10-100% (up to 2 times faster) (Results vary).

  • Easier to write types and don’t require any casting since it uses static types unlike metatable OOP.

  • More customization with metamethods (incase you need them).

  • Can contain multiple classes.

  • Ability to have same constructor for most of your classes.

  • Uses generic tables that don’t require any deep copy.

Cons:

  • No dirrect support from Roblox like with metatable OOP (as for now and i hope that it will change)

In this tutorial we will learn:

  • how to create a constructor
  • how to add methods to constructor
  • how to make a generic table
  • how to create types and assign them to our generic table/constructor/method

For more advanced tutorial i would recomend you watching my video on this OOP approach:

Before doing anything lets set us a goals:

  • Make a class Cat.
  • Give class cat ability to print “Meow” via method “Meow”.
  • Method that would print Text we input aswell as adding subtag with name of our cat in beggining of the message.
  • Make class Cat include: Age of this cat and Name of this cat.
  • Method that would tell us name and age of our class cat.

Part 1: Writing a Generic table

local Cat = {
Name="";
Age=0;
}

Part 2: Writing a constructor:

-- self shall be ignored as since calling methods will automatically pass 1st argument
--Source: https://create.roblox.com/docs/luau/functions#define-methods

local function Constructor(self,Name,Age)
-- nelf is our newly created class!
local nelf = table.clone(self)

-- will cast Name to string
nelf.Name = `{Name}`
-- will cast Age to number and if will fail then age will be 0
nelf.Age = tonumber(Age) or 0
-- returning our class
return nelf
}

local Cat = {
-- Referance to our constructor
new = Constructor;

Name="";
Age=0;
}

Part 3: Adding methods

-- self shall be ignored as since calling methods will automatically pass 1st argument
--Source: https://create.roblox.com/docs/luau/functions#define-methods

local function Constructor(self,Name,Age)
-- nelf is our newly created class!
local nelf = table.clone(self)

-- will cast Name to string
nelf.Name = `{Name}`
-- will cast Age to number and if will fail then age will be 0
nelf.Age = tonumber(Age) or 0
-- returning our class
return nelf
end

--Just a function, not an actual method 
local function Meow()
 warn("Meow")
end
-- Upon calling it will say Name of our cat + text
local function Say(self,text)
 warn(`{self.Name}: {text}`)
end
local function Info(self)
 return self.Name,self.Age
end

local Cat = {
-- Referance to our constructor
new = Constructor;
-- Referance to function (could be used without : but . instead when calling)
Meow = Meow;

Say = Say;
Info = Info;

Name="";
Age=0;
}

Part 4: Adding types and description

--!strict
--!optimize 2
-- self shall be ignored as since calling methods will automatically pass 1st argument
--Source: https://create.roblox.com/docs/luau/functions#define-methods

--[[
if you see argument <strong>self</strong> after writing then you are doing smth wrong; Perhabs you used singular dot instead of : for method calling
<strong>Name</strong> Name of our cat
<strong>Age</strong> Age of our cat
]]
function Constructor(self:Cat,Name:string,Age:number): Cat
	-- nelf is our newly created class!
	local nelf = table.clone(self)

	nelf.Name = Name
	nelf.Age = Age
	-- returning our class
	return nelf
end

--Just a function, not an actual method 
--[[
Can be called with singular dot aswell
A void function
]]
function Meow(): ()
	warn("Meow")
end
-- Upon calling it will say Name of our cat + text
function Say(self:Cat,text:string): ()
	warn(`{self.Name}: {text}`)
end
--[[Returns <strong>Tuple</strong> type that contains Name and Age of our cat]]
function Info(self:Cat): (string,number)
	return self.Name,self.Age
end

local Cat:Cat = {
	-- Referance to our constructor
	new = Constructor;
	-- Referance to function (could be used without : but . instead when calling)
	Meow = Meow;

	Say = Say;
	Info = Info;

	Name="";
	Age=0;
}

-- Creating type for our cat
export type Cat = {
-- Methods
	-- typeof does get what does function returns and description of function incase it has one
	new:typeof(Constructor);
	Meow:typeof(Meow);
	Say:typeof(Say);
	Info:typeof(Info);
-- Properties
	Name:string;
	Age:number;
}

Part 5: Usage

--!strict
--!optimize 2
-- self shall be ignored as since calling methods will automatically pass 1st argument
--Source: https://create.roblox.com/docs/luau/functions#define-methods

--[[
if you see argument <strong>self</strong> after writing then you are doing smth wrong; Perhabs you used singular dot instead of : for method calling
<strong>Name</strong> Name of our cat
<strong>Age</strong> Age of our cat
]]
function Constructor(self:Cat,Name:string,Age:number): Cat
	-- nelf is our newly created class!
	local nelf = table.clone(self)

	nelf.Name = Name
	nelf.Age = Age
	-- returning our class
	return nelf
end

--Just a function, not an actual method 
--[[
Can be called with singular dot aswell
A void function
]]
function Meow(): ()
	warn("Meow")
end
-- Upon calling it will say Name of our cat + text
function Say(self:Cat,text:string): ()
	warn(`{self.Name}: {text}`)
end
--[[Returns <strong>Tuple</strong> type that contains Name and Age of our cat]]
function Info(self:Cat): (string,number)
	return self.Name,self.Age
end

local Cat:Cat = {
	-- Referance to our constructor
	new = Constructor;
	-- Referance to function (could be used without : but . instead when calling)
	Meow = Meow;

	Say = Say;
	Info = Info;

	Name="";
	Age=0;
}

-- Creating type for our cat
export type Cat = {
-- Methods
	-- typeof does get what does function returns and description of function incase it has one
	new:typeof(Constructor);
	Meow:typeof(Meow);
	Say:typeof(Say);
	Info:typeof(Info);
-- Properties
	Name:string;
	Age:number;
}

local ClassCat1 = Cat:new("Tiger",3)
ClassCat1.Meow()
ClassCat1:Say("Im a talking cat")
local Name,Age = ClassCat1:Info()
print(`Info: Age: {Age}, Name: {Name}`)

That whole tutorial.

You could do a lot of work arounds with this OOP method but i like this exact way of it becouse it keeps polymorphy just enough.
This is my first tutorial of that kind and i would like to hear suggestions as to how i can improve my future/this tutorial(s).

27 Likes

A simple method yet satisfying. Why not a lot of comments? Anyone use this way or something similar?

2 Likes

Could you eleborate further?
I didnt quite understood you

Tables are going to be faster than metatables, I can say that your 100% increase in speed is an understatement, it can easily be 10x in some situations. Faster to create, faster to index, faster to modify, however usually slower to convert to string.

I definitely agree that this approach is easier to learn than metatables.

The reason we use metatables is customization, you want to enforce certain restrictions on what types the properties can be changed to, you HAVE to use metatables or functions. You want ease of use when developing multiple classes, especially those that extend other classes, use metatables. The more you want, the more you are forced into using metatables due to their flexibility, so I don’t see much adoption of this approach you are describing in practice for larger projects. Smaller projects or those made by beginners to learn and practice coding, certainly. Projects that prioritize speed whilst still requiring some structure, certainly.

Metatable OOP enforces types strictly through a bunch of runtime checks. This OOP model purely relies on a static type checker. Essentially it’s like comparing C and Rust. The first only makes an illusion of strict types, and the second actually enforces it. For most people who are coming off high-level languages, it always seems a better option to enforce types. But in reality it’s always just better to properly organize your code with the assistance of a static type checker rather than wonder how the hell you feed nil to some runtime type-checked function.

I still think it’s a valid reason why people use metatables instead of pure tables. But I personally never touched any of the features of the metatables ever besides the gc ones. So for me, this model was perfect.

The problem with this is, when you construct an object (an instantiated class), it’s gonna clone everything, which occupies memory, including methods. So for all instantiated class, the memory complexity is O(n(a+b)), n as the number of instances, a as the number of properties/members, and b as the number of methods. That might sound normal, since of course, it’ll linearly scale as more instances are created. But compared to the metatable OOP, it only creates a properties table, and uses a reference to point to the methods; which is just a memory complexity of O(na)

Suppose you have 100 instances, with a class that has 5 properties and 10 methods, then you’d have about 500 memory units if the methods are shared. However, you’d have about 1500 memory units if the methods are cloned; which is 3x more.

3 Likes

Not sure what you mean. Any data type besides some of the native ones is unique and is never created more than once. If you were to do

local exampleObj = {
   CoolFunc = function()
      print("Test")
   end
}

local obj1 = table.clone(exampleObj)
local obj2 = table.clone(exampleObj)

print(obj1.CoolFunc == obj2.CoolFunc)

It would output true. Yes, you are still spending more memory on pointers. But it’s negligible, and the performance gain is worth it. Also in case you are tight on memory I think it is just better to go full C

2 Likes

Look at benchmarking.
Don’t trust it? Do benchmark yourself
It always at least 15% faster than metatable OOP no metter what you do.
That some bonkers saying “metatable OOP can be x10 times faster” and never showing any proof.

I think you misunderstood them. They were agreeing that setting metatables are slower than pure tables. Even setting a metatable with no metamethods to a table reduces performance quite a bit.

However, there are still two reasons I can justify using metatables for OOP implementations.

  1. Code organization: It’s really easy to work with classes when the methods are outside of the constructor function. It can get a bit crowded putting both methods in properties within the constructor otherwise. Some classes could have a lot of methods! Setting method pointers within the constructor can also be mistaken for setting a property value rather than a method.

  2. Inheritance: Metatables are awesome when you want a class to extend from another. Though I know a lot of devs dislike inheritance, I might be one of the last devs to continue practicing it in Roblox Studio.

Just know that metatable OOP still has a reason to exist. Even if it’s less performant, as long as you know it won’t be an issue for your game, metatables are still a fine way to implement OOP.

If you’re trying to train a neural network, well that’s another question! Don’t use metatable OOP for the neural networks then!

1 Like

Metatable OOP in my opinion can only be justified to shorten memory usage of a table;
There probably are use cases where metatable OOP may be more efficient but otherwise it just “Product based” dev slop nowadays

@Artzified @towerscripter1386

It won’t make duplicates of the closure itself (unless there are upvalues that taint the environment), but it will still take up more space in the dictionary since it’s still a key-value pair that needs to be stored.

The __index approach can avoid this overhead since it only needs to store the method dictionary once and then can keep a pointer to the metatable when it’s needed. The reason the former ends up being slower is because it ends up needing to make more heap allocations which ruins the cache-locality of memory (very not good for CPU performance).

I always preach against object-oriented structured programming, especially in Lua where the VM machinery and language restrictions make it basically impractical to seriously use. I believe sticking to data-oriented and stateless logic ended up making me a better programmer in the long run.

1 Like

That’s why I said, in case you are tight on memory, you are better off just going with C-style oop. As for heap allocation, that mostly comes down to how you utilize object-oriented programming. That’s why most of lua oop code out here is ending up as “Managers” and not easily replaceable “Workers”. Also, the later optimization I don’t even believe is even available in Roblox.

Oh damn i really sorry for puttin table.create instead of table.clone in first 2 parts
I have not noticed it at all untill now.
I also localized functions now.

I should mention that operation overloads (myInstance + otherInstance) is non-existent on this OOP style, forcing developers to rely on myInstance1:Add(myInstance2) and checking whenever it’s the correct class.

Inheritance is still possible under this OOP style, albeit less convenient due to the typechecker being janky.

Example
export type Mammal = {
    Hunger: number;
    Eat: (self: Mammal)->();
}

local singleton = {}

local function inst_Eat(self: Mammal)
    self.Hunger += 15
end

function singleton.new() : Mammal
    local inst = {
        Eat = inst_Eat;
        Hunger = 50;
    }
    return inst
end
local Mammal = require(script.Mammal)

type Deer = Mammal & {
	Eat: (self:Deer)->();
	Jump: (self:Deer)->();
}

local Deer = {}

local function inst_Jump(self: Deer)
	self.Hunger -= 5
end

local function inst_Eat(self: Deer)
	self.Hunger += 10
end

function Deer.new()
	local inst = Mammal.new() :: Deer
	inst.Jump = inst_Jump
	inst.Eat = inst_Eat -- missing field "Jump"

	return inst
end

This shouldn’t throw any type check warnings from a glance, yet it throws one when overloading the “Eat” method.
Not sure if this is a typechecker problem, but I’ve dealt similar situations in other projects from experience.

Other than that detail, this is still useful when you’re keeping performance at priority while handling 1000 class instances or more.

1 Like

When referring to data-oriented logic - are you pointing more towards things like an ECS?

I find it very hard to make Roblox act like a ‘Stateless’ system when I find programming games in general to be inherently stateful. I find it REALLY hard to somehow figure out how I could turn a system in a game to a stateless, data-oriented architecture (or whatever you call it)

In ECS you do not have any centralized table;
Its made to cut lookup count to 50%.
You have multiple “entity lists” all referencing different stats.

local health = {
[Plr] = 100;
}
local Armor = {
[Plr] = 125;
}
local LVL = {
[Plr] = 5;
}

Makes you capable of dirrectly grabbing value rather than double lookup:
CentralBase[Plr].LVL

1 Like

Obviously, you can’t make everything stateless, but by moving all of your ‘stateful’ logic to the edges of your codebase and keeping its core parts pure, you’ll significantly reduce the surface area of side effects and make your game easier to reason with and unit test. This style is sometimes referred to as ‘functional core, imperative shell’.

Essentially, all of your domain logic stays pure while all of your imperative infrastructure lives in one coherent place. Since this style is inherently paradigm-less, it doesn’t matter what philosophy you subscribe to for your imperative code, but I personally find event-driven callbacks with CollectionService tagging to work well, which can be seen as a primitive form of ECS.

1 Like

I think I see what you mean. Sorry if I get it wrong, but basically the ‘states’ is data in tables separated from the actual logic? And then the logic are just functions that act on the data in the tables?

However I don’t know what you mean by moving logic to the ‘edge of your codebase’. Do you mean the methods which actually act on the world based off of the states?

And with CollectionService subscription yeah I can definitely conceptualise that with things like observers, maybe for things like Buildings and what not, but I don’t know an example for stuff like NPCs or Players. I do find what you describe - ‘functional core, imperative shell’ attractive though, I definitely mix up my methods and states a lot

Thank you, this defintely makes more sense for me. Is this how JECS does it?

1 Like

If you are really going for true ECS, then you should use raw, wrapperless ECS.
No need for wrappers unless for visualization and debugging.
Otherwise all benefits of ECS do get lost.