← Back to the index page

Object-oriented programming in Lua

Lua doesn’t have classes and objects. table is a single data structure to represent everything: arrays, maps, sets, lists, queues, etc. Classes and OOP can be emulated with table data structure and metatables and metamethods. At first sight, it might be a bit confusing, but actually, it is a dead simple solution.

Metametable based classes

The most simple way to create a class is just to use a table.

Consider:

local Animal = {
  age = 0,
  kind = "unknown",
  sound = "silence",
  makeSound = function(self)
    print(self.sound)
  end,
}

Animal.age = 2
Animal.kind = "feline"
Animal.sound = "Meow!"
print(Animal.makeSound(Animal)) --> "Meow!"

Looks like it works, but here is a drawback; there is only one instance of the Animal class and new instances cannot be created. Also please notice the self variable in the Animal:makeSound() function, this is the same as Animal.makeSound(Animal). It is similar to JavaScript function binding.

Making instances

Firstly, just create an empty table.

local Animal = {}

Next, create a new() method which will return the instance of the class.

function Animal:new()
    local t = {}
    return t
end

Arguments can be passed in the function to set properties. Also, methods can be defined here, if needed:

function Animal:new(age, kind, sound)
    local t = {}
    t.age = age
    t.kind = kind
    t.sound = sound
    return t
end

Everything together with instances.

local Animal = {}

function Animal:new(age, kind, sound)
    local t = {}
    t.age = age
    t.kind = kind
    t.sound = sound
    return t
end

function Animal.makeSound(self)
    return self.sound
end

local cat = Animal:new(2, "feline", "Meow!")
print(cat.kind, Animal.makeSound(cat)) --> "feline" "Meow!"

local dog = Animal:new(3, "canis", "Woof!")
print(dog.kind, Animal.makeSound(dog)) --> "canis" "Woof!"

local hamster = Animal:new(1, "cricetinae", "Eeeee!")
print(hamster.kind, Animal.makeSound(hamster)) --> "cricetinae" "Eeeee!"

It works! In this approach, methods can be accessed from the class, but the syntax is a bit weird. From this point, __index metamethod comes to help to make everything better.

Metamethod __index

Lua allows to overload operators like +, -, /, * with metamethods. In a lot of cases, metamethods can lead to great confusion, but for OO classes, it works greatly. There is metatmethod __index which overrides table indexing mechanics. Failed methods lookups on the instances will get the class table to access the methods and members.

Consider:

local t = { x = 1 }
print(t.x) --> 1
print(t.y) --> nil
print(t.z) --> nil

Here t table doesn’t have y key, so nil is returned. But, __index metamethod set using built-in Lua’s function setmetatable() overrides this behavior:

setmetatable(t, {
  __index = { y = 2, z = 3 }
})
print(t.x) --> 1
print(t.y) --> 2
print(t.z) --> 3

Defining metatable

Animal.__index = Animal

This makes the “magic”, if method lookup fails on the instances, then it will return the class’ table to access the methods. After this, : method access operator can be used to make the syntax more clear.

local Animal = {}
Animal.__index = Animal -- this makes "magic"

function Animal:new(age, kind, sound)
    local t = {}
    t.age = age
    t.kind = kind
    t.sound = sound
    return setmetatable(t, self)
end

function Animal:makeSound()
    return self.sound
end

local cat = Animal:new(2, "feline", "Meow!")
print(cat.kind, cat:makeSound()) --> "feline"   "Meow!"

local dog = Animal:new(3, "canis", "Woof!")
print(dog.kind, dog:makeSound()) --> "canis"    "Woof!"

local hamster = Animal:new(1, "cricetinae", "Eeeee!")
print(hamster.kind, hamster:makeSound()) --> "cricetinae"   "Eeeee!"

Inheritance

Inheritance means that the child (subclass) class should access all its parent methods and members and override them if needed. It is fairly easy to achieve.

Another way to use metatables:

local Cat = {}

function Cat:new(age)
    local t = {
        age = age,
        kind = "feline",
        sound = "Meow!",
    }
    setmetatable(t, { __index = Cat })
    setmetatable(Cat, { __index = Animal })
    return t
end

