Understanding Metatables, with practical use cases

Introduction

Many developers are confused by metatables, as they are not very intuitive, and their use cases not always clear. I’ll go over what exactly are metatables, some practical use cases like OOP and other fun stuff :wink:

How to use Metatables

I think it’s important to first know how to turn a normal table into a metatable

To make a metatable, you (usually) need two tables. The first one is simply your normal table. The second one, is a table containing metamethods, which can be found on the documentation page of Metatables, and a function (or table) that will define the custom behaviour.

local Table = {}
local Metamethods = {__index = function(t,i) end}

setmetatable(Table,Metamethods)

setmetatable is used to apply the metamethods from the second table onto the first one

What are metatables?

I think of metatables as a way to give custom behavior to tables in different situations, with the use of methamethods, which “activates” the custom behaviour. As an example, the most used metamethod, __index, “activates” when a nil index of a table is read:

local Table = {}
local Metamethods = {__index = function(t,i) print("ACTIVATED") end}

setmetatable(Table,Metamethods)

local Value = Table[1] --> ACTIVATED
print(Value) --> Nil

In this example, Table[1] is indexed, but it’s nil, which in turns, runs the function tied to __index

What can you do with metatables?

The use cases that jumps to the eye (though, far from being the most useful use case), is making a Vector or CFrame “class” using tables and these metamethods:
image
What does this mean? These allow us to define what should happen when operators are used on tables!
Yes, you heard me right, we can add tables together!

-- // Module Script // --
local Vector3Module = {}

local Metamethods = {
	__add = function(Vector, Value)
		-- Some error catching stuff
		if type(Value) ~= "table" then error("attempt to perform arithmetic (add) on Vector3 and "..type(Value)) end
		if Value._type ~= "Vector3" then error("attempt to perform arithmetic (add) on Vector3 and "..(Value._type or "table")) end
		
		-- The other Value is an other Vector, which means we can add them together!
		-- The vector is stored as an array in this case, so we can loop from 1 to 3, and add together the values
		for i = 1, 3 do 
			Vector[i] += Value[i]
		end
		return Vector -- Return the new vector
	end,
	
	__mul = function(Vector, Value)
		-- Some error catching stuff
		if type(Value) ~= "number" then error("attempt to perform arithmetic (add) on Vector3 and "..type(Value)) end
		
		-- The other Value is a number, which means we can multiply the Vector!
		-- Same here, loop from 1 to 3, but multiply by the number 
		for i = 1, 3 do 
			Vector[i] *= Value
		end
		return Vector -- Return the new vector
	end,
}

function Vector3Module.new(x,y,z)
	local Vector = {x,y,z,_type = "Vector3"} -- _type is used to be able to tell if a table is a Vector or not 
	setmetatable(Vector,Metamethods)
	
	return Vector
end

return Vector3Module

-- // Script // --

local VectorModule = require(script.VectorModule)

local VectorA = VectorModule.new(1,4,-2)
local VectorB = VectorModule.new(-5,2,6)

local VectorC = VectorA * -1
print(VectorC) --> {[1] = -1,[2] = -4,[3] = 2,["_type"] = "Vector3"}

local VectorD = VectorC + VectorB
print(VectorD) --> {[1] = -6,[2] = -2,[3] = 8,["_type"] = "Vector3"}

local VectorE = VectorD + "a" --> attempt to perform arithmetic (add) on Vector3 and string 

The first parameter of the metamethod’s functions is the Table to which the metamethod is “asigned”. The second parameter is the other value involved in the operation, it can be anything, a nubmer, a string, a Motor6D… You could define what happens when you add a Motor6D to a Vector :wink:

This makes for some very neat code! But what about that _type property? It doesn’t belong to the Vector, but it’s not like we can get rid of it… (well maybe you could but ehhhh)

We can’t remove it, but we can hide it, with __index!

local Metamethods = {
	__index = function(Vector, Index)

	end,
}

function Vector3Module.new(x,y,z)
	local Vector = {x,y,z}
	setmetatable(Vector,Metamethods)
	
	return Vector
end

Ok, cool, but how does __index help here?

Well, whatever is returned by the index function, will be what the script that indexed the table will receive:

local Metamethods = {
	__index = function(Table, Index)
		return "(´・ω・`)?"
	end,
}

local Table = {}
setmetatable(Table,Metamethods)

