Improving the Standard OOP Idiom

Introduction


Hi, the original purpose of this post was to gather feedback (and hopefully some assistence) on a class wrapper I was making using a mix of metatables and some automatic inference using the new type solver. End goal was to simplify the current idiom without changing it too much, while also providing some extra features.

After mostly getting backlash I figured this wasn’t worth the hassle, so I decided to resume the post, only leaving the important parts for future reference. Even though it’s functional, I consider this incomplete, so I won’t be moving it to the resources category.

Below you’ll find the original goal of the wrapper, a class comparision using the standard idiom and the one originally proposed, some type functions I made in order to accomplish the former (some functional, some not so much), and at the end of the post you’ll find a link to the utilities class featuring the full implementation as well as a working example of what little from this wrapper was salvaged.

Main goals for the wrapper were to:


  • Provide better class autocompletion out of the box.
    • This includes removing metamethods from class/object types, hiding both declared and inferred private properties, moving object functions declared in the main class to the object, etc.
  • Provide events and listeners for class/object properties.
  • Provide static, private and nullable “rules” for class properties.

Only the later 2 were accomplished.

Intended Wrapper


Class Example

Below is an example of a class using what I believe is the standard idiom (but asserting it’s types so it autocompletes as expected):

local Car = {}
Car.__index = Car

type Car = {
	Brand: string,
	Model: string,
	Plate: string,
	Year: number,
	
	Speed: number,
	Fuel: number,
	Owner: Player?,

	Start: (self: Car) -> ()
}
type InternalCar = Car & {
	_EngineOn: boolean,
	_Trove: Trove.Trove
}

Car.MAX_FUEL = 60
Car.WHEEL_COUNT = 4

Car.EngineStarted = Signal.new()
Car.EngineStopped = Signal.new()
Car.Refueled = Signal.new()

function Car.new(): Car
	local self = {} :: InternalCar
	
	self.Brand = "Unknown"
	self.Model = "Unknown"
	self.Plate = "Unknown"
	self.Year = 0

	self.Speed = 0
	self.Fuel = Car.MAX_FUEL
	self.Owner = nil
		
	self._EngineOn = false
	self._Trove = Trove.new()
	
	return setmetatable(self, Car) :: any
end

-- Start --

function Car.Start(self: InternalCar): ()
	assert(self ~= Car, "Start can only be called from car objects.")
	if not self.EngineOn then
		self.EngineOn = true
		
		print(self.Plate.."'s engine started.")
		Car.EngineStarted:Fire(self.Plate)
	else
		print(self.Plate.."'s engine is already on.")
	end
end

return Car

This works, but can get a little redundant on bigger classes.

Wrapper Example


Here is an example of the class syntax the original post proposed after several iterations:

local Car = {}

local Definition = {
	Rules = { --Rules aren't meant to affect autocompletion (except for 'Constant'), they are meant meant to allow you to enforce preset rules at runtime
		Class = {
			EngineStarted = "Constant", --Constants can't be edited
			EngineStopped = "Constant",
			Refueled = "Constant"
		},
		Object = {		
			Brand = "Static", --Static properties can't be set to a different type
			Model = "Static",
			Plate = "Static",
			Year = "Static",
			
			Speed = "Static",
			Fuel = "Static",
			Owner = "Nullable", --Nullables can't be set to a different type, but can be nil

			_EngineOn = "Static",
			Trove = "Static"
		}
	},
	Filters = { --Filters work much like 'Rules', but are user defined
		Object = {
			Plate = function(self, Value, OldValue) --This is just an example, 'Plates' is not defined
				if not Plates[Value] then
					Plates[Value] = true
					return true
				end
				return false, "Plate '"..tostring(Value).."' is already taken."
			end
		}
	},
	Callbacks = { --Callbacks are called after a property changes successfully, otherwise the error signal is triggered
		Object = {
			Brand = function(self, Value, OldValue)
				print("The car's brand changed from '"..OldValue.."' to '"..Value.."'.")
			end
		}
	}
}

Car.MAX_FUEL = 60 --No need to define, gets inferred to CONSTANT
Car.WHEEL_COUNT = 4

Car.EngineStarted = Signal.new()
Car.EngineStopped = Signal.new()
Car.Refueled = Signal.new()

function Car.new()
	local self = {}
	
	self.Brand = "Unknown"
	self.Model = "Unknown"
	self.Plate = "Unknown"
	self.Year = 0

	self.Speed = 0
	self.Fuel = Car.MAX_FUEL
	self.Owner = nil :: Player?
		
	self._EngineOn = false
	self.Trove = Trove.new()
	
	return setmetatable(self, Car)
end

-- Start --

