Nexus Instance - Object Oriented Library for Roblox

#1

Nexus Instance is an object oriented (OO) library designed for Roblox. The goal to provide some of the functionality of Roblox’s Instance object, as well as providing the context of “super” classes when extending a class.

Why Object Oriented?
This thread is meant to be a tutorial about using Nexus Instance; not Object Oriented programming in general. There are many different articles, books, and videos explaining why Object Oriented is “better” compared to functional programming. As a TL;DR of the benefits, I will pull the 4 main features of object oriented design from the answer key from the second assignment from one of my several object oriented university courses:

  • Identity - Classes are templates. Objects are instances of those templates. Every object is uniquely identifiable.
  • Encapsulation - State and behavior are grouped together inside an object. The object protects and controls access to them using access modifiers.
  • Inheritance - One class may establish a parent-child relationship with another. The child class inherits the accessible state and behavior of its parent.
  • Polymorphism - Any instance of a child class may be used where an instance of its parent class is expected. A reference may refer to an instance of any compatible type. What appears to be one method at compile time may be one of many different implementations at runtime (overriding).

Of course, object oriented is not a “silver bullet” that will make your code instantly better. You need to properly design for it, as well as practice. I wouldn’t make your first project with object oriented a large-scope project spanning months.

Why Use Any Library?
The main reason object oriented libraries get made for Lua is because the built-in method (metatables) can be very confusing for new users. Here is an example class with a constructor using Lua’s metamethods:

local TestClass = {}

--[[
Constructor for TestClass.
--]]
function TestClass.new(Value)
    --Create the base object.
    local Object = {Value = Value}
    setmetatable(Object,TestClass)
    TestClass.__index = TestClass

    --Return the object.
    return Object
end

--[[
Returns the value of the class.
--]]
function TestClass:GetValue()
    return self.Value
end

--Create an instance of the class.
local TestObject = TestClass.new(2)
print(TestObject:GetValue()) --2

The metatables used above are something that can be generalized so it doesn’t have to be repeated. Inheritance is also a bit complicated because you need to make several metatable calls, and you still won’t have a good way to reference a super function. It can be done, but makes refactoring difficult if you change the inheritance.

Why Nexus Instance?
One thing I didn’t know going into the project is that there is a lot of different object oriented libraries that exist for Lua, and the syntax is different between them. Syntax mostly comes down to which you prefer. The functionality that is implemented into Nexus Instance includes:

  • Easy extending of classes.
  • Proper hierarchy with a “super” property.
  • Class names and a built in “IsA” function.
  • Property change signalling. (Note: The current implementation uses Roblox’s BindableEvent object, so it is Roblox specific).

Using Nexus Instance
Most of this tutorial will mirror the “Why Nexus Instance” on the GitHub pages documentation. In this case, I will be using shapes as an example.

Creating a Base Class
Most objects in Roblox inherit from a common class (Instance). Nexus Instance is a same, except NexusObject is the common class. To create an empty class, NexusObject::Extend needs to be called on the base class. It is also recommended to set the class name using NexusObject::SetClassName. As an example, here is how you would create a “Rectangle” class:

local Sources = game:GetService("ReplicatedStorage"):WaitForChild("Sources")
local NexusObjectFolder = Sources:WaitForChild("NexusObject")
local NexusObject = require(NexusObjectFolder:WaitForChild("NexusObject"))

--Extend NexusObject for a Rectangle class.
local Rectangle = NexusObject:Extend()
Rectangle:SetClassName("Rectangle")

--Create the object.
local RectangleObject = Rectangle.new()

--Return the class (assuming it is a ModuleScript).
return Rectangle

Overriding the Constructor
Some classes will require some type of arguments given as part of the constructor to create the object. An example of this is Instance.new requiring the class name to create. For this, you need to override the __new in the class. To simplify setting parameters, the function gets called on the instantiated object so a self exists. Here is the same example with “Width” and “Height” parameters:

local Sources = game:GetService("ReplicatedStorage"):WaitForChild("Sources")
local NexusObjectFolder = Sources:WaitForChild("NexusObject")
local NexusObject = require(NexusObjectFolder:WaitForChild("NexusObject"))

--Extend NexusObject for a Rectangle class.
local Rectangle = NexusObject:Extend()
Rectangle:SetClassName("Rectangle")

