A guide on creating classes in Lua

A guide on how to create classes in Lua

An in-depth tutorial by @maddjames28.

Before we start, I would like to state that Lua is not meant to be an OOP language. Instead we implement custom classes with metatables.


Introduction


Hey! You probably came here because you saw some developers talking about ‘OOP’ or ‘Classes’ or even ‘Objects’. O.O.P. stands for Object Oriented Programming. A lot of programming languages use OOP as it is extremely useful in a lot of cases.

What are they, and what do they do?


Classes can be useful when you need data that does not persist throughout the running environment. Usually, you will have your script that makes local variables like this:

local var = 1
local var2 = "Hello, world!"
local var3 = true

But sometimes, this behavior of persisting variables is not always desired. This is where functions come in. Functions can take parameters (arguments) which are basically like variables but they get passed into the function for use like this:

local var = 1
local table1 = { }

local function myPrint(parameter)
    print(parameter)
    table.insert(table1, parameter)
end

local function findPrint(parameter)
    return table.find(parameter)
end

myPrint(1)
findPrint(1)

Now this works, but it can start to be annoying to supply the same parameter every time for the same thing, especially if your module is open sourced. This is where classes/objects come in. Now, you are probably familiar with Roblox’s objects, for example, Parts or Scripts. Each one of these objects have properties and extra functions connected to them. In Luau, you can create your own custom objects with code (quite easily actually). Now, lets get to the main point of this tutorial: learn how to make classes.

How do I make them?


Making classes is quite simple actually, and the possibilities are almost endless. I will be covering more advanced topics at a different time, but here are the basics.

First to create a class, you need to insert a ModuleScript via a plugin or the object explorer.

In your module, you first need to create a new function.

local module = {}

function module.new()
	
end

return module

Now, this is where metatables come in. They are what make classes actually work.

local module = {}

function module.new()
	local myClass = setmetatable({}, module)
	
	return myClass
end

return module

Now, we need to add our __index:

local module = {}
module.__index = module

function module.new()
	local myClass = setmetatable({}, module)
	
	return myClass
end

return module

Basically, metatables have these things called metamethods, which are basically functions that run when said thing has happened to the table. In classes, we use the metamethod __index, which fires whenever you try to index anything from the table. Here, we have it returning the module when we attempt to index it.

Now you can add properties to your object, just like how Roblox has a Name property on every object. Here is how can add them:

local module = {}
module.__index = module

function module.new()
	local myClass = setmetatable({}, module)
	
	myClass.Property = "Hello, world!"
	
	return myClass
end

return module

Now, if we call this function in our module, we can do this:

local module = require(path.to.module)
local myObject = module.new()

print(myObject.Property) -- output: "Hello, world!"

Pretty cool right? Well, the next part is even cooler!

So now we how to add properties to our classes, but how do we attach functions like Roblox does with their objects? Here’s how: So in Lua, there’s this variable called self. It is basically they keyword that we use to access properties in our classes from functions that are attached to them. All we need to do is add another function!

local module = {}
module.__index = module

function module.new()
	local myClass = setmetatable({}, module)
	
	myClass.Property = "Hello, world!"
	
	return myClass
end

function module.ObjectMethod()
	print(self.Property)
end

return module

Now, if we call that function we should be able to print the property - but it doesn’t, and instead it errors. Why? This is because you must supply self as a parameter or else the function won’t know what object to look for. You can add it by doing this:

function module.ObjectMethod(self)
	print(self.Property)
end

Now try doing this in your script:

local module = require(path.to.module)
local myObject = module.new()

myObject.ObjectMethod(myObject) -- output: Hello, world!

But, this can start to be annoying when you have a lot of methods. How can we fix this? Well, you simply call the function with a colon in your module like this:

local module = {}
module.__index = module

function module.new()
	local myClass = setmetatable({}, module)
	
	myClass.Property = "Hello, world!"
	
	return myClass
end

function module:ObjectMethod()
	print(self.Property)
end

return module

Now, you no longer need to supply self/the object because when using a colon, self is automatically supplied as an invisible parameter, and you no longer need to worry about supplying it yourself.

So, if we do this in our script:

local module = require(path.to.module)
local myObject = module.new()

myObject:ObjectMethod() -- output: Hello, world!

We should still get the same result as if we were doing the other method. Of course, you can add as many properties and methods as you want.

But sometimes, maybe you want to have different properties for each object. We can still use parameters as before with regular functions. Here is an example where we use parameters, and other methods to create a ‘People’ class with each object being a ‘Person’.

local module = {}
local people = {}

module.__index = module

function module.new(name, age, gender)
	local People = setmetatable({}, module)
	
	table.insert(people, name)
	
	People.Name = name
	People.Age = age
	People.Gender = gender
	
	return People
end

function module.GetPeople()
	return people
end

function module:GetPerson()
	local compiledPerson = {Name = self.Name, Age = self.Age, Gender = self.Gender}
	
	return compiledPerson
end

function module:RemovePerson()
	table.remove(people, table.find(people, self.Name))
	setmetatable(self, nil)
end

return module

Here is one last piece of advice: always make sure to type check parameters to ensure safety. Type checking is basically doing this: local var: number = 1.
What happens is if var is not a number, the script will yell at you telling you it should be a number.

Here’s an example of it in a function:

local function myFunction(parameter: string)
    return type(parameter) -- should always return "string" no matter what
