Advanced Object-Oriented Programming
Originally Written by Bitlet
Table of Contents
- Introduction
- Prerequisites
- Creating an Interface
- Creating an Abstract Class
- Creating a Class
- Inheriting
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.