Choosing an OOP Pattern in Lua: Metatables, Composition, and Closures
Guide to choosing between metatables, composition, and closures for OOP in Lua. Includes performance trade-offs, memory diagnostics, and implementation examples.
09 Dec 2025, 12:18 UTC

The Lua Object Dilemma
Lua does not have a native class keyword or built-in inheritance. Developers must implement object-oriented patterns using tables and metatables. The core problem is selecting a pattern that balances memory overhead, execution speed, and architectural flexibility without introducing unnecessary dependencies.
The primary takeaway is that for most high-performance applications, metatable-based delegation is the standard for inheritance, while composition is superior for flexible, multi-capability entities. Closures should be reserved for cases where strict data encapsulation is more critical than memory efficiency.
Comparing Lua OOP Patterns
The following table compares the most common patterns available in Lua 5.1 through 5.4.
| Pattern | Mechanism | Memory Cost | Lookup Speed | Encapsulation |
|---|---|---|---|---|
| Single Inheritance | __index table |
Low | Fast | Public |
| Composition/Mixins | Table merging | Medium | Fastest | Public |
| Closures | Upvalues | High | Fast | Private |
| OOP Libraries | Wrapper logic | Medium | Variable | Mixed |
Trade-offs and Decision Drivers
Metatable Delegation (The Standard)
This pattern uses the __index metamethod. When Lua cannot find a key in a table, it looks at the __index value of its metatable. This allows a "child" object to delegate method calls to a "parent" class.
- Pros: Extremely memory efficient; methods are stored once in the class table rather than in every instance.
- Cons: Limited to single inheritance unless you implement a complex custom
__indexfunction, which slows down lookups.
Composition and Mixins
Instead of a hierarchy, composition attaches specific behaviors (mixins) to an object at creation time. This avoids the "fragile base class" problem where changing a parent breaks all children.
- Pros: Avoids deep inheritance chains; allows an object to be "Flyable" and "Swimmable" simultaneously.
- Cons: Higher memory usage per instance because methods (or references to them) are copied into the instance table.
Closure-based Objects
By returning functions from a constructor, you can create private variables (upvalues) that cannot be accessed from outside the object.
- Pros: True data privacy; no need for
selfkeywords in some implementations. - Cons: High memory overhead; objects cannot be introspected using
pairs()oripairs().
Implementation: Inheritance vs. Composition
Below are two concrete implementations. Run these in a standard Lua environment (5.1+). Use lua -e "[code]" or a .lua file.
Example 1: Single Inheritance (Metatables)
-- Define Base Class
local Animal = {}
Animal.__index = Animal
function Animal:new(name)
-- Create instance and link to Animal via metatable
return setmetatable({name = name}, self)
end
function Animal:speak()
return self.name .. " makes a sound"
end
-- Define Derived Class
local Dog = setmetatable({}, Animal)
Dog.__index = Dog
function Dog:speak()
return self.name .. " barks"
end
-- Validation
local rex = Dog:new("Rex")
print(rex:speak()) -- Expected: Rex barks
Example 2: Composition (Mixins)
-- Define Behaviors
local Flyable = { fly = function(self) return self.name .. " flies" end }
local Swimmable = { swim = function(self) return self.name .. " swims" end }
local function createDuck(name)
local obj = { name = name }
-- Mix in behaviors
for k, v in pairs(Flyable) do obj[k] = v end
for k, v in pairs(Swimmable) do obj[k] = v end
return obj
end
-- Validation
local donald = createDuck("Donald")
print(donald:fly(), donald:swim()) -- Expected: Donald flies Donald swims
Verification and Diagnostics
To determine if your chosen pattern meets your performance constraints, use the following diagnostic checks:
1. Memory Footprint
Run this check to see how many kilobytes each instance consumes:
collectgarbage("collect")
local before = collectgarbage("count")
local instances = {}
for i = 1, 100000 do
table.insert(instances, Dog:new("test"))
end
collectgarbage("collect")
print("Memory used: " .. (collectgarbage("count") - before) .. " KB")
2. Method Lookup Overhead
Use os.clock() to measure the cost of delegation. Metatable lookups are generally fast, but a custom __index function can be 2-5x slower than a table-based __index.
Critical Limitations
- Garbage Collection: In Lua 5.1, the
__gcmetamethod is not called for tables. If you need finalizers for your objects, you must usenewproxy()or upgrade to Lua 5.2+. - Sandboxing: In embedded environments like Nginx or Redis,
debug.setmetatablemay be disabled for security. Ensure your pattern relies onsetmetatablerather than thedebuglibrary. - Introspection: Closure-based objects are "black boxes." If your project requires serialization (e.g., saving game state to JSON), avoid closures as you cannot iterate over their internal state.
Rollback and State Change
Since these patterns define how objects are created in memory rather than altering global system state or files, there is no system-level rollback. To revert a pattern change, replace the constructor function and clear the instance cache using collectgarbage("collect").
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.