end

What’s next?

You can do way more things with classes than I have mentioned here. Go checkout other tutorials about OOP and maybe you’ll learn.

Anyways, I hope you have learned something, and thanks for taking the time out of your day for reading my post!

Comment on what I can improve or what you liked about the tutorial.

18 Likes

There is a lot of repetition in the way of OOP with metatable, but is there really any advantage instead of using table.clone and type check?

I have never heard or have used this method before. Could you show me?

More simplified

local module = {}
function module.new()
	local myClass = table.clone(module)
	myClass.Property = "Hello, world!"
	return myClass
end
function module:ObjectMethod()
	print(self.Property)
end

return module

Now ignoring new and calling the module as a function

local module = {}
function module:ObjectMethod()
	print(self.Property)
end

return function()
	local myClass = table.clone(module)
	myClass.Property = "Hello, world!"
	return myClass
end

Personally I consider it cleaner, and it can be improved with type

local Methods = {}
function Methods:ObjectMethod()
	print(self.Property)
end


export type Type = {
	--		Properties		--
	Property: string,
	
	--		Methods		--
	ObjectMethod: (self: Type) -> ()
}

return function(): Type
	local myClass = table.clone(Methods)
	myClass.Property = "Hello, world!"
	return myClass
end

Something like this I am guessing?:

type class = {myFunction: () -> ()}

local module = {}
local methods = {}

function module.new(): class
	return table.clone(methods)
end

function methods.myFunction(self)
	print("Hello, world!")
end

local new = module.new()

new.myFunction(new)

return module

One thing is you can’t implant operator overloading with metatables.

1 Like

Yes - It is one of the main reason’s I use metatables for my classes in the first place, just due to the fact that it’s extremely useful in some scenarios.

I personally wouldn’t say there’s a ton of repetition with metatables, but the advantage of using __index instead of table.clone is memory allocation.

With table.clone you’re copying the table at full which requires memory for each separate table. Where with with __index you’re just going to index whatever you’ve assigned to index if you don’t find the key in your metatable meaning that you’re only have the methods stored once.

Here’s some example code regarding this behavior

local class = {}
function class.new()
	local self = setmetatable({}, {__index = class});
	self.Property = "hi";
	return self;
end
function class:method()
	print(self.Property);
end

local classOne = class.new();

------------------------------------------------

local classWithClone = {}
function classWithClone.new()
	local self = table.clone(class)
	self.Property = "hi";
	return self;
end
function classWithClone:method()
	print(self.Property);
end

local classTwo = classWithClone.new();

print(classOne) -- {Property = "hi"}
print(classTwo) -- {Property = "hi", method = function, new = function}

Pretty much, if you create a lot of classes, the table.clone() way would use more Ram than the metamethod way. Plus you don’t get access to the other metamethods. (__tostring, __len, etc)

Also you should be able to use types with metamethod classes, pretty sure luau has some stuff on documentation

4 Likes

This is what you’re looking for:
A new object-oriented programming idiom, and why you should ditch the old one! - Resources / Community Tutorials - DevForum | Roblox


Is there possibly a way to use the OOP module in an OOP manner?

You shouldve mentioned that lua is not an OOP language and this is just some sort of “implementation” for classes. Normally, in OOP languages you would be able to create classes without those implementations.

2 Likes

Sure! I’ll quote you in the main post.

2 Likes

The methods would get cloned with the table. There will be a memory difference for the methods. Just use __index, Lua isn’t an OOP language to begin with, that’s why it can’t be as clean as you might want.

It’s crazy that this only has 6 likes. This is one of the only and one of the better explanations I’ve seen for this! I’m kind of new to metatables, so I was just wondering what the purpose of setting the metatable of the class to the module was.

3 Likes

How can I override methods with this? Overriding properties and attributes works as normal, however when I try to override a method, it simply ignores it.

function module.new(plr)
	local self = _G.WeaponClass.new(plr)
	
	self.Name = "new_gun"
	
	self.Magazines = 3
	self.MagazineSize = 30
	
	self.MagazinesLeft = self.Magazines
	self.BulletsLeft = self.MagazineSize
	
	return self
end

function module:Attack()
	if self.Equipped == true then
		local raycastParams = RaycastParams.new()
		raycastParams.FilterDescendantsInstances = {self.Player.Character}

		_G.ProjectileModule:FireProjectile(
			{
				["Origin"] = self.Player.Character.Head.Position,
				["Params"] = raycastParams,
				["Direction"] = self.Player.Character.Head.CFrame.LookVector,
				["Speed"] = 15, -- studs per second
				["Damage"] = 10
			}
		)
	end
end

Here, the Attack() method isn’t overriding.

You shouldn’t be overriding methods.

Is there any alternative way to implement polymorphism for lua objects?

I love that you consistently used examples, it really helped me understand it better. Thank you for this post :slight_smile: :+1:

1 Like

I learned about OOP a while ago, it’s pretty neat stuff.

It does however have the downside that it can sometimes create a lot of complexity if you have classes, based on classes, based on more classes.

It is alsooooooo… Not very parallel Luau-compatible since actors cannot access the same module script and whatnot.
So having in-script methods to avoid using BindableEvents doesn’t work so well.

I definitely like OOP though, it reduces script duplication.
A LOT of developers still write long scripts and copy-paste them into every model or part, something OOP should prevent for the most part.