Classy - Middleclass Inspired, But a lot faster
Posted: Thu Dec 29, 2016 5:44 am
Greetings and salutations. So for my first trick, I bring to you all something that many of you who (like myself) use object oriented programming paradigms in Lua will find most interesting.
Games use memory. They tend to use a lot of memory. Like, duh right? So why is it that we still blindly copy stuff and endlessly add to tables without any regard to how lua is rehashing the tables under the hood? That is what I hope that this implementation will address.
I also did it because I'm neurotic and a stickler for how memory is handled and if I can avoid rehashes, then dammit I'm avoiding rehashes. Because reasons!
Enter classy.lua. It's a simple OO class implementation modeled after the very well designed middleclass that aims to address many of the shortcomings that I found it has. It uses a combination of simple code generation and object templating to prevent rehashes as much as possible as well as keep as many of the nicer features that middleclass has.
Wait... Doesn't 30log use Templating?
Yeah, and it is rather slow compared to middleclass. That and I already had a lot of code that used middleclass, so I wanted something I could more or less drop in without having to refactor a whole lot of stuff... turns out I couldn't just drop it in and was in for a lot of refactoring anyway, but it works! It's faster! And the code is actually easy to follow at first glance!
Why the obsession with rehashes?
I've operated under this simple philosophy: if you code with a particular optimization in mind now, you don't have to do it later. Lua is slow enough as it is (even with LuaJIT in Love), so helping it not have to allocate more memory than it needs is the way to go in my opinion.
Then again, having opinions on the internet is dangerous. And I like to live dangerously.
Anyway, here's the github.
https://github.com/ForgeMistress/classy
A simple classy class.
Subclassing (with an example of a table in the template):
Things that this does
Classes
... Yep. It does object orientation.
Method Overriding
Works as you expect it to, with calls to the base class implementation of a method supported with <class>.SomeMethod(self). Movin' right along!
Mixins
You like mixins? Classy supports them with a similar syntax as middleclass' mixins (to adapt kikito's example on the middleclass wiki).
Static Stuff
Like scoping things to a class? Here ya go sparky!
Things that this does well.
Large numbers of objects.
In my profiling, I've gotten it down to where base classes are allocated a little faster than middleclass classes with the same fields and for a similar amount of memory.
Large numbers of inherited objects.
This is where this implementation truly shines. I'll let the profiler results speak for itself, though the code used to generate this result is in the github repo. Inheritance hierarchy in this profiled code is DoubleSubclass->Subclass->BaseClass and the script is allocating 500000 instances of the buggers into a preallocated table. TL;DR: classy is significantly faster than middleclass in this very common use case.
classy
middleclass
The profiler code (as well as the results of some other tests) can be found in the github repo. I included a copy of middleclass and ProFi.lua as well so you can quickly profile it. Use ./profile.sh if you have Luapower already installed or modify the script where I left a comment to set your lua runtime to profile against. Feel free to test your own implementation against this as well. I'd love to see how we can make our OO implementations faster.
Metamethods
You can define metamethods using a Python style naming convention directly in the class definition. Most metamethods are supported and new ones can be added easily.
Things that this does not support
class(<name>, Superclass) Syntax
Them's the breaks when you're trying to keep rehashes down. <superclass>:subclass looks cleaner anyway if you ask me. Feel free to add it to your own copy if you want. It won't look as pretty though. And I like pretty things.
<class>(params) Allocation Syntax
Don't like it, didn't want it. Just another level of unnecessary indirection.
Potential Gotchas
When you pass in a class template to classy() or <class>:subclass(), that template is then read into a string and passed into loadstring with a couple other fields added to it (at least in the case of a base class). This means that if you have the following code, you will get a wonky result.
The goal of the class template is to create a default set of keys to instantiate an object with to prevent table rehashes, so the value can theoretically be anything you want. Bear in mind though, the values in the template are the values of whatever is set, so keep this in mind when defining your templates.
But What About Default Nils?
Ah, I hear your cry. Forsooth, having the table {X=nil} may well be the same as {}, but I prithy you not disdain over such trivial matters!
Remember, the biggest goal was to prevent rehashes, and Lua can guarantee that the size of the hash part of a table will not change unless a rehash is triggered. Since the size of the hash part is already defined, so long as there is a valid value in the template, the size of the hash will be set, Bob's your Uncle, Jenny's your Aunt, and all's right with the world... you may want to leave a comment in your template though (see above)... because it's just common courtesy to your fellow programmers.
Subclass Keys
Don't do this.
Or you'll get this.
And this.
Let me know how this implementation can be improved. I'd love to hear feedback from you all.
UPDATES!!!
* Performance has been increased on the allocation of objects. It now allocates objects twice as fast as middleclass based on my rewritten profiler.
* Method call speed remains the same.
* Renamed some of the more important keys in the class object to use a Pythonian naming convention. Foo.class is now Foo.__class__, Foo.super is now Foo.__super__, Foo.name is now Foo.__name__.
* Instances also share this change. instance.class is now instance.__class__, and so on and so forth.
* Metatable function definitions! You can now define functions like __index, __eq, and the like directly in the class definition using the Pythonian naming convention.
* Mixin level callbacks added. Mixins can now have callbacks in them that allow you do do things within them when they're included in a class. This is useful for when you want to write a mixin that keeps track of classes that use it. I use it in my reflection and serialization implementations.
Games use memory. They tend to use a lot of memory. Like, duh right? So why is it that we still blindly copy stuff and endlessly add to tables without any regard to how lua is rehashing the tables under the hood? That is what I hope that this implementation will address.
I also did it because I'm neurotic and a stickler for how memory is handled and if I can avoid rehashes, then dammit I'm avoiding rehashes. Because reasons!
Enter classy.lua. It's a simple OO class implementation modeled after the very well designed middleclass that aims to address many of the shortcomings that I found it has. It uses a combination of simple code generation and object templating to prevent rehashes as much as possible as well as keep as many of the nicer features that middleclass has.
Wait... Doesn't 30log use Templating?
Yeah, and it is rather slow compared to middleclass. That and I already had a lot of code that used middleclass, so I wanted something I could more or less drop in without having to refactor a whole lot of stuff... turns out I couldn't just drop it in and was in for a lot of refactoring anyway, but it works! It's faster! And the code is actually easy to follow at first glance!
Why the obsession with rehashes?
I've operated under this simple philosophy: if you code with a particular optimization in mind now, you don't have to do it later. Lua is slow enough as it is (even with LuaJIT in Love), so helping it not have to allocate more memory than it needs is the way to go in my opinion.
Then again, having opinions on the internet is dangerous. And I like to live dangerously.
Anyway, here's the github.
https://github.com/ForgeMistress/classy
A simple classy class.
Code: Select all
local class = require("classy")
local BaseClass = class("BaseClass", {
-- When BaseClass:new() is called, the object instance will have fields set to the values you assign here.
Bar = 0;
Baz = 0;
Foo = 0;
})
function BaseClass:__init__(foo, baz)
self.Foo = foo
self.Baz = baz
end
function BaseClass:func()
self.Bar = self.Bar + 1
end
-- This last line is key.
BaseClass:finalize()
-- Or if you want to keep to a one class per file structure with your code (like I like to do), finalize
-- also returns the finalized class for the sake of convenience, though even in the invocation above,
-- the class is finalized in place, because lua tables.
return BaseClass:finalize()
Code: Select all
local BaseClass = require("BaseClass")
local Subclass = BaseClass:subclass("Subclass", {
SubTable = 0;
SubBoolean = false;
})
function Subclass:__init__(foo, bar, baz)
BaseClass.__init__(self, foo, bar, baz) -- There's that overriding I was talking about.
self.SubTable = {"Test Table"}
assert(self.SubBoolean == false, "This will never assert because the generated allocator sets the value of SubBoolean on construction.")
end
return Subclass:finalize()
Classes
... Yep. It does object orientation.
Method Overriding
Works as you expect it to, with calls to the base class implementation of a method supported with <class>.SomeMethod(self). Movin' right along!
Mixins
You like mixins? Classy supports them with a similar syntax as middleclass' mixins (to adapt kikito's example on the middleclass wiki).
Code: Select all
local class = require("classy")
local HasWings = { -- HasWings is a module, not a class. It can be "included" into classes
-- Here's a fun one. This gets called when a class that uses this mixin is instantiated after it's own __init__ constructor is called.
__init__ = function(instance)
-- Though doing this can cause rehashing since it exists outside the template, which is evil. It's there if you need it
-- though until you restructure your code.
instance.CanFly = true
end;
-- New instances of classes that include this mixin will have these methods.
methods = {
fly = function(instance)
print('flap flap flap I am a ' .. instance.class.name)
end;
canFly = function(instance)
return instance.CanFly
end;
};
static = {
-- Add methods to a class object by adding them here.
};
}
local Animal = class("Animal")
:finalize()
local Insect = Animal:subclass("Insect")
:finalize()
local Worm = Insect:subclass("Worm")
:finalize() -- worms don't have wings
local Bee = Insect:subclass("Bee")
:include(HasWings) --Bees have wings. This adds fly() and canFly() to Bee
:finalize()
local Mammal = class("Mammal", Animal):finalize()
local Fox = Mammal:subclass("Fox")
:finalize() -- foxes don't have wings, but are mammals
local Bat = Mammal:subclass("Bat")
:include(HasWings) --Bats have wings, too.
:finalize()
local bee = Bee:new()
local bat = Bat:new()
bee:fly()
bat:fly()
Like scoping things to a class? Here ya go sparky!
Code: Select all
local Animal = class("Animal"):finalize()
local HoneyBadgerStates = {
PROBABLY_ABOUT_TO_MAUL_YOUR_FACE = 0;
NOT_GIVING_AN_F = 1;
MAULING_YOUR_FACE = 2;
}
local HoneyBadger = Animal:subclass("HoneyBadger", {
State = HoneyBadgerStates.NOT_GIVING_AN_F;
})
HoneyBadger.static.States = HoneyBadgerStates
function HoneyBadger:GetState()
return self.State
end
function HoneyBadger:SetState(state)
self.State = state
end
HoneyBadger:finalize()
local someBadger = HoneyBadger:new()
someBadger:SetState(HoneyBadger.States.MAULING_YOUR_FACE)
Large numbers of objects.
In my profiling, I've gotten it down to where base classes are allocated a little faster than middleclass classes with the same fields and for a similar amount of memory.
Large numbers of inherited objects.
This is where this implementation truly shines. I'll let the profiler results speak for itself, though the code used to generate this result is in the github repo. Inheritance hierarchy in this profiled code is DoubleSubclass->Subclass->BaseClass and the script is allocating 500000 instances of the buggers into a preallocated table. TL;DR: classy is significantly faster than middleclass in this very common use case.
classy
Code: Select all
###############################################################################################################
##### ProFi, a lua profiler. This profile was generated on: 12/28/16 23:19:12
##### ProFi is created by Luke Perkin 2012 under the MIT Licence, www.locofilm.co.uk
##### Version 1.3. Get the most recent version at this gist: https://gist.github.com/2838755
###############################################################################################################
| TOTAL TIME = 10.758000
| FILE : FUNCTION : LINE : TIME : RELATIVE : CALLED |
| .\classy.lua : new : 105 : 9.757 : 90.70% : 500000 |
| profile.lua : __init__ : 60 : 5.757 : 53.51% : 500000 |
| profile.lua : __init__ : 50 : 3.252 : 30.23% : 500000 |
| .\classy.lua : __index : 148 : 1.230 : 11.43% : 1000000 |
| [string "DoubleSubclass"] : allocator : 0 : 0.858 : 7.98% : 500000 |
| profile.lua : __init__ : 34 : 0.593 : 5.51% : 500000 |
| .\ProFi.lua : setHighestMemoryReport : 408 : 0.000 : 0.00% : 1 |
| .\ProFi.lua : setLowestMemoryReport : 418 : 0.000 : 0.00% : 1 |
| .\ProFi.lua : stop : 85 : 0.000 : 0.00% : 1 |
| [C] : insert : -1 : 0.000 : 0.00% : 1 |
| .\ProFi.lua : shouldReturn : 199 : 0.000 : 0.00% : 1 |
| .\ProFi.lua : checkMemory : 94 : 0.000 : 0.00% : 1 |
| .\ProFi.lua : startHooks : 234 : 0.000 : 0.00% : 0 |
| [C] : pairs : -1 : 0.000 : 0.00% : 500000 |
| [C] : getTime : -1 : 0.000 : 0.00% : 3 |
| [C] : collectgarbage : -1 : 0.000 : 0.00% : 2 |
| .\ProFi.lua : start : 67 : 0.000 : 0.00% : 0 |
| .\ProFi.lua : stopHooks : 238 : 0.000 : 0.00% : 1 |
| [C] : setmetatable : -1 : 0.000 : 0.00% : 500000 |
| [C] : sethook : -1 : 0.000 : 0.00% : 1 |
=== HIGH & LOW MEMORY USAGE ===============================
H 10.762 : 152609 Kbytes : 149.0 Mbytes H classy inheritance-allocation Iteration 500000
L 10.762 : 152609 Kbytes : 149.0 Mbytes L classy inheritance-allocation Iteration 500000
=== MEMORY USAGE ==========================================
| 10.762 : 152609 Kbytes : 149.0 Mbytes | classy inheritance-allocation Iteration 500000
Code: Select all
###############################################################################################################
##### ProFi, a lua profiler. This profile was generated on: 12/28/16 23:19:34
##### ProFi is created by Luke Perkin 2012 under the MIT Licence, www.locofilm.co.uk
##### Version 1.3. Get the most recent version at this gist: https://gist.github.com/2838755
###############################################################################################################
| TOTAL TIME = 21.877000
| FILE : FUNCTION : LINE : TIME : RELATIVE : CALLED |
| .\middleclass.lua : new : 123 : 17.188 : 78.57% : 500000 |
| .\middleclass.lua : __index : 82 : 7.044 : 32.20% : 2500000 |
| profile.lua : initialize : 94 : 6.419 : 29.34% : 500000 |
| profile.lua : initialize : 87 : 3.594 : 16.43% : 500000 |
| profile.lua : initialize : 76 : 1.203 : 5.50% : 500000 |
| .\middleclass.lua : __index : 35 : 1.118 : 5.11% : 500000 |
| [C] : collectgarbage : -1 : 0.000 : 0.00% : 2 |
| .\ProFi.lua : checkMemory : 94 : 0.000 : 0.00% : 1 |
| .\ProFi.lua : setHighestMemoryReport : 408 : 0.000 : 0.00% : 1 |
| .\ProFi.lua : setLowestMemoryReport : 418 : 0.000 : 0.00% : 1 |
| [C] : insert : -1 : 0.000 : 0.00% : 1 |
| .\ProFi.lua : shouldReturn : 199 : 0.000 : 0.00% : 1 |
| .\ProFi.lua : stop : 85 : 0.000 : 0.00% : 1 |
| [C] : __index : -1 : 0.000 : 0.00% : 500000 |
| .\ProFi.lua : startHooks : 234 : 0.000 : 0.00% : 0 |
| .\ProFi.lua : stopHooks : 238 : 0.000 : 0.00% : 1 |
| [C] : rawget : -1 : 0.000 : 0.00% : 2500000 |
| .\ProFi.lua : start : 67 : 0.000 : 0.00% : 0 |
| [C] : getTime : -1 : 0.000 : 0.00% : 3 |
| .\middleclass.lua : __index : 84 : 0.000 : 0.00% : 500000 |
| [C] : type : -1 : 0.000 : 0.00% : 1000000 |
| .\middleclass.lua : allocate : 118 : 0.000 : 0.00% : 500000 |
| [C] : assert : -1 : 0.000 : 0.00% : 1000000 |
| [C] : allocate : -1 : 0.000 : 0.00% : 500000 |
| [C] : sethook : -1 : 0.000 : 0.00% : 1 |
=== HIGH & LOW MEMORY USAGE ===============================
H 21.880 : 152609 Kbytes : 149.0 Mbytes H middleclass inheritance-allocation Iteration 500000
L 21.880 : 152609 Kbytes : 149.0 Mbytes L middleclass inheritance-allocation Iteration 500000
=== MEMORY USAGE ==========================================
| 21.880 : 152609 Kbytes : 149.0 Mbytes | middleclass inheritance-allocation Iteration 500000
Metamethods
You can define metamethods using a Python style naming convention directly in the class definition. Most metamethods are supported and new ones can be added easily.
Things that this does not support
class(<name>, Superclass) Syntax
Them's the breaks when you're trying to keep rehashes down. <superclass>:subclass looks cleaner anyway if you ask me. Feel free to add it to your own copy if you want. It won't look as pretty though. And I like pretty things.
<class>(params) Allocation Syntax
Don't like it, didn't want it. Just another level of unnecessary indirection.
Potential Gotchas
When you pass in a class template to classy() or <class>:subclass(), that template is then read into a string and passed into loadstring with a couple other fields added to it (at least in the case of a base class). This means that if you have the following code, you will get a wonky result.
Code: Select all
local ConstantTable = {}
local BaseClass = class("BaseClass", {
-- When BaseClass:new() is called, the values under these keys will have the values set here.
Bar = ConstantTable;
Baz = 0;
Foo = 0;
})
function BaseClass:__init__(foo,baz)
self.Foo = foo
-- Leaving self.Bar alone here since we can do that.
self.Baz = baz
end
-- This last line is key.
BaseClass:finalize()
-- generated allocator is literally "return {class=0,Bar={},Baz=0,Foo=0}", and that is the code that is run below.
local instance = BaseClass:new()
assert(instance.Bar == ConstantTable, "This will always assert because the generated allocator uses the _value_ of the field in the template.")
local AnotherClass = class("AnotherClass", {
Bar = { SubBar = 0 };
Baz = 0;
Foo = 0;
})
function AnotherClass:__init__(foo,baz)
self.Foo = foo
-- Leaving self.Bar alone here since we can do that.
self.Baz = baz
end
AnotherClass:finalize()
local instance = AnotherClass:new()
assert(instance.Bar.SubBar == 0, "This, however, should definitely work.")
But What About Default Nils?
Ah, I hear your cry. Forsooth, having the table {X=nil} may well be the same as {}, but I prithy you not disdain over such trivial matters!
Code: Select all
local Class = class("AnotherClass", {
Bar = 0; -- nil default.
Baz = 0;
Foo = 0;
})
function Class:__init__(foo,baz)
self.Foo = foo
self.Bar = nil
self.Baz = baz
end
Class:finalize()
Subclass Keys
Don't do this.
Code: Select all
local class = require("classy")
local BaseClass = class("BaseClass", {
-- When BaseClass:new() is called, the object instance will have fields set to the values you assign here.
Bar = 0;
Baz = 0;
Foo = 0;
})
function BaseClass:__init__(foo, baz)
self.Foo = foo
self.Baz = baz
end
-- This last line is key.
BaseClass:finalize()
local Subclass = BaseClass:subclass("Subclass", {
Bar = 0; -- <--- This little bugger right here.
})
Subclass:finalize()
Code: Select all
E:\Program Files\luapower\bin\mingw64\luajit.exe: .\classy.lua:57: Duplicate key detected: Key = Bar Class = Subclass
stack traceback:
[C]: in function 'assert'
.\classy.lua:57: in function '_mergeTables'
.\classy.lua:69: in function '__makeAllocatorFunction'
.\classy.lua:200: in function 'subclass'
profile.lua:46: in main chunk
[C]: at 0x00401f80
Let me know how this implementation can be improved. I'd love to hear feedback from you all.
UPDATES!!!
* Performance has been increased on the allocation of objects. It now allocates objects twice as fast as middleclass based on my rewritten profiler.
* Method call speed remains the same.
* Renamed some of the more important keys in the class object to use a Pythonian naming convention. Foo.class is now Foo.__class__, Foo.super is now Foo.__super__, Foo.name is now Foo.__name__.
* Instances also share this change. instance.class is now instance.__class__, and so on and so forth.
* Metatable function definitions! You can now define functions like __index, __eq, and the like directly in the class definition using the Pythonian naming convention.
Code: Select all
function Foo:__index__(key) end
Code: Select all
local Mixin = {
__oninclude__ = function (klass)
-- This will be called every time Class:include is called and this mixing is passed in.
end;
}