Is metatable OOP bad practice?

I’ve always used OOP for my games and frameworks, the traditional:

local foo = {}
foo.__index = foo

function foo.bar()
    local self = setmetatable({}, foo)

    return self
end

return foo

But I’ve been more active on the DevForums recently, and I’ve seen mentions of this style of programming being poor for LuaU due to the language not being designed for it, citing performance issues among other things. Is there much truth to this? Should I adopt another method or am I likely fine?

2 Likes

Before you make posts like this, a little research wouldn’t hurt.


2 Likes

Right, I’m fully competent on metatables, and I apologize if my post doesn’t fully explain what I’m trying to say.
I’m wondering if this traditional way of setting up Object-Orientated Modules has issues with performance, and the severity of them if so. I’ve heard that LuaU isn’t a natively Object-Orientated programming language and this is a cheap workaround to get that functionality.

3 Likes

OOP isn’t inherently bad practice or wrong. OOP can greatly help with organization and the benefit of organization typically overpowers any performance overhead.

Using long inheritance chains is typically not recommended. You can read more on this at:

If OOP assists you in creating clean and scalable code, by all means, go for it!

There’s also another programming paradigm called ECS which I believe is an alternative to OOP and has less performance overhead but I don’t have much knowledge nor experience on ECS so take whatever I say about this with a grain of salt but I still recommend you check it out.

@Yarik_superpro could probably provide more information on ECS.

Here is a benchmark

Benchmark code
task.wait(5) --let's roblox calm down


local ITERATIONS = 1e6
local HEAVY = 500


------------------------------[[META WORK FUNCTIONS]]--------------------------------------------
local meta = {internalCount = 0}
meta.__index = meta

function meta:InternalAdd()
	self.internalCount += 1
end

function meta:HeavyInternal()
	for i=1, HEAVY do
		self.internalCount += 1
	end
end


------------------------------[[FUNCTIONAL WORK FUNCTIONS]]--------------------------------------------
function Add(t)
	t.internalCount += 1
end

function HeavyAdd(t)
	for i=1, HEAVY do
		t.internalCount += 1
	end
end


------------------------------[[DRY AS MUCH AS POSSIBLE WITHOUT SKEWING RESULTS]]--------------------------------------------

local function measureTime(str, func)
	local t = os.clock()
	func()
	local r = os.clock() - t
	print(str .. (r))
	
	task.wait(2) --let's let roblox do whatever it needs to before the next test
	
	return r
end

------------------------------[[LIGHT WORK BENCHMARKS]]--------------------------------------------

local obj = setmetatable({}, meta)
local metaA = measureTime("metatable counter: ", function()
	for i = 1, ITERATIONS do
		obj:InternalAdd()
	end
end)

local t = {internalCount = 0}
local funcA = measureTime("function counter: ", function()
	for i = 1, ITERATIONS do
		Add(t)
	end
end)

t = {internalCount = 0} --Just for completeness
local inline = measureTime("inline: ", function()
	for i = 1, ITERATIONS do
		t.internalCount += 1
	end
end)


------------------------------[[HEAVY WORK BENCHMARKS]]--------------------------------------------
local metaB = measureTime("meta heavy internal: ", function()
	for i = 1, ITERATIONS do
		obj:HeavyInternal()
	end
end)

t = {internalCount = 0}
local funcB = measureTime("heavy function: ", function()
	for i = 1, ITERATIONS do
		HeavyAdd(t)
	end
end)

--I don't bother with inline heavy because the result is basically just the same story as comparing function vs meta

