Polyglot
Languages Lua oop
Lua § oop

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:

  1. Animal is a table containing methods.
  2. Animal.__index = Animal admits “if a key is missing on the instance, look in Animal”.
  3. Animal.new(...) constructs a fresh instance (a fresh table) with Animal as its metatable.
  4. a:greet() is sugar for a.greet(a) — the method’s first parameter self is 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:

  1. d:describe()describe not on instance → look in Dog.
  2. Not on Dog → look in Dog’s metatable’s __index (which is Animal).
  3. 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 = Cls for the standard class pattern.
  • Use Cls.new(...) for constructors.
  • Use : for methods; . for static functions.
  • Use __index = parent for single inheritance.
  • Use leading underscore for “private” (convention only).
  • Use closures for true privacy.
  • Use error for abstract methods.
  • Use __call for callable classes.
  • Use __tostring for 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).