Lua metatables introduction

If you ever used Lua, then you heard about its tables and the metatable feature. It is a mix of a powerful, simple and yet confusing mechanism for newcomers. Let’s walk through how they work and some examples of how useful they can be. You can also find real use cases by checking my Hammerspoon spoons.

Definitions

Lua has, beyond its primitive types, only one object-like type called table. Tables are associative arrays that can have any other type as key (except nil) and value. The runtime even uses them to load packages. That means a call to io["open"]() works as the usual io.open(). Here’s a sample of how to use tables:

#!/usr/bin/env lua

local waldo = {}

waldo[2] = "foo"
waldo["bar"] = true

for key,value in pairs(waldo) do
print(string.format("%s: %s", key, value))
end
-- output
-- 2: foo
-- bar: true

You can also use them as conventional arrays:

#!/usr/bin/env lua

local waldo = {"qux", "quz"}

print(waldo[1]) -- qux

for key,value in ipairs(waldo) do
print(string.format("%s: %s", key, value))
end

-- output
-- 1: qux
-- 2: quz

📌 Note

Lua arrays are 1-indexed, i.e. they start on index one instead of zero like some common programming languages.

Metatables

Metatables are tables assigned as a sort of controller for another table. We can describe them as a class akin to how classes work in other object-oriented languages. Another analogy is that meta tables act as a proxy of the target table.

You can assign a metatable to another table using the setmetatable:

#!/usr/bin/env lua

local meta = {}

local waldo = {}

print(getmetatable(meta)) -- nil
print(getmetatable(waldo)) -- nil

setmetatable(waldo, meta) -- set meta as the metatable of waldo

print(getmetatable(meta)) -- nil
print(getmetatable(waldo)) -- table: 0x...
print(getmetatable(waldo) == meta) -- true

Neat! Both setmetatable and getmetatable are standard library functions to either apply or remove a metatable from another table, respectively. You can use both tables in the previous example as usual, given the metatable has no metamethods. A metatable without metamethods is an ordinary table. It does not change the behavior of other tables in any way. Thus metatables need at least one metamethod to do something useful.

Metamethods

A metamethod is a value - usually a function or table - assigned to reserved key names on a metatable. The runtime looks them up when executing a certain operation on a table. They all start with two underscore characters __, so they’re easy to spot.

A good example of a metamethod is the __index. The default behavior of a table is to return nil if a requested key is not set:

#!/usr/bin/env lua

local waldo = {
foo = 1,
}

print(waldo.foo) -- 1
print(waldo.bar) -- nil

The runtime flow is:

When an index is not found on the target table, it falls back to its metatable’s __index metamethod. If present, Lua uses it to return a value instead of nil. That means you can create a custom logic for unknown index retrieval:

#!/usr/bin/env lua

local meta = {
bar = 2,
}

meta.__index = function(tbl, key)
return meta[key] or "unset"
end

local waldo = {
foo = 1,
}

setmetatable(waldo, meta)

print(waldo.foo) -- 1
print(waldo.bar) -- 2
print(waldo.qux) -- unset

What Lua tries to do in this case is:

The flow above is by no means authoritative, as I left out a few more nuances to this logic to keep it simple.1 It does cover the most common use cases though, so let’s keep it that way.

📌 Note

I’ve written __index(tbl,key) instead of __index(table,key) to prevent confusing the waldo table instance (or any metatable looked up on this loop) with the standard library table global object.

Use cases

Here’s some simple use cases to get used to how metatables work.

Default values

As the __index meta method can be a table, it may serve default values. This makes it dead simple to prevent checking/hard-coding alternative values everywhere:

#!/usr/bin/env lua

local defaults = {
["bar"] = true
}

defaults.__index = defaults

local waldo = {}

setmetatable(waldo, defaults)

print(waldo["bar"]) -- true

for key,value in pairs(waldo) do
print(string.format("%s: %s", key, value))
end
-- no output

waldo.bar = false

for key,value in pairs(waldo) do
print(string.format("%s => %s", key, value))
end
-- output:
-- bar => false

print(waldo["bar"]) -- false

Fallback values

Like the default values case, you can use a function as the __index metamethod to return a fallback value. This works for any key, not only those set on the metatable. This allows you to use many fallback tables, or always return an specific value type. This again contributes to a DRY code:

#!/usr/bin/env lua

local systemDefaults = {
["foo"] = "1",
}

systemDefaults.__index = systemDefaults

local userDefaults = {
["foo"] = "x",
["bar"] = "2",
}

userDefaults.__index = function(_, key)
return userDefaults[key] or ""
end

setmetatable(userDefaults, systemDefaults)

local waldo = {
["baz"] = "3",
}

setmetatable(waldo, userDefaults)

print(waldo["foo"]) -- x
print(waldo["bar"]) -- 2
print(waldo["baz"]) -- 3
print(waldo["qux"]) -- (an empty string)

Queue

The metatable __index together with the colon operator allows the implementation of methods. These resemble object-oriented methods thanks to Lua’s syntactic sugar. You can then create a model-controller pattern between a table and its metatable:

#!/usr/bin/env lua

local Queue = {}

Queue.__index = Queue

function Queue.new(target)
local target = target or {}
setmetatable(target, Queue)
return target
end

function Queue:push(value)
table.insert(self, value)
end

function Queue:pop()
return table.remove(self, 1)
end

function Queue:length()
return #self
end

local jobs = Queue.new()

print(jobs:length()) -- 0
print(jobs:pop()) -- nil

jobs:push("foo")

print(jobs:length()) -- 1

jobs:push("bar")

print(jobs:length()) -- 2
print(jobs:pop()) -- foo
print(jobs:length()) -- 1

jobs:push("qux")

print(jobs:length()) -- 2

-- jobs is still a plain table
for key,value in ipairs(jobs) do
print(string.format("%s: %s", key, value))
end
-- output
-- 1: bar
-- 2: qux

Beautiful. I’ll dive into the colon operator on a separate post later on.

📌 Note

The example above is a rather naive implementation to keep it simple. It doesn’t prevent direct CRUD operations, non-numerical or out-of-order keys.

Another reason is that Lua by design is extensible. That means it doesn’t provide as many ways to lock down tables as it offers to extend them. That doesn’t mean you can’t make things harder to tamper. You can set the __newindex and __index functions to block assignment and retrieval. In this case, both push and pull methods must use rawset and rawget to bypass them.

If you have a strong need to enforce data integrity then you should consider using an userdata value. You can define them using the C API.

Conclusion

I’ve only scratched the surface of Lua’s metatables. There’s dozens of supported metamethods. Yet the indexing ones are more than enough to show the power of this mechanism. They are the main drive that makes Lua excellent to extend and prototype fast. This alone explains why the gaming industry has a wide use for Lua.

📝 Edit

Updated diagrams, as I don’t use Mermaid anymore. In fact, I used it in this blog while using PlantUML and Mingrammer diagrams everywhere else. Now they’re all consistent.

Changed diagrams again, this time switching them to PNG instead of SVG. Size-wise they’re small and fare better when embedded in feeds like RSS and ATOM.

Yet another diagram change. This time I’m migrating to D2, which provides a great syntax for diagrams and a standalone CLI to generate good-looking SVGs.

The documentation states that any value that can resolve the __index metamethod works. This means userdata (C objects) can also act as a metatable. ↩︎