--The constructor of the rectangle.
--The following is functionally identical to Rectangle.__new = function(self,...)
function Rectangle:__new(Width,Height)
	self.Width = Width
	self.Height = Height
end

--Create the object.
local RectangleObject = Rectangle.new(2,4)
print(RectangleObject.Width) --2
print(RectangleObject.Height) --4

Initializing the Super Class
In most cases, you will want to initialize the super class to initialize the behavior. If the constructor isn’t overridden, this is done implicitly using the InitializeSuper function. If you do override the constructor, it is recommended (or potentially required) to explicitly call this. This function will call the constructor of the superclass with self.super as self, as well as any parameters. For the Rectangle example, a Square is a reasonable class to extend from it, with only a side length being the constructor.

local Sources = game:GetService("ReplicatedStorage"):WaitForChild("Sources")
local NexusObjectFolder = Sources:WaitForChild("NexusObject")
local NexusObject = require(NexusObjectFolder:WaitForChild("NexusObject"))

--Extend NexusObject for a Rectangle class.
local Rectangle = NexusObject:Extend()
Rectangle:SetClassName("Rectangle")

--The constructor of the rectangle.
--The following is functionally identical to Rectangle.__new = function(self,...)
function Rectangle:__new(Width,Height)
	self.Width = Width
	self.Height = Height
end

--Extend Rectangle for a Square class.
local Square = Rectangle:Extend()
Square:SetClassName("Square")

--The constructor of the rectangle.
function Square:__new(Length)
	self:InitializeSuper(Length,Length)
end

--Create the Rectangle object.
local RectangleObject = Rectangle.new(2,4)
print(RectangleObject.Width) --2
print(RectangleObject.Height) --4

--Create the Square object.
local SquareObject = Square.new(2,2)
print(SquareObject.Width) --2
print(SquareObject.Height) --2
print(SquareObject.ClassName) --"Square"
print(SquareObject:IsA("Rectangle")) --true

Adding Functions
A key feature of object oriented design is being able to abstract functionality behind certain methods. It is important to be able to either inherit the behavior or to override the behavior while keeping the same name (Polymorphism). In the Rectangle example, Rectangle::GetArea can be added, and the Square will inherit the method.

local Sources = game:GetService("ReplicatedStorage"):WaitForChild("Sources")
local NexusObjectFolder = Sources:WaitForChild("NexusObject")
local NexusObject = require(NexusObjectFolder:WaitForChild("NexusObject"))

--Extend NexusObject for a Rectangle class.
local Rectangle = NexusObject:Extend()
Rectangle:SetClassName("Rectangle")

--The constructor of the rectangle.
--The following is functionally identical to Rectangle.__new = function(self,...)
function Rectangle:__new(Width,Height)
	self.Width = Width
	self.Height = Height
end

--Returns the area of the rectangle.
function Rectangle:GetArea()
	return self.Width * self.Height
end

--Extend Rectangle for a Square class.
local Square = Rectangle:Extend()
Square:SetClassName("Square")

--The constructor of the rectangle.
function Square:__new(Length)
	self:InitializeSuper(Length,Length)
end

--Create the Rectangle object.
local RectangleObject = Rectangle.new(2,4)
print(RectangleObject:GetArea()) --8

--Create the Square object.
local SquareObject = Square.new(2,2)
print(SquareObject:GetArea()) --4

Changed Event
A built in extension of NexusObject is NexusInstance. The primary purpose of NexusInstance is to add a Changed event signal for when an property gets changed. This does add some overhead to run the event, but it should be insignificant. Going back to the Rectangle and removing the Square, here is an example with the GetPropertyChangedSignal function and Changed event.

local Sources = game:GetService("ReplicatedStorage"):WaitForChild("Sources")
local NexusObjectFolder = Sources:WaitForChild("NexusObject")
local NexusInstance = require(NexusObjectFolder:WaitForChild("NexusInstance"))

--Extend NexusInstance for a Rectangle class.
local Rectangle = NexusInstance:Extend()
Rectangle:SetClassName("Rectangle")

--The constructor of the rectangle.
--The following is functionally identical to Rectangle.__new = function(self,...)
function Rectangle:__new(Width,Height)
	self:InitializeSuper() --This is REQUIRED or signaling won't be set up.
	self.Width = Width
	self.Height = Height
end