function Car:Start(self): () --'self' should get automatically inferred to InternalCar in this context
	assert(self ~= Car, "Start can only be called from car objects.")
	if not self.EngineOn then
		self.EngineOn = true
		
		print(self.Plate.."'s engine started.")
		Car.EngineStarted:Fire(self.Plate)
	else
		print(self.Plate.."'s engine is already on.")
	end
end

return CmeUtil.ToInterface(Car, Definition) --Users should not see 'Start' on class, they should see 'Start' on car, they should see no metamethods, and they should see no private fields on their objects

Definition was originally planned to be an optional addition for those wishing to have a luau equivalent to getters and setters, with ToInterface still autocompleting classes as expected even if it wasn’t provided (removing metamethods, private fields it can infer such as underscores, etc. albeit with a little less control than interfaces with definitions).

Type Functions


As stated earlier the inference part of the wrapper wasn’t finished (both due to backlash and engine limitations), and the final version only retained definitions. As a replacement for this the following type functions were added instead.

ReturnsOf


-- ReturnsOf --

--[[
	Returns the resulting types from the passed function in
	vararg form, which is useful.
--]]
export type function returnsOf(func: type): ...type
	assert(func:is("function"), "Func expected to be a function.")

	local returns = func:returns()
	local head, tail = returns.head, returns.tail
	assert(returns.head or returns.tail, "Returns is empty!")
	
	local result = head or {}
	if tail then
		table.insert(result, tail)
	end

	return table.unpack(result)
end

Usage:

export type InternalCar = returnsOf<typeof(Car.new)> --Gets the type returned by the constructor without the need to pass arguments

FilterKeys


--[[
	Performs a filter operation on the table against
	the specified filter and keys, returning the result.
--]]
export type function filterKeys(tbl: type, filter: type, keys: type): type
	assert(tbl:is("table"), "Invalid type: 'Tbl' must be a table.")
	assert(keys:is("union") or keys:is("singleton"), "Invalid type: 'Keys' must be an union or a string.")
	
	local filterValue = assert(filter:is("singleton") and filter:value(), "Invalid type: 'Filter' didn't resolve to a singleton.")
	assert(filterValue == "include" or filterValue == "exclude", "Invalid type: Filter's value must be either 'include' or 'exclude'.")
	
	--We iterate over each property in our table
	local keysList = keys:is("union") and keys:components() or {keys}
	for keyType, valueTypes in tbl:properties() do
		local found = false
		
		--Now we iterate over each key we wanna filter
		for _, patternType in pairs(keysList) do
			local key = keyType:value()
			local pattern = patternType:value()
			
			assert(typeof(key) == "string", "Invalid type: 'key' didn't resolve to a valid string.")
			assert(typeof(pattern) == "string", "Invalid type: 'pattern' didn't resolve to a valid string.")
			
			--If we find the current property in the filter list, we mark it as found and stop iterating over our filters
			if key:find(pattern, 1, true) then
				found = true
				break
			end
		end
		
		--If the current property was or not found in the list, we apply the appropiate filter
		if (filterValue == "exclude" and found) or (filterValue == "include" and not found) then
			tbl:setproperty(keyType, types.singleton(nil))
			
		else --Otherwise, we check if our property is a table, in which case repeat recursively
			local valueType = (valueTypes.read or valueTypes.write) or types.singleton(nil)
			if valueType ~= types.singleton(nil) and valueType:is("table") then
				tbl:setproperty(keyType, filterKeys(valueType, filter, keys))
			end
		end
	end
	
	return tbl
end

Usage:

export type Car = filterKeys<InternalCar, "exclude", "_" | "Trove"> --Removes properties matching the passed unions if present

inheritMethods


--[[
	Looks for functions in 'Class', removes them if appropiate, and inserts
	them into 'Object' after replacing the argument 'self: Class' for 'self: Object'.
--]]
type function inheritMethods(class: type, object: type): type
	assert(class:is("table"), "Invalid Type: 'Class' needs to be a table.")
	assert(object:is("table"), "Invalid Type: 'Object' needs to be a table.")
	
	--Iterate over the class
	for keyType, valueTypes in class:properties() do
		local valueType = valueTypes.read or valueTypes.write
		local key = keyType:value()
		
		--Look for methods
		if valueType and valueType:is("function") then
			local params = valueType:parameters()
			local head, tail = params.head, params.tail
			
			--Override self
			if head and (head[1] == class or head[1] == object) then
				if head[1] == class then
					head[1] = object
				end
				valueType:setparameters(head, tail)
				
				--Insert to obj
				object:setproperty(keyType, valueType)
			end
		end
	end
	
	return object
end

Usage:

type Object = inheritMethods<Class, returnsOf<Class.new>> --Returns of should get the properties of the class, while inheritMethods should get it's functions

Conclusion


