Prototype-based OOP; A cleaner and simpler way to do OOP

Commonly used metatable-based OOP

There is a common method followed by developers when using OOP in Luau, and it usually looks something like this, as an example:

local Person = {}
Person.__index = Person

function Person.new(name: string, age: number)
	return setmetatable({
		_name = name,
		_age = age
	}, Person)
end

function Person:PrintName()
	print(self._name)
end

function Person:IncrementAge()
	self._age += 1
end

return Person

This is a common example of an OOP class in Luau. However, there are several flaws with it:

  1. When requiring the module, you’ll notice that the class methods (:IncrementAge(), :PrintName() ) are exposed despite not having constructed a Person yet.

  2. When you have constructed a Person with Person.new(), you’ll notice that you can call .new() again. ( Person.new().new().new()…), this is really quite odd and not really desirable.

  3. Metatables have overhead, which, combined with the existing table overhead, can make these classes unecessarily slower than they need to be.

  4. Metatables are vague and if your class is big it can get messy to find bugs or something else real fast.

Luckily, there is quite a simple solution that solves all of these flaws with metatable-based OOP,

Prototype-based OOP

What is prototype-based OOP? At its core, it is essentially just making the things you need for the Object beforehand, and then combining them when you construct it.

As an example using the previous Person class, but this time using prototype-based OOP:

local function person_printName(self)
	print(self._name)
end

local function person_incrementAge(self)
	self._age += 1
end

local function person_new(name: string, age: number)
	return {
		_name = name,
		_age = age,
		
		PrintName = person_printName,
		IncrementAge = person_incrementAge
	}
end

return {
	new = person_new
}

Pre-construction

As you can see, we are constructing the PrintName and IncrementAge functions beforehand, nullifying any overhead we would’ve gotten with closure-based OOP.

We can do this because both take in a self argument, which allows it to reference the Person itself without using upvalues/global variables.

Increased clarity

However, you may also notice that it’s much clearer what is being done when constructing the Person.
We are constructing a table, putting our name, age, and functions inside it, then returning it.

This differs much from the previous commonly used method, where you have to think about what the metatable is doing.

Full control over public and private functions

Another thing you may have noticed is that now we have full, explicit control over what functions we want to expose.
We can now choose what functions we want to keep private to the methods of Person, and which ones we want the user to be able to use.

Less overheads

This method of doing OOP also has a benefit of less overheads.
Since there are no hidden metatables or unused carry-over functions/members, classes made using this method will only be as big as you want them to be.

Flaws

With how good prototype-based OOP is, it is not without its flaws. Unlike metatable-based OOP which are proxies of another table, and thus only need to hold a pointer to that table, prototype-based Objects need to store references for each individual method, which makes it take up slightly more memory than metatable-based OOP.

There is also the flaw that you can’t really do inheritance with prototype-based OOP (however I personally consider this less of a flaw and more of a benefit.)

Conclusion

In conclusion, prototype-based OOP is definitely an alternative to the usual metatable-based OOP that you should try using for any future classes you plan to make.

Hopefully you enjoyed this post, and may your scripts always be blessed with no bugs :smiley:

SimpleSignal

Also, I’ve designed a version of Stravants GoodSignal called SimpleSignal, which utilizes this prototype-based approach and replaces all of the linked lists with dictionaries.

Download it here! →
SimpleSignal.rbxm (3.3 KB)

6 Likes

I don’t know if I missed a part but you didn’t mentionned a con with this way of doing OOP, it takes slightly more memory for each object created because it has to stores a references to the methods.

3 Likes

Oh yeah my bad, it definitely does store slightly more memory because it holds references to the methods instead of holding a pointer to another table, however I do think the benefits of prototype-based OOP still outweigh the common metatable-based OOP, it’s much easier to understand what you’re creating and using this way.

i agree, it’s much more friendly toward typed Luau

Luau metatables are already prototype based OOP

You can actually solve most of these by manually typing your class (plus you can hide internal properties from the user with an “internal” type that inherits from your object’s):

--!strict

----- Class -----

local Person = {}
Person.__index = Person

--- Types ---

type Class = {
	new: (name: string, age: number) -> Person
}

export type Person = {
	
	--Methods
	PrintName: (self: Person) -> (),
	IncrementAge: (self: Person) -> ()
}
type InternalPerson = Person & {
	
	--Properties
	name: string,
	age: number
}

--- Methods ---

-- Constructor --

function Person.new(name: string, age: number): Person
	local self = {} :: InternalPerson
	
	self.name = name
	self.age = age
	
	return setmetatable(self, Person) :: any
end

-- Print Name --

function Person.PrintName(self: InternalPerson): ()
	print(self.name)
end

-- Increment Age --

function Person.IncrementAge(self: InternalPerson): ()
	self.age += 1
end

return Person :: Class

I do agree ditching the metatables makes it cleaner and saves you from writting things twice, but imo the extra memory usage isn’t really worth it when you can do this.

Edit: An actual use case for this OOP though is if you wanna pass data through remotes, as your table will lose it’s metatable references when passed between the client/server.

3 Likes

Implementing OOP with metatables is inherently prototype-based. The overhead of metatables is negligible and I believe they even have optimizations for the most common usage. You can also eliminate constructors from being called by objects by not putting it in the metatable.

The code below is prototype-based and also provides types.

local Person = {}
Person.__index = Person

export type Person = typeof(setmetatable({} :: {
  _name: string,
  _age: number
}, Person))

function Person.PrintName(self: Person)
  print(self._name)
end

function Person.IncrementAge(self: Person)
  self._age += 1
end

return {
  new = function(name: string, age: number): Person
    return setmetatable({
      _name = name,
      _age = age
    }, Person)
  end
}
1 Like