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:
-
Which approach instantiates objects the fastest?
-
Which approach is the fastest when it comes to method calls?
-
Which approach uses the most memory?
-
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.
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)