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
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:
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
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