Advanced Object-Oriented Programming

Advanced Object-Oriented Programming


A Senior’s Guide to Object-Oriented Programming in Luau.
Originally Written by Bitlet

Table of Contents


Introduction


Before getting into the actual guide, I feel that it is important to declare some stuff first.

The target audience for this guide is not people who are looking to get into OOP. In fact, if you wanna get into OOP, I suggest picking a different language than Luau. Instead, this guide is targeted towards developers who have experience with OOP in other languages.

This guide serves as a means of demonstrating how almost every OOP feature can be emulated in Luau. With that said, just because you can emulate a feature doesn’t mean you always should. For a great example, see the section on Access Specifiers.

Prerequisites


Luau

This guide assumes you are already well-versed in the writing of Luau code. That means thoroughly understanding its syntax, as well as understanding how metatables work.

Object-Oriented Programming

This guide assumes you are already familiar with OOP in another programming language. That means understanding the following concepts:

  • Classes and Instances
    • Instance Variables and Methods
    • Static Variables and Methods
    • Access Specifiers
  • Abstraction
  • Inheritance

Creating an Interface


In order to create a class in Luau, we’re first gonna be creating an interface. As we go through this document we will be building on this interface until it’s a class.

Basic Structure

Interfaces can essentially be boiled down to method containers. These methods can either be static or instance-based. To keep these two types of methods separate, we will be creating two different tables to contain them.

-- This section represents our static interface reference.
local IExampleStatic = {}

-- This section represents our interface instance reference.
local IExampleInstanceMethods = {}
type IExample = typeof(IExampleInstanceMethods)

-- In the case of a ModuleScript, always return the static reference.
return IExampleStatic

Something that’s important to note is that we will be using typeof(methods) instead of typeof(setmetatable({}, {__index = methods})). This is a design decision motivated by the janky behavior of intersected types with metatables.

Static Methods

In the case that we want to add a static method to our interface, the method – as per the definition of an interface – is actually allowed to have a concrete implementation. Adding a static method to our basic structure looks like this.

local IExampleStatic = {}

local IExampleInstanceMethods = {}
type IExample = typeof(IExampleInstanceMethods)

-- Static methods should always be declared using "."
-- between the class name and the method name.
function IExampleStatic.staticMethod()
	print("Static method in IExample was called!")
end

return IExampleStatic

Instance Method Declarations

Interfaces define that – unlike static methods – instance methods are not allowed to have a definition. They are only allowed to be declared. Adding an instance method to our basic structure looks like this.

local IExampleStatic = {}

local IExampleInstanceMethods = {}
type IExample = typeof(IExampleInstanceMethods)

-- Instance methods should always be declared using ":"
-- between the table name and the method name, so that
-- the method recognizes "self" as the instance that
-- contains the method.
function IExampleInstanceMethods:instanceMethod()
	-- Emulate interface instance methods requiring
	-- implementation before being callable.
	error("Call to uninitialized method IExample:instanceMethod()!")
end

return IExampleStatic

Creating an Abstract Class


Basic Structure

Abstract classes are essentially interfaces with extra stuff tacked on. Namely:

  • The ability to contain initialized instance methods.
  • The ability to contain static variables.
  • The ability to contain instance variables.

To make these functionalities possible, we’ll have to update the basic interface structure to allow for them. The result looks like this.

local ExampleStatic = {}  -- Static variable initializations go here.

local ExampleInstanceMethods = {}
type ExampleInstanceVariables = {}	-- Instance variable declarations go here.
type Example = typeof(ExampleInstanceMethods) & ExampleInstanceVariables -- Updated!

return ExampleStatic

Initialized Instance Methods

Adding initialized instance methods to our abstract class is actually very simple. It’s essentially the same thing as adding an instance method declaration to an interface, except this time, it’s allowed to be properly initialized.

local ExampleStatic = {}

local ExampleInstanceMethods = {}
type ExampleInstanceVariables = {}
type Example = typeof(ExampleInstanceMethods) & ExampleInstanceVariables

function ExampleInstanceMethods:instanceMethod()
	-- This time, it's allowed to not just be error().
	print("Instance method in Example was called!")
end

return ExampleStatic

Static Variables

These are very easy to add to our abstract class. It’s done like so.

local ExampleStatic = {staticValue = 0} -- Very simple!

local ExampleInstanceMethods = {}
type ExampleInstanceVariables = {}
type Example = typeof(ExampleInstanceMethods) & ExampleInstanceVariables

