Skip to content

Classes and inheritance

Lesson 7.2

Lua has no class keyword, but metatables make classes easy. This lesson builds objects with methods, then shares behaviour through inheritance.

Methods and the colon

A method is a function stored in a table that works on that table. You could pass the table in by hand:

local player = { name = "Alex", health = 20 }

function player.damage(self, amount)
  self.health = self.health - amount
end

player.damage(player, 5)
print(player.health)  --> 15

The colon does this for you. Defining with : adds a hidden first parameter called self. Calling with : passes the table as that first argument:

local player = { name = "Alex", health = 20 }

function player:damage(amount)
  self.health = self.health - amount
end

player:damage(5)
print(player.health)  --> 15

player:damage(5) means exactly player.damage(player, 5). You have used this already: name:upper() is string.upper(name).

Dot or colon?

Mixing them up is the most common bug with objects. player.damage(5) passes 5 as self, and you get an error such as attempt to index a number value (local 'self'). If a function was defined with :, call it with :.

A class

A class is a table that holds the methods, and every object uses it as its __index. The objects store only their own data:

local Mob = {}
Mob.__index = Mob

function Mob.new(name, health)
  local self = setmetatable({}, Mob)
  self.name = name
  self.health = health
  return self
end

function Mob:damage(amount)
  self.health = math.max(self.health - amount, 0)
end

function Mob:is_dead()
  return self.health == 0
end

function Mob:__tostring()
  return self.name .. " (" .. self.health .. " HP)"
end

local zombie = Mob.new("Zombie", 20)
zombie:damage(8)
print(zombie)            --> Zombie (12 HP)
print(zombie:is_dead())  --> false

How it works:

  1. Mob.__index = Mob means: when an object doesn’t have a key, look in Mob.
  2. Mob.new creates a table, sets Mob as its metatable and fills in the fields. This is the constructor.
  3. zombie:damage(8) looks for damage in zombie, doesn’t find it, and finds it in Mob through __index.
  4. Mob is also the metatable, so its __tostring works for every mob.

Each object has its own name and health, but all mobs share one copy of the methods.

Some people call the constructor with a colon, Mob:new(...), and use self instead of Mob inside it. Both styles work. Mob.new is the simpler one, because a constructor isn’t working on an existing object.

Inheritance

A subclass reuses the methods of its parent and adds or changes some. Set the parent as the subclass’s __index:

local Mob = {}
Mob.__index = Mob

function Mob.new(name, health)
  return setmetatable({ name = name, health = health }, Mob)
end

function Mob:damage(amount)
  self.health = math.max(self.health - amount, 0)
end

function Mob:describe()
  return self.name .. " has " .. self.health .. " HP"
end

-- Creeper is a Mob
local Creeper = setmetatable({}, { __index = Mob })
Creeper.__index = Creeper

function Creeper.new(fuse)
  local self = Mob.new("Creeper", 20)   -- build the Mob part
  self.fuse = fuse
  return setmetatable(self, Creeper)    -- then make it a Creeper
end

function Creeper:explode()
  return self.name .. " explodes after " .. self.fuse .. " ticks!"
end

-- override a parent method, and call the parent's version
function Creeper:describe()
  return Mob.describe(self) .. ", fuse " .. self.fuse
end

local c = Creeper.new(30)
c:damage(5)                -- damage is found in Mob
print(c:describe())        --> Creeper has 15 HP, fuse 30
print(c:explode())         --> Creeper explodes after 30 ticks!

The lookup goes along a chain: first the object, then Creeper, then Mob. c:damage isn’t in c or Creeper, so it comes from Mob.

To call the parent’s version of an overridden method, call it through the parent with a dot and pass self: Mob.describe(self).

Checking the class

getmetatable(obj) == Creeper checks the exact class. To check “is a” through the whole chain, walk up:

local function is_a(obj, class)
  local mt = getmetatable(obj)
  while mt do
    if mt == class then return true end
    local parent = getmetatable(mt)
    mt = parent and parent.__index
  end
  return false
end

A tiny class helper

If you write many classes, a helper removes the repetition. This one supports a parent class and a constructor called init:

local function class(parent)
  local c = {}
  c.__index = c
  if parent then
    setmetatable(c, { __index = parent })
  end
  c.new = function(...)
    local obj = setmetatable({}, c)
    if obj.init then obj:init(...) end
    return obj
  end
  return c
end

local Animal = class()
function Animal:init(name) self.name = name end
function Animal:speak() return self.name .. " makes a sound" end

local Cat = class(Animal)
function Cat:init(name, lives)
  Animal.init(self, name)
  self.lives = lives
end
function Cat:speak() return self.name .. " meows" end

local a = Animal.new("Cow")
local c = Cat.new("Tom", 9)
print(a:speak())  --> Cow makes a sound
print(c:speak())  --> Tom meows
print(c.lives)    --> 9

Note that metamethods such as __tostring and __add are not inherited through __index: Lua looks them up directly in the metatable. Copy them into the subclass if you need them there.

Private data

Fields in a table are always visible. Lua programmers usually mark “please don’t touch” fields with a leading underscore, such as self._cooldown. For real privacy, keep data in a closure (see More about functions) instead of in the object.

Try it

Write a class Item with Item.new(id, count), a method add(n) that never lets the count go above 64 and returns how many didn’t fit, and a __tostring like minecraft:arrow x16. Then make a subclass Tool with a durability field, a use() method that lowers it by 1, and a count that is always 1.

Show a solution
local Item = {}
Item.__index = Item

function Item.new(id, count)
  return setmetatable({ id = id, count = count or 1 }, Item)
end

function Item:add(n)
  local space = 64 - self.count
  local added = math.min(n, space)
  self.count = self.count + added
  return n - added
end

function Item:__tostring()
  return self.id .. " x" .. self.count
end

local Tool = setmetatable({}, { __index = Item })
Tool.__index = Tool
Tool.__tostring = Item.__tostring   -- metamethods aren't inherited

function Tool.new(id, durability)
  local self = Item.new(id, 1)
  self.durability = durability
  return setmetatable(self, Tool)
end

function Tool:add(n)
  return n   -- tools don't stack: nothing fits
end

function Tool:use()
  self.durability = self.durability - 1
end

local arrows = Item.new("minecraft:arrow", 60)
print(arrows:add(10))  --> 6
print(arrows)          --> minecraft:arrow x64

local pick = Tool.new("minecraft:iron_pickaxe", 250)
pick:use()
print(pick, pick.durability)  --> minecraft:iron_pickaxe x1	249

Recap: obj:method() passes obj as self, a class is a table used as __index for its objects, and a subclass sets its parent as its own __index.