--Create the Rectangle object.
local RectangleObject = Rectangle.new(2,4)
RectangleObject.Changed:Connect(function(Property)
	print(Property..": "..tostring(RectangleObject[Property])) --"Width: 4"
end)
RectangleObject:GetPropertyChangedSignal("Width"):Connect(function()
	print("Width: "..tostring(RectangleObject.Width)) --"Width: 4"
end)

RectangleObject.Width = 4

Locking Properties
If you need a property to be immutable (read-only), the function NexusInstance::LockProperty can be used to make a property immutable. An example where this may be useful is any of Roblox’s datatypes, where they need to be re-instantiated rather than changing a component of the userdata. For the Rectangles example, the Width and Height may need to be immutable for performance reasons or how they are used by other parts of the software.

local Sources = game:GetService("ReplicatedStorage"):WaitForChild("Sources")
local NexusObjectFolder = Sources:WaitForChild("NexusObject")
local NexusInstance = require(NexusObjectFolder:WaitForChild("NexusInstance"))

--Extend NexusInstance for a Rectangle class.
local Rectangle = NexusInstance:Extend()
Rectangle:SetClassName("Rectangle")

--The constructor of the rectangle.
--The following is functionally identical to Rectangle.__new = function(self,...)
function Rectangle:__new(Width,Height)
	self:InitializeSuper() --This is REQUIRED or signaling won't be set up.
	self.Width = Width
	self.Height = Height
	
	--Lock the properties.
	self:LockProperty("Width")
	self:LockProperty("Height")
end

--Create the Rectangle object.
local RectangleObject = Rectangle.new(2,4)
RectangleObject.Width = 4 --"Width is read-only."

Changes
Version V1.1.0 (Interfaces, Property Validation, Metamethod Passthrough)

31 Likes

Can you create actual classes and how?
How to Implement OOP in Rbx.Lua
#2

Excellent work! Creating of objects in Lua is pretty bulky in my opinion, this library is very helpful.

2 Likes

#3

Interesting library, I always hated creating classes in Lua simply because it was annoying writing the metatables out.

I’m also curious why you have a :SetClassName method. Wouldn’t it be a bit cleaner just to have NexusObject:Extend(string className)?

2 Likes

#4

It is something I was considering but decided not to do in case I was going to add extra behavior when setting up the class.

0 Likes

#5

If you wanted some future proofing in case you wanted other behavior you could make it take a table which should help mitigate that issue.

0 Likes

#6

Something you could consider is creating a wrapper, or extending NexusObject/NexusInstance and overriding the Extend method.

local Sources = game:GetService("ReplicatedStorage"):WaitForChild("Sources")
local NexusObjectFolder = Sources:WaitForChild("NexusObject")
local NexusInstance = require(NexusObjectFolder:WaitForChild("NexusInstance"))

--Extend NexusInstance.
local NexusInstanceHelper = NexusInstance:Extend()

--Override the extend function.
function NexusInstanceHelper:Extend(ClassName)
	local ExtendedClass = self.super:Extend()
	ExtendedClass:SetClassName(ClassName)
	return ExtendedClass
end
	
--Test an extended class.
local TestClass = NexusInstanceHelper:Extend("TestClass")
local TestObject = TestClass.new()
print(TestObject.ClassName) --"TestClass"
1 Like

#7

I suggest you look at roblox-ts


TypeScript is a truly beautiful OOP language which, more importantly, has interfaces. The algebraic typing system is very powerful and it has type definitions written for Roblox. Plus there’s hundreds of platform agnostic libraries written for it.

If you combine it with Rojo then your TypeScript can be automatically compiled and pulled into Roblox Studio any time you make a change. It’s a really solid workflow.

The Lua it compiles to is very readable as well, which is a plus.

0 Likes

#8

TypeScript is something far beyond the scope of what Nexus Instance was meant to achieve. The goal was to keep everything in-platform and require majoring refactoring to adapt to, mainly for my projects.
Roblox-ts also has a higher barrier to entry since you need to set up both that and Rojo, then practice the syntax. As someone who doesn’t require the features it gives, but does want event signaling, Nexus Instance works for me.

0 Likes

#9

Interesting project for sure! I recently got into playing around with OO Lua constructs. Sadly since I am still not terribly familiar with the best ways to implement many parts of it my system lacks perhaps the most important part of true OO philosophy, inheritance. I specifically sacrificed it when I was looking at ways to accomplish more robust encapsulation than I had.

