I was geeking out on the interwebs reading about an old, seemingly discontinued JIT compiler for Lua (conveniently called LuaJIT). Somehow stumbled upon a thread where this quite simple and eloquent solution for OOP popped up. It’s authored by ‘kikito’ on github and is titled ‘middleclass’.
‘middleclass’ makes declaring classes, inheritance, initializing etc. super simple. IMO, this is a much easier way than setting up metatables (which I do agree is versatile, but so verbose and annoying to use)!
Example Code (from link)
local class = require(YOURDIRPATHTOMODULEHERE) -- original line of code: local class = require 'middleclass'
local Fruit = class('Fruit') -- 'Fruit' is the class' name
function Fruit:initialize(sweetness)
self.sweetness = sweetness
end
Fruit.static.sweetness_threshold = 5 -- class variable (also admits methods)
function Fruit:isSweet()
return self.sweetness > Fruit.sweetness_threshold
end
local Lemon = class('Lemon', Fruit) -- subclassing
function Lemon:initialize()
Fruit.initialize(self, 1) -- invoking the superclass' initializer
end
local lemon = Lemon:new()
print(lemon:isSweet()) -- false
This saves you… no code at all? I don’t really see the point. Especially because you can’t just require 'middleclass' in Roblox.
local class = require 'middleclass'
local Fruit = class('Fruit')
function Fruit:initialize(sweetness)
self.sweetness = sweetness
end
Fruit.static.sweetness_threshold = 5
function Fruit:isSweet()
return self.sweetness > Fruit.sweetness_threshold
end
vs
local Fruit = {}
Fruit.__index = Fruit
function Fruit.new(sweetness)
return setmetatable({ sweetness = sweetness }, Fruit)
end
Fruit.sweetness_threshold = 5
function Fruit:isSweet()
return self.sweetness > Fruit.sweetness_threshold
end
One benefit would be replacing obfuscating statements with ones that are easier to understand if you just have an OOP background (and don’t know about Lua metatables)
I’ve explored middleclass, but personally I think metatables are more clear than middle class. Metatables are built into Lua and using them as a class is a “standard” pattern. I don’t think middleclass is as intuitive.
I’ve looked at midldeclass in the past. Was quite interesting, but didn’t really use it. Whenever I needed classes back then, a simple __index metatable was all I needed. and now I’ve created my own class stuff, which now that I think about it, is quite similar to middleclass in some regards
That’s the consensus here too. After much consultation with the Lua experts here (@Tiffblocks, @0xBAADF00D, and @LPGhatguy) as well as some A/B testing and pros/cons evaluation, I decided on using the metatable OOP pattern for the camera scripts refactor as well. Other Lua projects here have used it also, like the Lua Avatar editor. The whole __Index thing wasn’t natural and readable to me either, as someone who has worked mostly in Java and C++ that are designed specifically for OOP, but having a few quirky lines of metatable boilerplate is a small price to pay. In the camera modules, those lines look like this:
local BaseCamera = require(script.Parent)
local ClassicCamera = {}
ClassicCamera.__index = ClassicCamera
setmetatable(ClassicCamera, {
__index = BaseCamera,
__call = function(class,...)
return class.new(...)
end
})
After that, the rest of the class looks pretty much like it would in any other language, with class methods of the form function ClassName:MethodName()
Honestly, I’d really prefer to have the OOP look like this, although this is definitely biased towards my own style, I think it provides the most clarity…
This especially makes it clear when initialization is happening instead of assuming __call() is the constructor (Although if you come from Python or JavaScript maybe this is more clear).
Also it allows BaseCamera to construct itself! Mostly, it prevents this setmetatable() call from turning into an inlined table constructor, which I think is cleaner.
local BaseCamera = require(script.Parent)
local ClassicCamera = setmetatable({}, BaseCamera)
ClassicCamera.__index = ClassicCamera
function ClassicCamera.new()
local self = setmetatable(BaseCamera.new(), ClassicCamera)
-- Initialize
self.Variable = true
return self
end
function ClassicCamera:Method()
-- Do thing...
end
If you need to add metamethods you can do
function ClassicCamera:__call(...)
return self.new(...)
end
function ClassicCamera:__newindex(Index, Value)
print("New Index", Index, Value)
rawset(self, Index, Value)
end
Is that bolded BaseCamera intentional, or should it be ClassicCamera?
And yeah, I could easily leave out the __Call function. It’s natural to me for a constructor to look like ClassName(), but you’re right in that it would be more consistent with the rest of Roblox Lua to explicitly call ClassName.new() to construct.
Another interesting idea is for more complex components, you can override the __index metamethod to get behavior based upon indexes.
A really useful class that came out of this was a ValueObject which works like an ObjectValue, but for any Lua object. Check out the source.
You can use classes like this for really interesting results:
...
function ActionManager.new()
local self = setmetatable(BasicPane.new(), ActionManager)
self.ActiveAction = ValueObject.new()
self.Actions = {}
self.ActionAdded = Signal.new() -- :fire(Action)
self.Maid:GiveTask(ContextActionService.LocalToolEquipped:connect(function(Tool)
self:StopCurrentAction()
end))
self.Maid:GiveTask(self.ActiveAction.Changed:connect(function(Value, OldValue)
if OldValue then
OldValue:Deactivate()
end
end))
return self
end
function ActionManager:StopCurrentAction()
self.ActiveAction.Value = nil -- Fires the changed event!
end
...
This is incredibly useful as state is maintained in a single object – and it’s easy to see how these events connect. Being able to fire/mutate state based upon an internal state is really useful and makes for cleaner code.
The cost is that it’s a bit harder to follow for people not used to events (the observer pattern).
Whoops, didn’t mean to delete that. I basically mentioned how it’s interesting that metatable pseudo-classes actually save a lot of memory (relatively), since it allows any number of instances to share the exact same methods.
Example:
local Person = {}
Person.__index = Person
function Person.new(firstName, lastName)
local self = setmetatable({
FirstName = firstName;
LastName = lastName;
}, Person)
return self
end
function Person:GetFullName()
return self.FirstName + " " + self.LastName
end
-- Create 10,000 people:
local people = {}
for i = 1,10000 do
table.insert( Person.new("Bob", "Smith" + i) )
end
While there are 10,000 instances of Person, there is still only 1 method GetFullName in memory, not 10,000. The metatable will just point to the same GetFullName, passing self to give it instance intent. So, while I could have easily computed the full name and saved it as a property, I saved memory by only computing it from a method and not storing it.
I’m not sure how languages with actual classes do this, but I can only imagine that they do such optimizations.