return ExampleStatic

Instance Variables

Adding these is very similar to adding static variables. This time however, we can only declare their types (since abstract classes can’t be directly instantiated).

local ExampleStatic = {}

local ExampleInstanceMethods = {}
type ExampleInstanceVariables = {instanceValue: number} -- Declaration only!
type Example = typeof(ExampleInstanceMethods) & ExampleInstanceVariables

return ExampleStatic

Access Specifiers

Making a variable/method protected or private means it shouldn’t show up in our Example type, but we should still be able to reference it from inside a class method.

The general idea is that each access level will be given their own table and type. This makes it convenient to assign a new method/variable either a public or a private access specifier, as well as gain full access to all of the protected and private methods/variables inside of a class method.

Public

By default, the basic structure makes everything public. If you want to make something public, simply adhere to the previously demonstrated structure.

Protected

In order to implement a protected access, we need to create a new table for the methods and a new type for the variable declarations. These will both be used by the final extended type.

The actual inheriting of these variables will be explained in the chapter on Inheriting.

local ExampleStatic = {}

local ExamplePublicInstanceMethods = {} 	-- Public methods here.
local ExampleProtectedInstanceMethods = {} 	-- Protected methods here.

type ExamplePublicInstanceVariables = {}  	-- Public variable declarations here.
type ExampleProtectedInstanceVariables = {} -- Protected variable declarations here.

type Example = typeof(ExamplePublicInstanceMethods) &
			   ExamplePublicInstanceVariables
type ExampleProtected = Example &
						typeof(ExampleProtectedInstanceMethods) &
						ExampleProtectedInstanceVariables

-- Example instance method
function ExamplePublicInstanceMethods:exampleMethod()
	-- selfFull = public + protected
	local selfFull = (self :: any) :: ExampleProtected 
end

return ExampleStatic

One drawback of this approach is that while the protected instance variables may not be visible to the Example type, they’re still technically contained in the instance. In fact, if you iterate over its keys and values, it will include the protected variables. This can’t really be changed, since the instance will always have to store these values. Since they don’t show up on the autocomplete, you typically won’t accidentally use them anyway.

Private

The changes made to add a protected access layer can simply be repeated for the private layer.

local ExampleStatic = {}

local ExamplePublicInstanceMethods = {}
local ExampleProtectedInstanceMethods = {} 
local ExamplePrivateInstanceMethods = {} -- Private instance methods here.

type ExamplePublicInstanceVariables = {}
type ExampleProtectedInstanceVariables = {}
type ExamplePrivateInstanceVariables = {} -- Private variable declarations here.

type Example = typeof(ExamplePublicInstanceMethods) &
			   ExamplePublicInstanceVariables
type ExampleProtected = Example &
						typeof(ExampleProtectedInstanceMethods) &
						ExampleProtectedInstanceVariables
type ExamplePrivate = ExampleProtected &
					  typeof(ExamplePrivateInstanceMethods) &
					  ExamplePrivateInstanceVariables

-- Example instance method
function ExamplePublicInstanceMethods:exampleMethod()
	-- selfFull = public + protected + private
	local selfFull = (self :: any) :: ExamplePrivate 
end

return ExampleStatic

Creating a Class


The Constructor

A Necessary Workaround

There is one small problem that arises while creating the constructor for our class. If we have separate tables for public, protected, and private instance methods, then our extended instance needs to have all of them as its __index metavalue. Since __index only accepts one value, we can’t really do that. So are we screwed?

Thankfully not! __index can also be set to a function that takes in the instance and the key. This means we can write a function that checks for the key in multiple tables in a specific order. Writing this function manually each time is unnecessary; we can write some code that generates these functions for us. It should look something like this.

local function multiIndex(...: {[any]: any} | (any, any)->any): (any, any)->any
	local indexables = {...}
	return function(object: any, index: any): any
		for _, indexable in ipairs(indexables) do
			local v: any
			if type(indexable) == "function" then
				v = indexable(object, index)
			else
				v = indexable[index]
			end
			if v then return v end
		end
		return nil
	end
end

This makes it possible to have multiple tables as the __index metavalue!

local tableA = {a = 1}
local tableB = {b = 2}
local tableC = {c = 3}

local tableAll = setmetatable({}, {__index = multiIndex(tableA, tableB, tableC)})

print(tableAll.a) -- 1
print(tableAll.b) -- 2
print(tableAll.c) -- 3