Sadly from my testing the first 2 type functions only work on contained tests, and won’t beheave as expected on actual classes. ‘inheritMethods’ is currently not supported due to engine limitations, but may be on the future.

At the end the only thing I managed to finish was the definition functionality at runtime, which was meant to be more of an extra from the start, but it’s better than nothing nonetheless.

You can find the full ‘ToInterface’ implementation here near the end of the module (I dropped support upstream, but here’s an old version with it):

And you can find a real example of usage on my ‘Binder’ module (once again on an older version):

In the future please abstain from bumping this post, i’ve only rewritten it to keep it for future reference.

4 Likes

Would it be better for this in the community tutorials?

3 Likes

I thought of it but figured the point of a tutorial is to provide a functional resource at the end. This doesn’t quite meet that, and I don’t wanna go around spreading missinformation in the tutorials category either (if anything I’ll do a proper post with some research if I finish this).

2 Likes

you can also do:

type Car = {
	Brand: string,
	Model: string,
	Speed: number,
	Start: (self: Car) -> ()
	-- do not include members beginning with _
}
type CarObject = {
	Plate: string,
	Start: (self: CarObject) -> ()
}
type CarClass = {
	MAX_FUEL: number,
	new: (plate: string) -> CarObject
}
local Car: CarClass = {}
local myCar: CarObject = Car.new("TEST")

image

image

About events, it’s not really something you’d want all the time. A separate Signal class or just the usual bindables is a better fit in most cases.
The only thing I’m not quite sure how to handle is static fields at compile time. Doing it at runtime doesn’t really sit well with me.

1 Like

Hi, I do mention this in the post:

It’s pretty much how I currently type my classes:

type Car = {}
Car.__index = Car

export type Car = {
	Brand: string,
	Model: string,
	Speed: number,
	--etc.
}
export type InternalCar = Car & { --Exposed in case idk, you wanna inherit the class or something
	Trove: Trove.Trove,
	--etc.
}

function Car.new(): Object
	local self: InternalObject = {} :: any

	--Definition

	return setmetatable(self, Car) :: any
end

--...

return {
	new = Car.new
}

--Alternatively:
--return (Car :: any) :: {new = typeof(Car.new)} --Along typing everything you wanna pass back manually

As stated it works, and i’ll likely stick to it for the time being (albeit with some of the improvements I found for this implementation), but the point of the wrapper is to be a compact solution for these problems, I don’t think you should have to type your classes manually to have them autocomplete as expected.

Getters/setters and signals are absolutely a personal preference and I understand that, in the ideal implementation they’d be optional for those who want to use them, otherwise you’d still get to benefit from the type refinement.

Lastly these of course absolutely don’t work as they would in a language that supports them, they are mainly meant for getter/setter use cases where you wanna prevent the user from breaking something with a simple rule (in that regard the current naming convention may be misleading).

Anyways, all feedback is appreciated, and i’m willing to make some changes based on feedback if I do come back to this.

1 Like

Why do you need __index for singleton?

OOP in Luau sucks and is unoptimized
ECS is the only way

1 Like

you could at least applaud this guy for putting so much work into this
its not constructive criticism without construction

5 Likes

But it brings wrong point and even uses pairs/ipairs which is weak compare to optimizations of generic iteration.

It also shows metatable OOP which is honestly awful if you have to use OOP in the first place.

Just use ECS for everything and let OOP die in Luau.

Also it should be in different category like Resources > Community Tutorials

All post did was bring some type function for analizing which is fully optional.

2 Likes

Buddy were you expecting this post to transcend your consciousness or something? You can geek out to your superior ideas somewhere else instead of putting this post down. Sounds like you just learned about ECS a day ago the way you’re glazing it.

3 Likes

its probably just a typo, or he’s tired. can you have some grace please?
devforum is meant to be a relatively welcoming place.

2 Likes

There is a difference between grace and a lie; your example implies that lying would equal grace, which I disagree with.

What typo? Typo with a length of 5K+ characters? :rofl:
You can’t “typo” a code snippet, dude. What are you talking about?

This part has nothing to do with the current topic.

1 Like

He does this anytime OOP is mentioned. I just learn to ignore it, as I honestly can’t tell if its rage-bait, or just intellectual arrogance.

4 Likes

you dont need __index at all (not opening that can of worms)

__index is basically free in Luau, since if its a table, it doesn’t bother doing the GETTABLEKS, MOVE, CALL stuff, it just calls it. (it might still call MOVE to prepare registers).

3 Likes

I’ll have to agree with you as well. I don’t think needing to rewrite the entire OOP idiom for ECS isn’t necessary. Also, OP probably doesn’t even know what ECS is in the first place, so it’s essentially opening a can of worms of complicated stuff.

4 Likes