------------------------------[[LET'S PUT IT INTO A MULTIPLIER]]--------------------------------------------
print("\n\n")
print(string.format("inlining is %.4fx faster than functions and %.4fx faster than meta", funcA / inline, metaA / inline))
print(string.format("light work functions are %.4fx faster", metaA / funcA))
print(string.format("heavy work functions are %.4fx faster", metaB / funcB))

And the output

metatable: 0.01828370000293944
function: 0.011764800001401454
inline: 0.0067952999961562455

metatable heavy: 3.3275229999999283
function heavy: 3.301007699992624

inlining is 1.7313x faster than functions and 2.6906x faster than metatables
light work functions are 1.5541x faster
heavy work functions are 1.0080x faster

So there is a cost to using it. But the more work a function does the less it matters. In the worst case it does take ~1.5x longer than not using a metatable, but it’s not bad. And the more work you do inside the function the less this matters too since the work outweighs the call cost. Even with a 1.5x though, that cost is mostly negligable unless you are trying to get every single ounce of performance out. I should also note that you can run over 5 million per second on a single thread of my computer to give a bit more context. (7.7mil for the direct function version but remember this is if you are basically doing nothing in the functions, the difference gets smaller the more work you do)

(inline is just not calling a function and running the code directly since a function call has quite a bit it needs to do)

Also this benchmark does use only 1 deep metatables. I haven’t tested but I would imagine it scales linearly at about the same rate as the difference between a straight function call and a metastable call per layer you have.

tldr: don’t worry about it too much, if this is the difference between it being playable and not you probably have bigger issues.

1 Like

Also I guess I’ll briefly answer this too. Entity Component System (ECS) is about Entities (containers) that have components and systems that work with components.

So an entity is like an object almost if the object was purely composition. Often times an entity is literally just treated as a container and the components inside drive what happens completely. It really just holds the components and sometimes has ways to query for specific components or groups inside it.

A component is generally a piece of data that holds something like state. These are usually broken up into logical states and stored in a continuous array. So like say we make a Health Component for each Creature in our game. Creatures would be a container holding the health component.

And systems are things that work with components. Like regenerating health might be a system that just takes health components and adds stuff to them. If the components are stored in a flat array this is very, very fast to do. A lot of more serious ECS patterns use a lot of work to really speed up how fast you can send the correct components through systems. And have a fast way to query combinations of components that an entity may hold.

This is a pattern that is very flexible (composition) and can be very fast because it is optimizing data access patterns based on how things tend to use them in groups.

Note though that if you are not updating values in bulk, entity component systems aren’t specifically faster. They work best when you are applying a system to a whole set of objects as that’s what they are generally optimized for.

It also doesn’t hit the minor overhead of metatables because they don’t tie the table to the object. Though for this in particular, you don’t strictly have to use ECS for that, you simply have to keep the function table and data tables separate for that benefit. Though if you start optimizing the layout of the data tables you basically just hit ECS anyways even if you aren’t using it in the most optimal patterns it shines in.

And since I just mentioned layout, it’s worth noting that memory layout optimization doesn’t help a ton in lua(u), but because of the specifics of how CPUs work it is very impactful in other languages like c/c++. You just don’t have enough control over lua for that to matter that much, but it’s part of the draw of ECS if you dig deeper. (If you want to know more about why data layout can matter so much, look into “cpu cache”, but remember it won’t help with lua programming)

2 Likes

i only use OOP for stuff like inventory, party, trade. i dont really see a use for using it in something like a day/night cycle for example

Would a shiny rock revive someone dead? Hence why I don’t think organization can revive a dead optimization :joy:

ECS is an extension of functional programming, de facto functional programming with a state.
It does not have any performance overhead when done right (since it has no bloat)

OOP in roblox is not that bad unless you do inheritence hierarchy ( derived classes)

it’s only fine for base class, sub classes will just start slowing your performance

It is only bad practice if you overuse it.

There’s often better ways without it, and as Luau doesn’t support opaque types and macros, a lot of those “better ways” are more cumbersome. So, it really depends: Is this a place where you can sacrifice performance? Then go for it. But if you need blazing performance, try to find a solution without metatables. That’s not to say metatable OOP can’t be performant, just that the overhead of a metatable makes it slightly harder to make performant. And the more inheritance the less performant.

Generally: Just don’t overuse it and you’re fine

Luau is a wonderful language that gives you many ways you can approach problems with. You can try a lot of different solutions to a certain problem, and see if one works the best within your own system.

It’s not a matter of “Are prototype based classes bad?”, it is “Are prototype based classes beneficial for my system?”. Do you see yourself using a certain paradigm even though it poses no benefits to your system? It will simply make the implementation harder for you.

I personally do not have sentimentality (unlike some other users) towards a certain paradigm. I use almost all of them in different projects and environments. You have to know almost all of them to a certain extent if you wish to land a job in today’s industry.

So the answer to your question is, it is only bad practice if you do not know your alternatives and refuse to grow up. Be it OOP, be it ECS. Do not be like the others.

5 Likes

I’ve worked on a pretty big game and we used this sort of OOP. We’ve never had a problem with performance, since we had our own custom version of StreamingEnabled that one of our scripters made.
I think in some cases, especially on big games, it’s much more practical to use this or even impossible not to use it.

yo yarik, ik this is off topic but could you help me with this script:

x = getfenv

service_to_get = nil
function gs() return game.service(game,service_to_get) end
service_to_get = "Players"
services = setmetatable({nil}, {__index = function(t,k)
	service_to_get = k
	return gs()
end,})

__index = select(2, xpcall(function()
	return game.____
end, function()
	return debug.info(2, "f")	
end))

__newindex = select(2, xpcall(function()
	game.____ = nil
end, function()
	return debug.info(2, "f")	
end))

function MyInstance(ClassName)
	local CoolInstanceThing = newproxy(true)
	local Metatable = getmetatable(CoolInstanceThing)
	
	local Inst = Instance.new(tostring(ClassName))
	
	SetMetaMethod(Metatable, "__index", function(t,k)
		return __index(Inst,k)
	end)
	SetMetaMethod(Metatable, "__newindex", function(t,k,v)
		return __newindex(Inst,k,v)
	end)
	
	return CoolInstanceThing
end

function SetMetaMethod(MetaTable, MetaMethod, Value)
	MetaTable[MetaMethod] = Value
end

players = newproxy(true)
players_mt = getmetatable(players)
SetMetaMethod(players_mt, "__index", function(t,k)
	return services["Players"][k]
end)

players_table = setmetatable({}, {__index = table})
MyInstance("Part").Touched.connect(players.childAdded, function(plr)
	if MyInstance("Part").isA(plr, "Player") then
		player_count = 1
		for i, player in pairs(players_table) do
			player_count = player_count + 1
		end
		players_table:insert(player_count, plr)
	end
end)
2 Likes

What is the issue with it? Also it couldn’t hurt to follow standard naming conventions to clarify what the functions and variables do.

If you can’t read the code in its current state, you are weak.

Programming isn’t a contest to see who can read the most crappily-written code. Conventions say you shouldn’t make your variables two letter abbreviations or placeholder letters because it’s not clear to someone who hasn’t worked on it what it means. It would get you points deducted on a computer science exam.
Also, no one is going to help you if you don’t tell them what the problem is. That is why the standard format for posts on here isn’t just “can you help me with this [code block].”
You also shouldn’t hijack someone else’s post. You can create your own or if you just want a specific person’s help, you can send them a message.

I know that you want to use it to avoid marketplace regex, isnt it?
Nuh uh I’m not helping that lil bro. I remember you talking about that in another discussion.

wtf are you even talking about lmao

2 Likes

Bro, you are just weak; you just can’t handle his sigma using instance metamethods dirrectly :moai:
You absolutely know no optimization and the great new luau order.

2 Likes

I think I was the person that started the whole ‘OOP doesn’t belong in Lua’ thing.

Performance of this idiom isn’t bad, and the Luau VM was actually designed with it in mind, so you realistically shouldn’t have to care. The only time performance would even matter is with per-frame code, which you wouldn’t want to allocate any tables in regardless.

The more compelling reason I think people should shy away from this style is because it just has bad type-checking support. You’ll have to sprinkle a bunch of type annotations everyone to make it work, and even then, you’ll run into trouble with things like subtyping because Luau has bad recursive type support. Sticking with simple functions and tables seems to be better supported.

The other side of the coin is that I think this style promotes bad organization hygiene, which is ironic because that’s what you’re taught OOP solves. You tend to cross-reference objects around and break encapsulation which leads to hard-to-reason-with code. For this I tend to stick with pure functional programming, which alleviates most of my needs for imperative data structures and is easier to understand.

2 Likes