A few things I’d love to see added to your implementation, input capturing and validation. Currently you allow for locking properties so that they are read only which is great. Sometimes though you don’t want to prevent changing of a property you just want to only allow specific inputs and cast when possible.

Sticking with your examples, if we have a rectangle we want to be able to set it’s Width and Height only to numbers, and we always want them positive. When “Rectangle.Width = -2” is called, we should be capturing it for processing first, and validate it against being able to cast “-2” as a number (which it already is so no problem there) and then set Width to math.abs(input). This ensures our object doesn’t break by someone saying Rectangle.Width = “Foo”, and that we never return a negative area.

Anyways, love what you’ve got going so far and look forward to following you expanding it!

1 Like

#10

This is probably something I can implement. It would be a registering function (ex: NexusInstance::AddPropertyValidator), but I am not sure how I would implement it from there. Ideally, it would be a InputValidator class with a few built-in ones for the basic cases (ex: must be a number, must be a UDim2.new(), can’t be nil). It is something I may address later since it isn’t a priority.

1 Like

#11

This looks really helpful! Thanks for making and sharing this!

How extensively has this library been tested in production? I’m interested in giving it a go, but I am novice enough with it all to not know if I should try it on my project.

0 Likes

#12

I have tested it in a few projects, although none of them are public yet. Nexus Admin 1.3.0 will be the first Nexus Instance project to be released, which hopefully be by the end of next week.
If you do want to try it, and you have never done object oriented, I would do some sort of small scale project that relies on only a few classes to see if it will work for you, or even just one (ex: a LoadableObject in a loading screen).

1 Like

#13

Thanks! Looking forward to seeing this grow :slight_smile:

0 Likes

#14

After a bit of designing, a bit of program, a lot of unit testing, and a LOT of documentation, V.1.1.0 has been released! Here are the highlights (from the GitHub pages):

Interfaces
Interfaces exist to enforce the implementation of behavior. Interfaces can also implement behavior, but this is not recommended because interfaces are meant to enforce the implementation of a behavior without implementing it.

Example:

local NexusObject = game:GetService("ReplicatedStorage"):WaitForChild("NexusObject")
local NexusInstance = require(NexusObject:WaitForChild("NexusInstance"))
local NexusInterface = require(NexusObject:WaitForChild("NexusInterface"))
local TestClass = NexusInstance:Extend()
TestClass:SetClassName("TestClass")

--Create an interface.
local TestInterface = NexusInterface:Extend()
TestInterface:SetClassName("TestInterface")
TestInterface:MustImplement("Test")
TestClass:Implements(TestInterface)

--[[
Implement the test function. Not doing
this will result in an error for not
implementing the function test.
--]]
function TestClass:Test()
    --Implementation
end


--Create an instance of the class.
local TestObject = TestClass.new()
print(TestObject:IsA("TestClass")) --true
print(TestObject:IsA("TestInterface")) --true

Property Validation
Property changes can be validated before being set. This allows for modifying of inputs to correct values, or throwing errors if the new property is invalid.

Example:

local NexusObject = game:GetService("ReplicatedStorage"):WaitForChild("NexusObject")
local NexusInstance = require(NexusObject:WaitForChild("NexusInstance"))
local TypePropertyValidator = require(NexusObject:WaitForChild("PropertyValidator"):WaitForChild("NexusInstance"))
local TestClass = NexusInstance:Extend()

--Create and add a property validator.
local TestObject = TestClass.new()
local Valdiator = TypePropertyValidator.new("CFrame")
TestObject:AddPropertyValidator("Location",Validator)

--Set the property.
TestObject.Location = CFrame.new()
TestObject.Location = "Test" --Throws an error

Metamethods
Most of the metamethods can be implemented into classes directly. This is mostly for __tostring , but can be used for any other Lua metamethod that isn’t __index or __newindex .
Example:

local NexusObject = game:GetServic exusObject:WaitForChild("NexusInstance"))
local TestClass = NexusInstance:Extend()

--[[
Constructor for TestClass.
--]]
function TestClass:__new(Value)
    self:InitializeSuper()

    --Set the value.
    self.Value = Value
    self:LockProperty("Value")
end

--[[
Returns the object as a string.
--]]
function TestClass:__tostring()
    return "Test: "..self.Value
end

--Create an instance of the class.
local TestObject = TestClass.new(2)
print(tostring(TestObject)) --"Test: 2"
1 Like