3 Different OOP approaches: performance, memory consumption, and aesthetics

Figuring out what OOP implementation works best for you can be difficult since Lua(u) is such a flexible language. In Roblox, the idiomatic way to implement OOP is via metatables. This approach has been shown to be memory-efficient, albeit somewhat slower in the Lua community. However, Luau can be very different from normal Lua and the gap is only getting wider. Therefore, I see it as necessary to run performance tests without relying on Lua benchmarks of questionable relevance.

I have implemented a simple bank account object in 3 different ways to answer these questions:

  1. Which approach instantiates objects the fastest?

  2. Which approach is the fastest when it comes to method calls?

  3. Which approach uses the most memory?

  4. Which approach allows for encapsulation/privacy?

Results
For those who do not have the time to read how these tests were done, here are the results:

1. Which approach instantiates objects the fastest?

  • Closure-based: 0.032793264000211 seconds per 100 000 objects
  • Metatable-based: 0.015553079999227 seconds per 100 000 objects
  • Table-with-Methods-based: 0.017901616001327 seconds per 100 000 objects

2. Which approach is the fastest when it comes to method calls?

  • Closure-based: 0.062994397999428 seconds per 2 000 000 invocations
  • Metatable-based: 0.07871618199948 seconds per 2 000 000 invocations
  • Table-with-Methods-based: 0.066852572001517 seconds per 2 000 000 invocations

3. Which approach uses the most memory?

  • Closure-based: 421 875 kB per 1 000 000 objects
  • Metatable-based: 195 312 kB per 1 000 000 objects
  • Table-with-Methods-based: 320 312 kB per 1 000 000 objects

4. Which approach allows for privacy/encapsulation?
The closure-based implementation allows for true privacy, which may be desirable for some projects.

An Aside: What about ā€˜niceā€™ constructors?
The __call metamethod allows for constructors that closely resemble those of conventional OOP programming languages. It allows us to write:

local instance = Class(arg1, arg2, arg3)

Instead of:

local instance = Class.new(arg1, arg2, arg3)

But what is the performance impact of this tiny cosmetic change on instantiation speed?

Metatable-based with nice constructor: 0.016088053999993 seconds per 100 000 objects

The speed penalty of using a nice constructor is very, very low, which makes me happy. :slightly_smiling_face:

Conclusion
The closure-based approach has averaged to be slower to instantiate and takes up the most memory, but its methods can be invoked the fastest. Also, its unique ability to provide true privacy must be kept in mind.

The table-with-methods approach is very nearly as fast as the metatable approach but uses significantly
more memory (since all objects carry their own methods) and does not provide any additional benefits the way the closure-based approach does.

For the vast majority of use cases, the metatable approach is undoubtedly the most efficient and optimal one, and can even be safely used with a nice constructor without sacrificing speed.

Here are the OOP implementations. Note that they do not make use of custom Luau types, but that does not make a difference, at least not at the time of writing, since Luau does not use type information to do optimizations.

Implementation 1: Closure-based

local ClosureAccount = {}

function ClosureAccount.new(owner: string, balance: number, active: boolean)
	local self = {
		owner = owner,
		balance = balance,
		active = active
	}
	
	local function Withdraw(amount: number)
		self.balance -= amount
	end
	
	local function Deposit(amount: number)
		self.balance += amount
	end
	
	return {
		Withdraw = Withdraw,
		Deposit = Deposit
	}
end

return ClosureAccount

Implementation 2: Metatable-based

local MetatableAccount = {}
MetatableAccount.__index = MetatableAccount

function MetatableAccount.new(owner: string, balance: number, active: boolean)
	local self = {
		owner = owner,
		balance = balance,
		active = active
	}
	
	return setmetatable(self, MetatableAccount)
end

function MetatableAccount:Deposit(amount: number)
	self.balance += amount
end

function MetatableAccount:Withdraw(amount: number)
	self.balance -= amount
end

return MetatableAccount

Implementation 3: Table-with-methods-based

local TableWithMethodsAccount = {}

