Metatables
Lesson 7.1
A metatable changes how a table behaves: what happens when you read a missing key, add two tables, call a table like a function, or print it.
The idea
Every table can have a metatable: a second, ordinary table that holds special fields called metamethods. Their names start with two underscores, like __index or __add. When Lua meets an operation it can’t do on its own, such as adding two tables, it looks in the metatable for the matching metamethod and calls it.
setmetatable(t, mt) attaches a metatable and returns t. getmetatable(t) gives it back.
local t = {}
local mt = {}
setmetatable(t, mt)
print(getmetatable(t) == mt) --> true
__index: fallback for missing keys
When you read a key that a table doesn’t have, Lua checks the metatable’s __index. If __index is a table, Lua looks the key up there:
local defaults = { health = 20, speed = 1.0 }
local zombie = setmetatable({ speed = 0.7 }, { __index = defaults })
print(zombie.speed) --> 0.7
print(zombie.health) --> 20
zombie has its own speed, but no health, so Lua falls back to defaults.health. The fallback table is not copied; it is shared. This is the base of classes and inheritance in the next lesson.
__index can also be a function. Lua calls it with the table and the missing key:
local names = setmetatable({}, {
__index = function(t, key)
return "minecraft:" .. key
end,
})
print(names.stone) --> minecraft:stone
print(names.dirt) --> minecraft:dirt
__index is only used for keys that are missing. If the table has the key, Lua never asks the metatable.
__newindex: watching new keys
__newindex runs when you assign to a key that doesn’t exist yet. You can use it to reject or redirect writes:
local function read_only(t)
return setmetatable({}, {
__index = t,
__newindex = function(_, key)
error("can't change " .. tostring(key) .. ": the table is read-only", 2)
end,
})
end
local config = read_only({ max_players = 20 })
print(config.max_players) --> 20
print(pcall(function() config.max_players = 50 end))
The last line prints false and the error message. pcall catches errors; the lesson Error handling explains it. The proxy table stays empty, so every write goes through __newindex.
rawget and rawset
Sometimes you want to skip the metatable. rawget(t, k) reads a key without __index, and rawset(t, k, v) writes without __newindex:
local zombie = setmetatable({}, { __index = { health = 20 } })
print(zombie.health) --> 20
print(rawget(zombie, "health")) --> nil
Inside a __newindex function, use rawset to really store the value. A plain t[k] = v there would call __newindex again, forever.
__tostring: how it prints
print and tostring use __tostring if it exists:
local pos_mt = {
__tostring = function(p)
return string.format("(%d, %d, %d)", p.x, p.y, p.z)
end,
}
local spawn = setmetatable({ x = 0, y = 64, z = 0 }, pos_mt)
print(spawn) --> (0, 64, 0)
Without it, a table prints as something like table: 0x55d0c3a8.
__call: tables you can call
__call lets you call a table like a function. The table itself comes first, then the arguments:
local counter = setmetatable({ n = 0 }, {
__call = function(self, step)
self.n = self.n + (step or 1)
return self.n
end,
})
print(counter()) --> 1
print(counter(5)) --> 6
Operators
Metamethods also define what the operators do on tables:
| Metamethod | Operator |
|---|---|
__add, __sub, __mul, __div |
+, -, *, / |
__mod, __pow, __idiv, __unm |
%, ^, //, unary - |
__concat |
.. |
__len |
# |
__eq |
== (and ~=) |
__lt, __le |
<, <= (and >, >=) |
__band, __bor, __bxor, __shl, __shr, __bnot |
bitwise operators |
A small vector type:
local Vec = {}
Vec.__index = Vec
local function vec(x, y, z)
return setmetatable({ x = x, y = y, z = z }, Vec)
end
Vec.__add = function(a, b) return vec(a.x + b.x, a.y + b.y, a.z + b.z) end
Vec.__mul = function(v, n) return vec(v.x * n, v.y * n, v.z * n) end
Vec.__eq = function(a, b) return a.x == b.x and a.y == b.y and a.z == b.z end
Vec.__tostring = function(v) return "(" .. v.x .. ", " .. v.y .. ", " .. v.z .. ")" end
local pos = vec(10, 64, 5)
local step = vec(1, 0, 0)
print(pos + step) --> (11, 64, 5)
print(step * 3) --> (3, 0, 0)
print(pos + step == vec(11, 64, 5)) --> true
Some details:
- For
a + b, Lua checksa’s metatable first, thenb’s. So__mulabove handlesstep * 3, but3 * stepwould call__mul(3, step), with the number first. A full vector type checks the argument types. __eqis only used when both sides are tables (or both userdata). Comparing a table to a number is simplyfalse.a > bis turned intob < a, anda >= bintob <= a. Define both__ltand__leif you want all four comparisons.
Celestial uses this exact idea: positions in the API are tables with a metatable, so you can write pos + offset.
Other metamethods
__lenchanges what#treturns.__concathandlest .. "text".__closeruns when a<close>variable goes out of scope (see Error handling).__gcruns when the garbage collector frees the table.__mode = "k"or"v"makes a weak table, whose keys or values don’t keep their objects alive. It is useful for caches.__metatablehides the metatable:getmetatablereturns this field instead, andsetmetatablerefuses to change it.__nameis used by some error messages and bytostringfor userdata.
Try it
Make a “safe” inventory: a table where reading an item that isn’t there gives 0 instead of nil, so inv.diamond + 1 always works. Then give it a __tostring that lists the items as name=count, sorted by name.
Show a solution
local inv_mt = {
__index = function() return 0 end,
__tostring = function(inv)
local names = {}
for name in pairs(inv) do names[#names + 1] = name end
table.sort(names)
local parts = {}
for _, name in ipairs(names) do
parts[#parts + 1] = name .. "=" .. inv[name]
end
return table.concat(parts, ", ")
end,
}
local inv = setmetatable({}, inv_mt)
inv.diamond = inv.diamond + 1
inv.coal = inv.coal + 16
print(inv.emerald) --> 0
print(inv) --> coal=16, diamond=1
pairs only sees keys the table really has, so the __index default doesn’t add entries.
Recap: a metatable’s metamethods decide what happens on missing keys (__index), new keys (__newindex), printing, calls and operators.