You may even have noticed that the multiIndex method contains the string "function". That bit of code was implemented to make it possible to supply not only tables, but also __index methods into the function.

local tableA = {a = 1}
function indexMetaMethod(object: any, index: any): any
	if index ~= "b" then return nil end
	return 2
end

local tableAll = setmetatable({}, {__index = multiIndex(tableA, indexMetaMethod)})

print(tableAll.a) -- 1
print(tableAll.b) -- 2

And since the __index method and the output of multiIndex are both of type (any, any)->any, it’s even possible to feed the output of multiIndex back into multiIndex!

local tableA = {a = 1}
local tableB = {b = 2}
local tableC = {c = 3}

local tableAll = setmetatable({}, {
	__index = multiIndex(tableA, multiIndex(tableB, tableC))
})

print(tableAll.a) -- 1
print(tableAll.b) -- 2
print(tableAll.c) -- 3

Not necessarily the most optimal way of using it, but undeniably flexible nonetheless!

The Implementation

Finally we can properly construct our instance. The resulting code looks like this.

local ExampleStatic = {}

local ExamplePublicInstanceMethods = {}
local ExampleProtectedInstanceMethods = {} 
local ExamplePrivateInstanceMethods = {}

type ExamplePublicInstanceVariables = {}
type ExampleProtectedInstanceVariables = {}
type ExamplePrivateInstanceVariables = {}
type ExampleInstanceVariables = ExamplePublicInstanceVariables &
--[[ Added for convenience --]] ExampleProtectedInstanceVariables &
--[[    in constructor.    --]] ExamplePrivateInstanceVariables

type Example = typeof(ExamplePublicInstanceMethods) &
			   ExamplePublicInstanceVariables
type ExampleProtected = Example &
				   typeof(ExampleProtectedInstanceMethods) &
				   ExampleProtectedInstanceVariables
type ExamplePrivate = ExampleProtected &
					  typeof(ExamplePrivateInstanceMethods) &
					  ExamplePrivateInstanceVariables

function ExampleStatic.new(): Example
	local instance: ExampleInstanceVariables = {
		-- Initialize public, protected, and
		-- private instance variables here.
	}

	-- Set the metatable to make it index the instance
	-- method tables. Otherwise it will believe that
	-- it has these methods despite not being able
	-- to index them.
	return (setmetatable(
		instance,
		{__index = multiIndex(
			ExamplePublicInstanceMethods,
			ExampleProtectedInstanceMethods,
			ExamplePrivateInstanceMethods
		)}
	) :: any) :: Example -- And finally cast the whole thing to an
						 -- Example for autocomplete convenience.
end

return ExampleStatic

Inheriting


Custom Classes

Preparing the Super Class

If we want a class to inherit from a custom class, we’ll have to prepare the custom class first. Specifically we need to export the public and protected types for extension, as well as references to the public and protected instance tables for instance indexing.

local SuperStatic = {}

local SuperPublicInstanceMethods = {}
local SuperProtectedInstanceMethods = {} 
local SuperPrivateInstanceMethods = {}

-- Add a list of inheritables to the static export for indexing.
SuperStatic.inheritables = {
	publicMethods = setmetatable({}, {__index = SuperPublicInstanceMethods}),
	protectedMethods = setmetatable({}, {__index = SuperProtectedInstanceMethods})
}

-- Export the public and protected instance variable types for extension.
export type PublicInstanceVariables = {}
export type ProtectedInstanceVariables = {}
type SuperPrivateInstanceVariables = {}
type SuperInstanceVariables = PublicInstanceVariables &
							  ProtectedInstanceVariables &
							  SuperPrivateInstanceVariables

type Super = typeof(SuperPublicInstanceMethods) &
			 PublicInstanceVariables
type SuperProtected = Super &
					  typeof(SuperProtectedInstanceMethods) &
					  ProtectedInstanceVariables
type SuperPrivate = SuperProtected &
					typeof(SuperPrivateInstanceMethods) &
					SuperPrivateInstanceVariables

function SuperStatic.new(): Super
	local instance: SuperInstanceVariables = {}

	return (setmetatable(
		instance,
		{__index = multiIndex(
			SuperPublicInstanceMethods,
			SuperProtectedInstanceMethods,
			SuperPrivateInstanceMethods
		)}
	) :: any) :: Super
end

return SuperStatic

Creating the Inheriting Class

Now that our super class has been prepared for extension, we can hook up our extending class to utilize its types and method tables.