function TableWithMethodsAccount.new(owner: string, balance: number, active: boolean)
	return {
		owner = owner,
		balance = balance,
		active = active,
		
		Withdraw = function(self, amount: number)
			self.balance -= amount
		end,
		
		Deposit = function(self, amount: number)
			self.balance += amount
		end,
	}
end

return TableWithMethodsAccount

Implementation 4: Metatable-based with nice constructor

local MetatableAccount = {}
MetatableAccount.__index = MetatableAccount

local function new(n, owner: string, balance: number, active: boolean)
	local self = {
		owner = owner,
		balance = balance,
		active = active
	}

	return setmetatable(self, MetatableAccount)
end

setmetatable(MetatableAccount, {__call = new})

function MetatableAccount:Deposit(amount: number)
	self.balance += amount
end

function MetatableAccount:Withdraw(amount: number)
	self.balance -= amount
end

return MetatableAccount

Now onto the measuring script:

1. Measuring instantiation speed

local results = {}
local resultsLen = 0

for i = 1, 50 do
	local start = os.clock()
	
	for i = 1, 100000 do
		local account = --account constructor invocation here
	end
	
	local finish = os.clock()
	
	resultsLen += 1
	results[resultsLen] = finish - start
end

local sum = 0

for i = 1, resultsLen do
	sum += results[i]
end

print(sum / 50)

2. Measuring method invocation speed

local results = {}
local resultsLen = 0

local account = --account constructor invocation here

for i = 1, 50 do
	local start = os.clock()

	for i = 1, 1000000 do
        -- NOTE: you must use '.' as opposed to ':' in the closure-based approach
		account:Deposit(1)
		account:Withdraw(1)
	end

	local finish = os.clock()

	resultsLen += 1
	results[resultsLen] = finish - start
end

local sum = 0

for i = 1, resultsLen do
	sum += results[i]
end

print(sum / 50)

3. Measuring memory consumption

local start = gcinfo()

local accounts = table.create(1000000)
for i = 1, 1000000 do
	accounts[i] = --account constructor invocation here
end

local finish = gcinfo()

print(finish - start)

An Aside: Measuring the speed of nice constructor invocation

local results = {}
local resultsLen = 0

for i = 1, 50 do
	local start = os.clock()
	
	for i = 1, 100000 do
		local account = --nice constructor invocation here
	end
	
	local finish = os.clock()
	
	resultsLen += 1
	results[resultsLen] = finish - start
end

local sum = 0

for i = 1, resultsLen do
	sum += results[i]
end

print(sum / 50)
29 Likes

Thanks for making this resource! Iā€™m feeling even better about using metatables now (apart from the fact it makes you look like a pro and its various use-cases)!


I have one simple question. Is it more efficient to:

Personally I prefer using metatables with this style:

local Object = {
  __class = {}
}

local Class = Object.__class
Class.__index = Class

function Object.new()
  return setmetatable({}, Class)
end

function Class:Something()

end

return Object

It is useful if I want to have a class/library hybrid. For example including methods to check ā€œcustom typesā€ without making every single new object inherit it:

function Object.Is(obj)
  return type(obj) == "table" and getmetatable(obj) == Class
end

You can also easily access instance methods by indexing them in the __class key under the Object table. Implementing the __call metamethod with this style is possible, but I find that unnecessary for myself.

NOTE: ā€œ__classā€ is not a metamethod and I just chose for there to be two underscores because I felt like doing so.

3 Likes

What about OOP which use proxy tables? Basically a combination of metatables and standard tables. Itā€™s kind of similar to the closure except one is a metatable and one isnā€™t. Does it make a difference?

1 Like

I donā€™t think it really matters which one you do. If any difference Iā€™m assuming itā€™s negligible.

1 Like

This is a very interesting approach, thanks for sharing! I can definitely see it simplifying custom type checking significantly.

1 Like

Well itā€™s not necessarily specifically for custom type checking. The style is mainly useful if you want a class-library hybrid. I just used the ā€˜Isā€™ method since it was the simplest example I could think of.

Also the style prevents new objects from calling the constructor which the traditional style suffers from.