print(Table[2]) --> (´・ω・`)?

Even though Table is empty, __index makes it so Table[Index] will never return nil, instead, it returns funny face
(Note that Table[2] stays nil, it doesn’t become funny face)

This means that we can keep the _type property, while hidding it away from the Vector table:

local Metadata = {
	_type = "Vector3",
	_OtherProperty = "OtherValue",
}

local Metamethods = {
	__index = function(Vector, Index)
		return Metadata[Index] -- Using a table, so more than one hidden property can be added
	end,
}

function Vector3Module.new(x,y,z)
	local Vector = {x,y,z}
	setmetatable(Vector,Metamethods)
	
	return Vector
end

But we’re not done, Lua provides us with an other neat trick
__index (and __newindex) have the special ability to accept a table instead of a function. In the case of __index, it will basically look up into that other table if it can’t find it in the original table, well, it will have the exact same behaviour as the previous example!

local Metadata = {
	_type = "Vector3",
	_OtherProperty = "OtherValue",
}

local Metamethods = {
	__index = Metadata,
}

function Vector3Module.new(x,y,z)
	local Vector = {x,y,z}
	setmetatable(Vector,Metamethods)
	
	return Vector
end

The result?

local VectorModule = require(script.VectorModule)

local Vector = VectorModule.new(1,4,-2)

print(Vector._type) --> Vector3
print(Vector) --> {[1] = 1,[2] = 4,[3] = -2}

Magic!

The Infinite Table

Here is my favorite use case for metatables, and the one I use the most often
I call it the infinite table because, if you index a value that is nil, it’s not longer nil, it’s a new table!

local Table = {}
local Metamethods = {
	__index = function(Table, Index)
		Table[Index] = {} -- Create a new table inside the original table
		return Table[Index] -- Return that table, otherwise it would return nil
	end,
}

setmetatable(Table,Metamethods)

local X = 3
local Y = 5

Table[X][Y] = "Epic 2D Tile"

print(Table) --> {[3] =  ▼  {[5] = "Epic 2D Tile"}}

This avoids a lot of if statements, no need to check if the table already exists, if it doesn’t, it will be created automatically, without effort!

The compact version:

local Table = setmetatable({},{__index = function(t,i) t[i] = {} return t[i] end})

You might also notice that setmetatable is used differently here. setmetatable returns the table passed to it (the first one), with the metamethods applied. It makes for tidier code

But it’s not quite “infinite” yet

local function InfiniteTable(Table, Index)
	Table[Index] = setmetatable({},{__index = InfiniteTable})
	return Table[Index]
end

-- setmetatable returns the table passed to it, which makes for nice and compact code
local Table = setmetatable({},{__index = InfiniteTable})

local X = 3
local Y = 5
local Z = -3

Table[X][Y][Z] = "Epic 3D Block"

print(Table) --> {[3] =  ▼  {[5] =  ▼  {[-3] = "Epic 3D Block"}}}

There’s some sort of weird recursion going on in that function… The function is called again as the new table’s __index metamethod “activates”, creating yet another table. Anyway, now, there is no limit to how far down you can go in the table!

OOP

Here is your basic OOP structure:

local Module = {}
Module.__index = Module

function Module.new(HonkSound)
	local Object = setmetatable({},Module)
	
	Object.HonkSound = HonkSound
	
	return Object
end

function Module:Honk()
	self.HonkSound:Play()
end

return Module

The first part was the most confusing to me

local Module = {}
Module.__index = Module

Module is both a “normal” table and a metamethods table, but it doesn’t have to be:

local Module = {}

function Module.new(HonkSound)
	local Metamethods = {__index = Module}
	local Object = setmetatable({},Metamethods)
	
	Object.HonkSound = HonkSound
	
	return Object
end

function Module:Honk()
	self.HonkSound:Play()
end

return Module

This works the exact same way. Object only needs to have __index set to the module, allowing functions from Module to be used on Object. Setting Module.__index to itself is simply a little trick, combining the metamethod with the module table, cutting down on a table and having neater code overall

But why does self have access to HonkSound?

function Module:Honk()
	self.HonkSound:Play()
end

While the function is inside Module, it was called from Object, and not from Module, thus self is Object, and not Module. (I guess that’s how it works) (more details)

Proxy Tables

It was 3:30 AM so I didn’t write this section,
Might do this section if the tutorial gets more visibility

Conclusion

I hope this has been helpful,
If there is anything that is confusing or unclear, please give some feedback, and I’ll try to improve the tutorial accordingly

19 Likes

Would warn you here in this part, this MIGHT cause a C-Stack overflow error (I think, but I see it happening if there is a __newindex included!) so instead of doing Table[Index] = {}, you must instead do rawset(Table, Index, {}

2 Likes

There is a simple typo here.
Also, nice tutorial.

1 Like

You are right, it will cause a C-Stack overflow if the table has __newindex (in some situations).
In this case however, it works just fine, but if you do have __newindex, rawset is indeed required

1 Like

So basically how this works:
In Lua, colon notation is just syntactic sugar here. It is directly identical to the following code:

function Module.Honk(self)
	self.HonkSound:Play()
end

Colon notation just adds legibility by implicitly passing the calling table as self. The reason self is Object can be explained simply in steps.

  • 1: You have created what is called a constructor function, which in Lua applies inheritance handling within it.
  • 2: The metatable that your constructor function attaches to all new objects contains __index = Module. Which means that when you attempt to index a non-existant key of your new object, it will follow up by searching Module for that key.
  • 3: Your objects don’t contain type function Honk key, and so Module is then indexed for it instead, which results in finding your function.
  • 4: Your type function Honk key (mind you, still inside of Module not your object) is called, but your object is what called it, not Module, therefor your object with colon notation is implicitely passed as self.

This is a good point that you mention it doesn’t have to be. In larger OOP projects, you really aren’t likely to see this because the code becomes obfuscated (imo) to include both methods (object related functions) and their constructors in the same location. You can - it’s just probably not great. It does act as a great learning example though.

Proxy tables are interesting because Luau negates one of their main uses (immutable tables - which can now be created innately through the table lib). I wrote a DevForum article a while ago about performance differences between the two:

2 Likes

About Proxy tables, I wrote that because the tutorial wasn’t getting a lot of visibility and initially I didn’t write it because it was 3:30 AM lol,
Maybe if I have some time to kill, but as of now that isn’t the case

I appreciate the details you’ve added

1 Like