Classy - Middleclass Inspired, But a lot faster

Showcase your libraries, tools and other projects that help your fellow love users.
User avatar
Forgemistress
Prole
Posts: 9
Joined: Thu Dec 01, 2016 1:50 am

Classy - Middleclass Inspired, But a lot faster

Post by Forgemistress »

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.

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()
Subclassing (with an example of a table in the template):

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()
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).

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()
Static Stuff
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)
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

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
middleclass

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
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
Image

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.")

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!

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()
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.

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()
Or you'll get this.

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
And this.
Image

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 
* 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.

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;
}
Last edited by Forgemistress on Thu Mar 09, 2017 6:00 pm, edited 3 times in total.
The flame cleanses all. It clears away the old to make way for the new. Forge for yourself new opportunities from the dross of the old.
User avatar
airstruck
Party member
Posts: 650
Joined: Thu Jun 04, 2015 7:11 pm
Location: Not being time thief.

Re: Classy - Middleclass Inspired, But a lot faster

Post by airstruck »

I tried adding a section to your performance tests for a "plain vanilla" solution with no class library. The setup part looked like this:

Code: Select all

	local function func (self)
		self.Bar = self.Bar + 1
	end
	
	BaseClass = function (foo, bar, baz)
		return { Foo = foo, Bar = bar, Baz = baz, func = func }
	end
	
	Subclass = function (foo, bar, baz, bool)
		local t = BaseClass(foo, bar, baz)
		t.NewTable = {"Test Table"}
		t.NewBoolean = bool
		return t
	end
	
	DoubleSubclass = function (bar)
		local t = Subclass(2, bar, "String value!", false)
		t.AnotherNewTable = {}
		return t
	end
According to these tests, the plain vanilla solution slightly outperforms both class libraries (in every test, on both memory and speed, on my laptop anyway). It might be good to add something like this to your tests as a sort of control group, and then maybe write some tests that show where using a class library has an advantage over not using one.
User avatar
raidho36
Party member
Posts: 2063
Joined: Mon Jun 17, 2013 12:00 pm

Re: Classy - Middleclass Inspired, But a lot faster

Post by raidho36 »

Can you program the implementation in a way that it boils everything down to a vanilla-identical code? So that you don't lose on performance over class definition flavors.
User avatar
airstruck
Party member
Posts: 650
Joined: Thu Jun 04, 2015 7:11 pm
Location: Not being time thief.

Re: Classy - Middleclass Inspired, But a lot faster

Post by airstruck »

If more methods were added to the test case, the vanilla no-metatables solution would start to lose on memory consumption. It would also be interesting to benchmark against a plain vanilla prototype-inheritance-with-metatables approach, which should do fine on memory.

Avoiding rehashes and keeping overall memory consumption down seem like they might be conflicting goals. You could get memory consumption down even more by putting all the default field values in the prototype table instead of the object table, and in cases where the default value doesn't change, you'll save a few bytes of memory (same as storing function references there instead of in the object table).

Since this library chooses to store those values in the object rather than the prototype, it seems to choose "obsession with rehashes" over reducing overall memory consumption. Not that that's necessarily a bad thing; it sounds to me like favoring increased speed over reduced memory consumption, which seems perfectly reasonable (as long as there's actually an overall speed advantage). It doesn't feel like that's quite in line with the stated intent at the top of the post, though.
User avatar
Forgemistress
Prole
Posts: 9
Joined: Thu Dec 01, 2016 1:50 am

Re: Classy - Middleclass Inspired, But a lot faster

Post by Forgemistress »

@raidho
When I feel compelled to, I may try to do that, however I try to share data wherever I can, hence the re-use of prototypes when subclassing objects.

@airstruck
The goal was to reduce the memory usage at a per-instance level, not at the per-class level (I'd like to optimise both here in the future, but I already spent more time than I wanted to on this one small bit of my codebase), however I see what you mean. Reducing rehashes and reducing memory consumption aren't mutually exclusive goals though, since objects with large numbers of fields could potentially trigger a rehash that takes more memory than you will actually ever need. It may be splitting hairs, all things considered, but if you take it into the context of mobile development, you need every byte of RAM you can get and a rehash of a table can potentially hose memory for a more demanding game. It all goes back to what I was saying before, "don't use more memory than you need." I actually am tempted to have it error out when you try to __newindex an object instance, but that also involves me going through my entire codebase to the areas where I was being lazy... and I'm still lazy.

@pgimeno
Interesting. Well, I don't plan on renaming it now.
The flame cleanses all. It clears away the old to make way for the new. Forge for yourself new opportunities from the dross of the old.
alloyed
Citizen
Posts: 80
Joined: Thu May 28, 2015 8:45 pm
Contact:

Re: Classy - Middleclass Inspired, But a lot faster

Post by alloyed »

I'm not sure it's a good idea to use profi when you're trying to benchmark your code. It's good for identifying bottlenecks but adding debug hooks like profi does changes how luajit can optimize the code it's running. To do elapsed time, you can just compare os.clock() calls on your own, and for counting memory you can just do collectgarbage()
User avatar
Forgemistress
Prole
Posts: 9
Joined: Thu Dec 01, 2016 1:50 am

Re: Classy - Middleclass Inspired, But a lot faster

Post by Forgemistress »

@alloyed
Updated the git repo to do exactly that. There is definitely something henkey going on under the hood when ProFi is brought into the mix.
The flame cleanses all. It clears away the old to make way for the new. Forge for yourself new opportunities from the dross of the old.
User avatar
TheZombieKiller
Prole
Posts: 8
Joined: Sun Dec 13, 2015 11:53 am
Location: Gold Coast, Australia
Contact:

Re: Classy - Middleclass Inspired, But a lot faster

Post by TheZombieKiller »

pgimeno wrote:There's another Classy, by the way: https://www.love2d.org/forums/viewtopic.php?f=5&t=82027
Considering I haven't worked on Classy for a while now, and have a much better implementation of its style of classes coming soon (soon being whenever I get off my butt and get it ready for release), I don't really mind if this project becomes the more commonly-known one under the name.
User avatar
Forgemistress
Prole
Posts: 9
Joined: Thu Dec 01, 2016 1:50 am

Re: Classy - Middleclass Inspired, But a lot faster

Post by Forgemistress »

Sorry about that dude. Didn't even think to look to see if there was another project with the same name.
The flame cleanses all. It clears away the old to make way for the new. Forge for yourself new opportunities from the dross of the old.
Post Reply

Who is online

Users browsing this forum: Ahrefs [Bot] and 5 guests