-- override
function Cat:makeSound()
    return string.rep(self.sound, 3)
end

local kitty = Cat:new(4)
print(kitty.age, kitty.kind, kitty:makeSound()) --> 4   "feline"    "Meow!Meow!Meow!"

Limitations

There are no real private members or methods in Lua. Usually, these are prefixed with underscore _. But actually, everything is public with this approach.

Another good way to mark method or member as private use annotations. Modern IDEs should help with that.

Animal:_privateMethod()

Also, there is no static scope for classes. Somewhat similar to static can be achieved using the table constructor. But such defined members also will be accessible on the instances.

local Cat = {
    REIGN = "mammals"
  -- ...
}

function Cat:new()
    -- ...
end

print(Cat.REIGN) --> "mammals"

Closure-based classes

Another approach is to use closure for classes. One of the advantages private variables can be used as local variables. The Second advantage is no need to deal with metatables and : method accessor.

Consider Animal class:

local function Animal(age, kind, sound)
    local self = {
        age = age or 0,
        kind = kind or "unknown",
        sound = sound or "silence",
    }

    function self.makeSound()
        return self.sound
    end

    ---@private
    local function privateMethod()
        return 'something private'
    end

    return self
end

local cat = Animal(2, "feline", "Meow!")
print(cat.age, cat.kind, cat.makeSound())

Inheritance

Inheritance for closure-based is also straightforward.

local function Animal(age, kind, sound)
    local self = {
        age = age or 0,
        kind = kind or "unknown",
        sound = sound or "silence",
    }

    function self.makeSound()
        return self.sound
    end

    ---@private
    local function privateMethod()
        return 'something private'
    end

    return self
end

local function Cat(age)
    local self = Animal(age, "feline", "Meow!")

    -- override
    function self.makeSound()
        return string.rep(self.sound, 3)
    end

    return self
end

local kitty = Cat(4)
print(kitty.age, kitty.kind, kitty.makeSound())

Prototype-based inheritance

Prototype-based approach doesn’t use classes (class-free OOP), it uses cloning and prototype delegation. More about prototype-based object-oriented programming.

Here is the most basic example of the prototype-based approach. First of all need to define some helper functions.

---@param a any
---@param b any
---@return table
local function clone(a, b)
    if type(a) ~= "table" then
        return b or a
    end
    b = b or {}
    b.__index = a
    return setmetatable(b, b)
end

---@param a any
---@param b any
---@return boolean
local function isPrototypeOf(b, a)
    local bType = type(b)
    local aType = type(a)
    if bType ~= "table" and aType ~= "table" then
        return bType == aType
    end
    local index = b.__index
    local _isa = index == a
    while not _isa and index ~= nil do
        index = a.__index
        _isa = index == a
    end
    return _isa
end

After the functions, new instances can be created:

local Animal = clone(table, {
    age = 0,
    kind = "unknown",
    sound = "silence",
    makeSound = function(self)
        return self.sound
    end,
    clone = clone,
    isPrototypeOf = isPrototypeOf,
})
print(Animal.age, Animal.kind, Animal:makeSound(), Animal:isPrototypeOf(table)) --> 0   "unknown"   "silence"   true


local cat = Animal:clone()
cat.age = 4
cat.kind = "feline"
cat.sound = "Meow!"
print(cat.age, cat.kind, cat:makeSound(), cat:isPrototypeOf(Animal)) --> 4  "feline"    "Meow!" true

local dog = Animal:clone()
dog.age = 3
dog.kind = "canis"
dog.sound = "Woof!"
print(dog.age, dog.kind, dog:makeSound(), cat:isPrototypeOf(Animal)) --> 3  "canis" "Woof!" true

Annotations

Annotations for Lua Language Server can be very handy and greatly improve the DX. Here is the example for Animal and Cat classes with annotations.

Tip

Notice that annotations are just Lua’s comments, but begin witha a triple dash (---).

---@class Animal
---@field public age number
---@field public kind string
---@field public sound string
local Animal = {}
Animal.__index = Animal

---@param age number
---@param kind string
---@param sound string
---@return Animal
function Animal:new(age, kind, sound)
    local t = {}
    t.age = age
    t.kind = kind
    t.sound = sound
    return setmetatable(t, self)