-- Require the superclass
local Super = require(script.Parent.Super)

local ClassStatic = {}
setmetatable(ClassStatic, {__index = Super}) -- Static reference index metavalue.

-- Instance methods are inherited through metatables.
local ClassPublicInstanceMethods = setmetatable(
	{}, {__index = Super.inheritables.publicMethods}
)
local ClassProtectedInstanceMethods = setmetatable(
	{}, {__index = Super.inheritables.protectedMethods}
)
local ClassPrivateInstanceMethods = {}

-- Make sure SuperClass' inheritables can't be accessed through our class.
ClassStatic.inheritables = {}

-- Instance variable types must inherit from Super.
type ClassPublicInstanceVariables = Super.PublicInstanceVariables & {}
type ClassProtectedInstanceVariables = Super.ProtectedInstanceVariables & {}
type ClassPrivateInstanceVariables = {}
type ClassInstanceVariables = ClassPublicInstanceVariables &
							  ClassProtectedInstanceVariables &
							  ClassPrivateInstanceVariables

type Class = typeof(ClassPublicInstanceMethods) &
					ClassPublicInstanceVariables
type ClassProtected = Class &
						typeof(ClassProtectedInstanceMethods) &
						ClassProtectedInstanceVariables
type ClassPrivate = ClassProtected &
					typeof(ClassPrivateInstanceMethods) &
					ClassPrivateInstanceVariables

function ClassStatic.new(): Class
	local instance: ClassInstanceVariables = {}

	return (setmetatable(
		instance,
		{__index = multiIndex(
			ClassPublicInstanceMethods,
			ClassProtectedInstanceMethods,
			ClassPrivateInstanceMethods
		)}
	) :: any) :: Class
end

Roblox Classes

Concrete

If we want to inherit from a concrete roblox class (like a Part), we can’t just use the same method as we did with our custom classes. Instead, we’ll have to wrap our class around it.

In order to correctly route indexes and assignments onto our wrapped instance, we need to add two custom functions to the metatable of our custom class instance.

-- Responsible for indexing our instance.
local function indexInstance(object: any, index: any): any
	local success, result = pcall(function(instance, index)
		return instance[index]
	end, object.instance, index)

	if not success then return nil end

	if typeof(result) ~= "function" then return result end
	return function (object, ...) return result(object.instance, ...) end
end

-- Responsible for forwarding assignment attempts to our instance.
local function newIndexInstance(object: any, index: any, value: any)
	local success, _result = pcall(function(instance, index, value)
		instance[index] = value
	end, object.instance, index, value)

	if success then return end

	object[index] = value
end

With these functions, implementing the inheritance is actually rather simple.

local ExampleStatic = {}

local ExampleInstanceMethods = {}

type ExamplePublicInstanceVariables = {}
type ExamplePrivateInstanceVariables = {instance: Part} -- Keep instance stored.
type ExampleInstanceVariables = ExamplePublicInstanceVariables &
								ExamplePrivateInstanceVariables

type Example = Part & -- Let type inherit from instance type.
			   typeof(ExampleInstanceMethods) &
			   ExamplePublicInstanceVariables
type ExamplePrivate = Example &
					  ExamplePrivateInstanceVariables

function ExampleStatic.new(): Example
	local instance: ExampleInstanceVariables = {
		instance = Instance.new("Part") -- Initialize instance
	}

	return (setmetatable(
		instance,
		{
			__index = multiIndex(
				ExampleInstanceMethods,
				indexInstance, -- Index instance if key is not found in our methods.
			),
			__newindex = newIndexInstance -- Direct assignments to the instance.
		}
	) :: any) :: Example
end

return ExampleStatic

Abstract

Unfortunately, inheriting from abstract roblox classes (such as BasePart) is impossible. In order to inherit from a roblox class, our custom class instance needs to wrap around an instance of the inheriting class. Since abstract classes can’t be instantiated, this is impossible to do.

11 Likes

Very good tutorial about advanced OOP :saluting_face:. Seeking to move in to Advanced OOP in the future, and this is a really good tutorial since most of the OOP documentation is kind of confusing. :slight_smile: Thanks, man!

1 Like

I must say, this advanced OOP tutorial is well shown and taught. Amazing job man! Appreciate the help and I will soon move into this area of scripting soon. Thanks bro!

1 Like

Very clean document. Hopefully this will help people when moving to another language like Java. Just pray people actually read this wonderful post.

1 Like