Introduction
This tutorial will quickly review basic usage of tables and dictionaries and later get into metatables, and how they can be used to better your code.
Pre-Requisites:
- Tables
- ModuleScripts
Terminology Used
Array: A collection of various data types put together and ordered using numerical keys.
- Example: { “Hello” , “World”, true, 27, { } }
Dictionary: A collection of various data types put together using non-numerical keys.
- Example: {Key1 = “Nice” , EpicKey = “EPIC” , SomeNumber = 27 }
Function: A set of instructions that execute a task, not associated with an object.
- Example:
local function exampleFunc() print("Hello World!") end
Method: A set of instructions that execute a task, associated with an object:
- Example:
Part:Clone()
Let’s Get Started!
Before we begin talking about metatables it’s important that we have a solid understanding of tables and dictionaries. In this tutorial we will mainly be focusing on dictionaries for the reason of us being able to have non-numerical keys. Very quickly I will go over dictionaries and how to set one up.
-- you can use ; (semicolon) or , (comma) at the end of each key w/ its value
-- I prefer using semicolons
local myDictionary = {
A = "Hello";
B = "World";
What = 2727;
}
-- "A", "B", and "What" are all keys in the dictionary "myDictionary"
-- Their values are across the equal sign
-- If we wanted to get the value of key "A" we would do this:
print(myDictionary.A) --> Hello
-- We could also do this (same output)
print(myDictionary["A"]) --> Hello
-- If you try to index a key that isn't defined in the dictionary,
-- it will return nil
print(myDictionary.no) --> nil
Above is a very basic explanation of dictionaries and if you are already familiar with them, it would be best to skip it, if not there is no harm in reading!
Metatable Introduction
Before we start discussing the wonderful ways in which metatables can better your code, let’s discuss what they even are.
Metatables really aren’t all that confusing to understand but sometimes people over complicate them when they shouldn’t be. By definition, a metatable is a regular Lua table that can only contain these things called metamethods. It’s probably important to know what a metamethod is then right? A metamethod is an event that is fired when an action is done upon a table. How this is accomplished is by attaching the metatable to a normal table. I will explain a function to attach the metatable after I list the various metamethods.
All Metamethods
The main method we will be focusing on in this tutorial is the __index metamethod. Feel free to mess with the other’s but this tutorial will not go over them.
Attaching a Metamethod
In order to attach a metatable to a table or dictionary we need to use a global Lua function called setmetatable. The wiki image will explain it below, but I will explain it further after.
setmetatable takes two parameters, the first being the normal table or dictionary we want to attach the metatable to, the second being the metatable we are attaching. So it would look something like this:
local myTable = { Value = "Nice" }
setmetatable(myTable,{})
--Alternate way to do it:
local myTable = setmetatable({ Value = "Nice" },{ })
Using the __index Metamethod
As said before I will only be using the __index metamethod in this tutorial as it will be useful later when we get into a coding that relates to Object Oriented Programming. For now I will explain how this metamethod works and how to use it.
According to the API, this metamethod does this:
If you don’t understand how the wiki explains it, let me try to clear things up for you.
Let’s say we have a dictionary called “myDictionary” and we set a couple keys for said dictionary:
local myDictionary = {
A = 42;
B = true;
}
Now let’s say we want to get the value of the key “A”. We would have to index the key in the dictionary in order to get it’s assigned value. Like this:
local myDictionary = {
A = 42;
B = true;
}
local whatWeWant = myDictionary.A
print(whatWeWant) --> 42
Now look back at the description of the __index metamethod, this method is fired when you try to index something in a table ONLY if that key you try to index is nil. So if we did this:
local myDictionary = {
A = 42;
B = true;
}
local whatWeTry = myDictionary.why -- the __index metamethod will fire
print(whatWeTry) --> nil
The only issue with the example above is we assumed there was a metatable attached to the normal table. Let’s have an example where I actually attach a metatable to a table with the __index metamethod inside of it:
-- first I create the normal table
local myTable = {
A = "Epic";
B = true;
}
-- now I will attach a metatable to the normal and have an __index metamethod inside of it
setmetatable(myTable,{
__index = function(normalTab,attemptedKey)
print(normalTab,attemptedKey)
end
})
-- now if I try to index a nil key this will happen
local myKey = myTable.noExist
--> __index fires > table_address "noExist"
print(myKey) --> nil
As you can see the __index metamethod takes 2 parameters: the normal table and the key you attempt to index. In the function body I choose to print out the parameters.
Please do mess around with this knowledge, but know I will be getting into a more important aspect of the __index metamethod. Notice how I am setting it to a function, but you can also set it to another table. If the key you index in the first table is nil, and your __index metamethod is set to another table, it will attempt to find said key in the second table before returning nil.
For example:
local firstTable = {
A = "Epic";
B = true;
}
local secondTable = {
C = "Woah";
}
setmetatable(firstTable,{
__index = secondTable;
})
-- now if we try to index a nil value in the first table
-- it will check the second table to see if it exists there
local value = firstTable.C --> __index metamethod fires
print(value) --> Woah
Alright awesome! Now that we have accomplished this I can finally show you how these metatables and metamethods can be useful.
First we Need ModuleScripts
If you aren’t familiar with what a ModuleScript is, let me briefly explain it. A ModuleScript is a type of script object that was made to return data when retrieved using a require function. For example:
--ModuleScript
--This is what you will see inside a newly created ModuleScript
local module = {}
return module
--(Normal)Script
--Now let's get the data from that ModuleScript
local myModule = require(path_to_module)
The variable “myModule” is now equal to the table that we return inside the ModuleScript. We can return other things besides tables, like strings, numbers, functions, etc. but the most useful return value to use are tables and dictionaries.
Object Oriented Programming (OOP)
According to Wikipedia, “Object-oriented programming is a programming paradigm based on the concept of “objects”, which can contain data, in the form of fields, and code, in the form of procedures.”
To explain that in more simpler terms, in OOP, everything is considered as objects, and those objects are created using classes. If you think about it, this is a very good approach because, all objects in the real world have properties about them that make them what they are. In programming, we can code objects to have properties (with variables), states (with variables), and behaviors (with methods).
Now you may be asking, well how do I create objects. And there is a very simple answer: dictionaries. It would be impractical to use arrays, because of the fact they have numerical ordered keys. But with dictionaries, there can be non-numerical keys, which is good if we want to have custom properties and methods.
Let’s dive straight into creating objects with tables, and we are going to do that with ModuleScripts. Here’s how:
--ModuleScript
local Object = {}
return Object
Alright great, so we made a ModuleScript containing a table called “Object”. Next I will demonstrate how the __index metamethod will play a role.
--ModuleScript
local Object = {}
Object.__index = Object
return Object
You may be confused by what I just did, but remember how we can set the __index metamethod to a table, well I basically just created a new key for the metamethod and set it to the table we have created called “Object”. You may be questioning why I do this, but let me continue further with code so it hopefully makes more sense.
--ModuleScript
local Object = {}
Object.__index = Object
function Object.new()
end
return Object
We have now created a new function key in “Object” called new. This term is generally used when creating new objects, even in other programming languages.
--ModuleScript
local Object = {}
Object.__index = Object
function Object.new()
local newObject = setmetatable({ },Object)
return newObject
end
return Object
Alright so you may be confused on what I just did, but let me explain. As mentioned before, the setmetatable function is used to attach a metatable to the normal table, the normal table being the first parameter, and the metatable being the second. It also returns the normal table with the attached metatable. In the code, I attach “Object” to a new blank table I create, and then return that table at the end.
The question now is, why do I set the metatable of the new table equal to Object. Remember how I said, metatables can only contain metamethods, and any other key that is not a metamethod will get ignored and become nil. Well in our situation, at the top of the code, I set a new metamethod key of __index equal to the Object table.
Next I want you to remember how when the __index metamethod is set to another table, if you try to index a nil key in the first table, it will attempt to find said key in the table you set the metamethod to.
This is great because now we can index keys in our newly created table and have them search the Class to see if it exists. Notice how I am calling the Object table a class now. A class is basically the blueprint for an object.
Here is how this can be useful:
--ModuleScript
local Object = {}
Object.__index = Object
Object.SomeProperty = 27
function Object.new()
local newObject = setmetatable({ },Object)
return newObject
end
return Object
--(Normal)Script
local Object = require(path_to_module)
--now lets create a new object with our "new" method
local newObject = Object.new()
--now we will attempt to find "SomeProperty" inside of the newObject
print(newObject.SomeProperty) --> 27
--this successfully prints because "SomeProperty" is a key inside of the
--Object table which is what __index is set to in the newObject's metatable
We can also apply this knowledge to methods, which may look a little more useful:
--ModuleScript
local Object = {}
Object.__index = Object
function Object.new()
local newObject = setmetatable({ },Object)
return newObject
end
function Object:SayHello()
print("Hello there!")
end
return Object
--(Normal)Script
local Object = require(path_to_module)
local newObject = Object.new()
-- this successfully prints because "SayHello" is a method of the Object table
-- and thanks to our __index metamethod
newObject:SayHello() --> Hello there!
Creating Properties in the New Object
Besides creating new keys in the Object table and having the __index metamethod handle finding those keys, we can also create them in the “newObject” table, like so:
--ModuleScript
local Object = {}
Object.__index = Object
function Object.new()
local newObject = setmetatable({ },Object)
newObject.IsEpic = true
return newObject
end
function Object:SayHello()
print("Hello there!")
end
return Object
Now we get the “property” in a normal script, like so:
--(Normal)Script
local Object = require(path_to_module)
local newObject = Object.new()
print(newObject.IsEpic) --> true
One major issues that comes up is, what if we want to access our newObject we create inside of our Object methods. This can be done easily using the keyword: “self”. It is pretty self explanatory (pun intended), but it refers to itself, or the table in which we are calling the method upon.
For example, let’s say we want to return the “IsEpic” property in a method called “GetEpicStatus”.
--ModuleScript
local Object = {}
Object.__index = Object
function Object.new()
local newObject = setmetatable({ },Object)
newObject.IsEpic = true
return newObject
end
function Object:GetEpicStatus()
-- self refers to the table that calls this method
-- since __index exists allowing for us to call this method upon newObject
-- self would refer to the newObject
return self.IsEpic
end
return Object
--(Normal)Script
local Object = require(path_to_module)
local newObject = Object.new()
print(newObject:GetEpicStatus()) --> true
Using a : (colon) VS . (dot)
When creating methods for our “Object”, there are actually two ways of going about doing so. You can either use a : (colon) or a . (dot). For example:
--ModuleScript
local Object = {}
Object.__index = Object
function Object.new()
local newObject = setmetatable({ },Object)
newObject.IsEpic = true
return newObject
end
function Object:GetEpicStatus()
return self.IsEpic
end
function Object.GetEpicStatus(self)
return self.IsEpic
end
return Object
This person below explains it very well and I couldn’t have explained it better myself.
Credit to @Isocortex
In our script, however, the normal script would look something like this:
--(Normal)Script
local Object = require(path_to_module)
local newObject = Object.new()
print(newObject:GetEpicStatus()) --> true
print(newObject.GetEpicStatus(newObject)) --> true
Please be aware if you are using a colon or dot, because you cannot use a colon is you use a dot and vice versa.
Example of Actual Usecase
I am going to very quickly setup a ModuleScript and use OOP to create, load, and manage data using the knowledge we have learned. I am not going to be very detailed or complete with the code, because I want you all to experiment on your own and find the best way to go about doing things with your own experience.
--ModuleScript
local default_data = {}
local PlayerData = {}
PlayerData.__index = PlayerData
function PlayerData.new(Player)
local self = setmetatable({},PlayerData)
self.DataKey = Player.UserId
self.Session = {
Data = default_data
{
return self
end
function PlayerData:Load()
-- load the player data, do anything you need to do like actions upon the player based on the data they have saved
end
function PlayerData:EnableAutoSave()
-- resume an auto save thread
end
function PlayerData:DisableAutoSave()
-- pause an auto save thread
end
function PlayerData:Save()
-- a general save method using datastores
end
return PlayerData
--(Normal)Script in ServerScriptService
local Players = game:GetService("Players")
local PlayerData = require(path_to_module)
local AllPlayerData = {}
Players.PlayerAdded:Connect(function(Player)
local newPlayerData = PlayerData.new(Player)
AllPlayerData[Player.UserId] = newPlayerData
end)
Players.PlayerRemoving:Connect(function(Player)
local pData = AllPlayerData[Player.UserId]
if pData then
-- a little different on how i save it but you get the point
pData:Save()
AllPlayerData[Player.UserId] = nil
pData = nil
end
end
Conclusion
If you made it this far, thank you for reading my first ever tutorial on the DevForum. Please feel free to correct any mistakes I have made, I have proofread this post, but have probably missed something due to its size. Also if I explained something in the wrong way please let me know in the replies and I will try to correct it as soon as I can.
Thank you
Extra Resources