end

---@return string
function Animal:makeSound()
    return self.sound
end

---@class Cat : Animal
local Cat = {}

---@return Cat
function Cat:new(age)
    local t = {}
    t.age = age
    t.kind = "feline"
    t.sound = "Meow!"
    setmetatable(t, { __index = Cat })
    setmetatable(Cat, { __index = Animal })
    return t
end

-- override
function Cat:makeSound()
    return string.rep(self.sound, 3)
end

local kitty = Cat:new(1)
local tom = Cat:new(2)
print(kitty.age, kitty.kind, kitty:makeSound()) --> 1   "feline"    "Meow!Meow!Meow!"
print(tom.age, tom.kind, tom:makeSound()) --> 2 "feline"    "Meow!Meow!Meow!"

Comparison

This is a very rough comparison. The results are average for many runs.

Resources consumed:

ApproachMemoryTime
Closure257833.97 Kb0m6.259s
Metatable164843.76 Kb0m6.495s
Prototype164844.70 Kb0m16.715s

The conclusion can be made that closure-based classes take more memory. The methods and members’ accessors aren’t much faster, as mentioned in the article.

Closure-based class test code
local function Animal(age, kind, sound)
    local self = {
        age = age or 0,
        kind = kind or "unknown",
        sound = sound or "silence",
    }
    function self.makeSound()
        return self.sound
    end
    -- @private
    local function privateMethod()
        return "something private"
    end
    return self
end

local ITERS = 1000000
local cats = {}
for i = 1, ITERS do
    local cat = Animal(2, "feline", "Meow!")
    cats[i] = cat
    print(cat.age, cat.kind, cat:makeSound())
end

local res = collectgarbage("count")
print(res .. "kb")
Metatables-based class test code
local Animal = {}
Animal.__index = Animal

function Animal:new(age, kind, sound)
    self.age = age or 0
    self.kind = kind or "unknown",
    self.sound = sound or "silence",
    return setmetatable(t, self)
end

function Animal:makeSound()
    return self.sound
end

local ITERS = 1000000
local cats = {}
for i = 1, ITERS do
    local cat = Animal:new(2, "feline", "Meow!")
    cats[i] = cat
    print(cat.age, cat.kind, cat:makeSound())
end

local res = collectgarbage("count")
print(res .. "kb")
Prototype-based test code
---@param a any
---@param b any
---@return table
local function clone(a, b)
    if type(a) ~= "table" then
        return b or a
    end
    b = b or {}
    b.__index = a
    return setmetatable(b, b)
end

---@param a any
---@param b any
---@return boolean
local function isPrototypeOf(b, a)
    local bType = type(b)
    local aType = type(a)
    if bType ~= "table" and aType ~= "table" then
        return bType == aType
    end
    local index = b.__index
    local _isa = index == a
    while not _isa and index ~= nil do
        index = a.__index
        _isa = index == a
    end
    return _isa
end

local Animal = clone(table, {
    age = 0,
    kind = "unknown",
    sound = "silence",
    makeSound = function(self)
        return self.sound
    end,
    clone = clone,
    isPrototypeOf = isPrototypeOf,
})

local ITERS = 1000000
local cats = {}
for i = 1, ITERS do
    local cat = Animal:clone()
    cat.age = 4
    cat.kind = "feline"
    cat.sound = "Meow!"
    print(cat.age, cat.kind, cat:makeSound(), cat:isPrototypeOf(Animal))
    cats[i] = cat
    print(cat.age, cat.kind, cat:makeSound())
end

local res = collectgarbage("count")
print(res .. "kb")

Metatable-based classes

Pros

Cons

Closure-based classes

Pros

Cons

Prototype-based

Pros

Cons

Another comparison opinion can be read in lua-users.org article.

Conclusion

In my opinion metatable method is the most optimal in performance and maintainability. Of course, everything depends on the task. Sometimes maintainability is more important than performance, especially if you are working in a large team with different technical skills. What approach to choose is up to you.

References

Feedback

For feedback, please check the contacts section. Before writing, please specify where you came from and who you are. Sometimes spammers go insane. Thank you in advance for your understanding.

← Back to the index page