OOP idioms
Lua does not admit classes as a built-in feature — OOP is implemented via tables and metatables. The conventional pattern: a class is a table containing methods; an instance is a separate table with the class as its metatable’s __index. The colon syntax (a:b()) admits implicit self parameter — a:b(c) is equivalent to a.b(a, c). Inheritance is implemented by chaining __index. Multiple inheritance admits __index as a function. The combination — table-as-class, metatable-with-__index for method lookup, the colon syntax for methods, the constructor convention (Class.new(...)), the inheritance via __index chains — is the substance of Lua’s OOP idioms.
The basic class pattern
local Animal = {} -- the "class" table
Animal.__index = Animal -- self-reference for instances
function Animal.new(name) -- constructor (Animal.new)
local self = setmetatable({}, Animal)
self.name = name
return self
end
function Animal:greet() -- method (colon syntax)
return "Hello, I am " .. self.name
end
local a = Animal.new("Rex")
print(a:greet()) -- "Hello, I am Rex"
print(a.name) -- "Rex"
The mechanism:
Animalis a table containing methods.Animal.__index = Animaladmits “if a key is missing on the instance, look in Animal”.Animal.new(...)constructs a fresh instance (a fresh table) withAnimalas its metatable.a:greet()is sugar fora.greet(a)— the method’s first parameterselfis the instance.
Colon syntax
The : admits implicit self:
-- These are equivalent:
function Animal:greet()
return "Hello, " .. self.name
end
function Animal.greet(self)
return "Hello, " .. self.name
end
-- Calling:
a:greet()
a.greet(a) -- equivalent
The colon admits substantial conciseness — both for definition and call.
Constructor patterns
The conventional contemporary forms:
Class.new pattern
local Point = {}
Point.__index = Point
function Point.new(x, y)
return setmetatable({x = x, y = y}, Point)
end
Class:new pattern
local Point = {}
Point.__index = Point
function Point:new(x, y) -- colon: self is Point
local instance = setmetatable({x = x, y = y}, self)
return instance
end
local p = Point:new(1, 2) -- explicit colon call
Module-style with table parameter
function Point.new(opts)
return setmetatable({
x = opts.x or 0,
y = opts.y or 0,
color = opts.color or "black"
}, Point)
end
local p = Point.new{x = 1, y = 2} -- table-as-args
The braces around the call admit substantial named-argument syntax — convention in DSL-style code.
Single inheritance
Inheritance is implemented by chaining __index:
local Animal = {}
Animal.__index = Animal
function Animal.new(name)
return setmetatable({name = name}, Animal)
end
function Animal:speak()
return "..."
end
function Animal:describe()
return self.name .. " says " .. self:speak()
end
-- Dog inherits from Animal:
local Dog = setmetatable({}, {__index = Animal}) -- Dog inherits from Animal
Dog.__index = Dog
function Dog.new(name, breed)
local self = Animal.new(name) -- call parent constructor
self.breed = breed
return setmetatable(self, Dog) -- change metatable to Dog
end
function Dog:speak() -- override
return "Woof!"
end
local d = Dog.new("Rex", "Labrador")
print(d:speak()) -- "Woof!"
print(d:describe()) -- "Rex says Woof!" (inherited from Animal)
print(d.breed) -- "Labrador"
The lookup chain:
d:describe()—describenot on instance → look inDog.- Not on
Dog→ look inDog’s metatable’s__index(which isAnimal). - Found on
Animal— call.
The mechanism admits substantial single-inheritance hierarchies.
Method override
Subclasses override by defining same-named methods:
function Dog:speak()
return "Woof!" -- overrides Animal:speak
end
For calling the parent:
function Dog:describe()
local base = Animal.describe(self) -- explicit parent call
return base .. " (a " .. self.breed .. ")"
end
There is no built-in super — the conventional approach is named parent reference:
function Dog:describe()
return Dog.parent.describe(self) .. " (a " .. self.breed .. ")"
end
-- With Dog.parent = Animal stored elsewhere
Multiple inheritance via __index function
local function search_classes(classes)
return function(_, key)
for _, class in ipairs(classes) do
local value = class[key]
if value ~= nil then return value end
end
return nil
end
end
local Walking = {}
function Walking:walk() return self.name .. " walks" end
local Swimming = {}
function Swimming:swim() return self.name .. " swims" end
local Duck = {}
Duck.__index = search_classes({Duck, Walking, Swimming})
function Duck.new(name)
return setmetatable({name = name}, Duck)
end
local d = Duck.new("Donald")
print(d:walk()) -- "Donald walks"
print(d:swim()) -- "Donald swims"
The mechanism admits substantial multi-inheritance via a function-based __index lookup.
Privacy
Lua does not enforce visibility — all instance fields are admitted via direct access:
local p = Point.new(1, 2)
print(p.x) -- accessible
p.x = 999 -- mutable
The conventional defences:
Naming convention
Use leading underscore for “private”:
function Account.new(initial)
return setmetatable({_balance = initial}, Account)
end
function Account:deposit(amount)
self._balance = self._balance + amount
end
function Account:balance()
return self._balance -- explicit getter
end
The _balance is conventional for “internal”; not enforced.
Closures (true privacy)
local function make_account(initial)
local balance = initial -- truly private (closure)
return {
deposit = function(_, amount)
balance = balance + amount
end,
balance = function()
return balance
end
}
end
local a = make_account(100)
a:deposit(50)
print(a:balance()) -- 150
-- balance is not accessible directly
The mechanism admits substantial encapsulation through closures — at the cost of substantial per-instance memory (each instance has its own copies of methods).
Metamethods on classes
Add operator overloading via metamethods:
local Vec = {}
Vec.__index = Vec
function Vec.new(x, y)
return setmetatable({x = x, y = y}, Vec)
end
function Vec.__add(a, b)
return Vec.new(a.x + b.x, a.y + b.y)
end
function Vec.__tostring(v)
return "(" .. v.x .. ", " .. v.y .. ")"
end
local a = Vec.new(1, 2)
local b = Vec.new(3, 4)
print(a + b) -- "(4, 6)"
Treated in Metatables.
Static members
For class-level (rather than instance-level) members, attach to the class table:
Point.MAX_INSTANCES = 1000 -- class constant
Point.instance_count = 0 -- class state
function Point.new(x, y)
if Point.instance_count >= Point.MAX_INSTANCES then
error("too many points")
end
Point.instance_count = Point.instance_count + 1
return setmetatable({x = x, y = y}, Point)
end
function Point.reset_count()
Point.instance_count = 0
end
print(Point.MAX_INSTANCES) -- 1000
Point.reset_count()
Common patterns
Standard class
local Person = {}
Person.__index = Person
function Person.new(name, age)
return setmetatable({name = name, age = age}, Person)
end
function Person:greet()
return "Hello, I am " .. self.name
end
function Person:birthday()
self.age = self.age + 1
end
function Person:describe()
return self.name .. " (" .. self.age .. ")"
end
local p = Person.new("Alice", 30)
p:birthday()
print(p:describe()) -- "Alice (31)"
Class hierarchy
local Animal = {}
Animal.__index = Animal
function Animal.new(name)
return setmetatable({name = name}, Animal)
end
function Animal:speak() return "..." end
function Animal:describe() return self.name .. " says " .. self:speak() end
local Dog = setmetatable({}, {__index = Animal})
Dog.__index = Dog
function Dog.new(name, breed)
local self = Animal.new(name)
self.breed = breed
return setmetatable(self, Dog)
end
function Dog:speak() return "Woof!" end
local Cat = setmetatable({}, {__index = Animal})
Cat.__index = Cat
function Cat.new(name)
return setmetatable(Animal.new(name), Cat)
end
function Cat:speak() return "Meow!" end
local d = Dog.new("Rex", "Lab")
local c = Cat.new("Whiskers")
print(d:describe()) -- "Rex says Woof!"
print(c:describe()) -- "Whiskers says Meow!"
Builder pattern
local QueryBuilder = {}
QueryBuilder.__index = QueryBuilder
function QueryBuilder.new()
return setmetatable({
conditions = {},
order = nil,
limit = nil
}, QueryBuilder)
end
function QueryBuilder:where(condition)
table.insert(self.conditions, condition)
return self -- return self for chaining
end
function QueryBuilder:order_by(field)
self.order = field
return self
end
function QueryBuilder:limit_to(n)
self.limit = n
return self
end
function QueryBuilder:build()
local parts = {"SELECT *"}
if #self.conditions > 0 then
table.insert(parts, "WHERE " .. table.concat(self.conditions, " AND "))
end
if self.order then
table.insert(parts, "ORDER BY " .. self.order)
end
if self.limit then
table.insert(parts, "LIMIT " .. self.limit)
end
return table.concat(parts, " ")
end
local sql = QueryBuilder.new()
:where("active = 1")
:where("role = 'admin'")
:order_by("created_at DESC")
:limit_to(10)
:build()
Singleton
local Logger = {}
Logger.__index = Logger
local instance = nil
function Logger.get_instance()
if not instance then
instance = setmetatable({prefix = "LOG"}, Logger)
end
return instance
end
function Logger:log(message)
print("[" .. self.prefix .. "] " .. message)
end
Logger.get_instance():log("started")
Mixin via copy
local function mixin(target, source)
for k, v in pairs(source) do
if not target[k] then -- don't overwrite
target[k] = v
end
end
return target
end
local Trackable = {
track = function(self, action)
print(self.name .. " did " .. action)
end
}
local User = {}
User.__index = User
mixin(User, Trackable) -- User now has track
function User.new(name)
return setmetatable({name = name}, User)
end
local u = User.new("Alice")
u:track("login") -- "Alice did login"
Abstract method
Lua does not admit abstract methods natively; the conventional pattern is error:
local Shape = {}
Shape.__index = Shape
function Shape.new(name)
return setmetatable({name = name}, Shape)
end
function Shape:area()
error("Shape:area must be implemented by subclass")
end
function Shape:describe()
return self.name .. " with area " .. self:area()
end
local Circle = setmetatable({}, {__index = Shape})
Circle.__index = Circle
function Circle.new(radius)
local self = Shape.new("Circle")
self.radius = radius
return setmetatable(self, Circle)
end
function Circle:area()
return math.pi * self.radius ^ 2
end
Constructor with validation
local Email = {}
Email.__index = Email
function Email.new(address)
if not address:match("[^@]+@[^@]+%.[^@]+") then
error("Invalid email: " .. address)
end
return setmetatable({address = address}, Email)
end
function Email:local_part()
return self.address:match("^([^@]+)")
end
function Email:domain()
return self.address:match("@(.+)$")
end
Method via colon, function via dot
function Person.create(name, age) -- factory (no self)
if age < 0 then error("invalid age") end
return Person.new(name, age)
end
function Person:rename(new_name) -- method (uses self)
self.name = new_name
return self
end
local p = Person.create("Alice", 30)
p:rename("Alice Smith"):rename("Alice Jones")
The convention: dot for static (no self); colon for instance methods (use self).
__call for class instantiation
local Person = setmetatable({}, {
__call = function(cls, name, age)
return setmetatable({name = name, age = age}, cls)
end
})
Person.__index = Person
function Person:greet()
return "Hello, " .. self.name
end
local p = Person("Alice", 30) -- call as constructor
print(p:greet())
The form admits substantial Pythonic-style instantiation.
Type checking
local function isa(obj, class)
local mt = getmetatable(obj)
while mt do
if mt == class or mt.__index == class then
return true
end
mt = getmetatable(mt.__index)
end
return false
end
print(isa(d, Dog)) -- true
print(isa(d, Animal)) -- true
print(isa(c, Dog)) -- false
The mechanism walks the inheritance chain.
Methods on built-in types
For tables, methods can be added via the __index of the table’s metatable. For all tables, set the global metatable:
debug.setmetatable({}, {__index = {
map = function(self, fn)
local result = {}
for i, v in ipairs(self) do result[i] = fn(v) end
return result
end
}})
print(({1, 2, 3}):map(function(x) return x * 2 end)[1]) -- 2
The pattern is rare and conventionally avoided — admit substantial action-at-a-distance.
A note on the absence of native classes
Lua’s design philosophy: provide a small set of primitives (tables, metatables, closures) that admit substantial OOP via convention rather than language-level constructs. The mechanism admits:
- Multiple OOP styles — class-based, prototype-based, closure-based.
- Substantial flexibility — admit any inheritance model.
- Small language — no built-in class machinery.
The trade-offs:
- Boilerplate — each class requires the
Cls = {}; Cls.__index = Cls; Cls.new = ...pattern. - No type safety — admit substantial dynamic typing.
- Multiple competing conventions — different libraries use different OOP styles.
Several libraries provide class systems on top of Lua: middleclass, 30log, Penlight’s pl.class. The conventional discipline is plain tables and metatables unless a project has substantial benefit from a library.
A note on the conventional discipline
The contemporary Lua OOP advice:
- Use
Cls = {}; Cls.__index = Clsfor the standard class pattern. - Use
Cls.new(...)for constructors. - Use
:for methods;.for static functions. - Use
__index = parentfor single inheritance. - Use leading underscore for “private” (convention only).
- Use closures for true privacy.
- Use
errorfor abstract methods. - Use
__callfor callable classes. - Use
__tostringfor substantial debugging output. - Avoid deep inheritance — composition is conventionally clearer.
- Avoid class libraries unless project genuinely benefits.
The combination — table-as-class, metatable-with-__index for method lookup, the colon syntax for methods, the conventional constructor pattern, the __index chain for inheritance, the metamethod-based operator overloading — is the substance of Lua’s OOP idioms. The discipline produces substantial OOP from a small language core; the cost is substantial boilerplate and substantial flexibility (which admits substantial inconsistency).