Bubbles are composable objects presented as an alternative to classical-style inheritance. Instead of modeling our objects with parent-child relationships as is conventional in Lua-style OOP, we represent each of the traits of our object as individual, simple objects that perform one purpose. We can then compose many of these simple objects into one larger object that encompasses all of the traits of the constituent objects.
You can think of Bubbles as conventional Lua "classes", but more flexible, without limits, and fully featured. At first glance, Bubbles are somewhat similar to conventional classes:
local Bubbles = require(path.to.Bubbles)
local Bubble = Bubbles.Bubble
local bubble = Bubble.new()
print(getmetatable(bubble) == Bubble) --> true
However, Bubbles carry around metadata about the object instance they will produce when instantiated (with .new()
) inside of a compose
field. This is known as a Bubble descriptor.
local descriptor = Bubble.compose
Conventional classes model objects with child->parent->parent->parent->... relationships. In contrast, when you compose two or more Bubbles together, you merge methods, properties, static methods, and initializers (constructors) from multiple objects, making a brand new object. Fields from later-composed Bubbles overwrite fields from earlier-composed Bubbles.
The merge process is facilitated by this metadata attached to Bubbles, which are essentially instructions on how to create that type of object.
There are 8 kinds of metadata:
name
: string- A human-friendly name for this object, useful for debugging. If you call
tostring
on this object, you'll get this value.
- A human-friendly name for this object, useful for debugging. If you call
initializers
: array (table)- Initializers are similar to constructors, except every initializer from every Bubble you compose will run upon instantiation.
- The order of execution is the order of composition.
- Initializers only accept one argument: a dictionary (table) with descriptively-named properties. This way, all initializers can live in harmony with each other.
- Initializers are always de-duplicated upon composition.
methods
: dictionary (table)- Methods that will be available on Bubble instances belong in the
methods
table. - Methods accept
self
as their first argument, followed by any number of arguments, just as in conventional classes. - The methods table becomes the __index metamethod of the Bubble.
- Methods that will be available on Bubble instances belong in the
props
: dictionary (table)- Props, or properties, are nothing more than default values for properties of the instantiated value.
- Props are copied by reference into the new object upon instantiation.
deepProps
: dictionary (table)- Deep props are similar to props, except that upon composition, they are deeply merged with identically-named props from other Bubbles.
- Tables are deep-copied.
- Dictionaries are deeply merged.
- Arrays are concatenated.
- If a normal property and a deep property share the same name, the normal property wins.
statics
: dictionary (table)- Both static methods and properties can be contained within
statics
. - They are available on the non-instance version of the Bubble.
- Both static methods and properties can be contained within
deepStatics
: dictionary (table)- Deep statics are semantically identical to deep props, except they are accessed on the non-instance version of the Bubble.
composers
: array (table)- Composers are special hooks that run at time of composition and have the ability to influence the resulting object.
- Composers are always de-duplicated upon composition.
Composing Bubbles can be accomplished a few different ways. Firstly, simply calling the static Bubble as a function will compose it with any passed values:
local Example = Bubble({
name = "Example";
methods = {
foo = function(self)
print(self) --> Example
end
};
})
In the code snippet above, we are composing the base Bubble
object with a descriptor. This results in a new Bubble, merging the descriptor's traits with the base Bubble.
To clarify, all Bubbles must be composed from the base Bubble
object. The Bubble
object is a Bubble with all blank metadata, so composing with it just creates a fresh Bubble merged with your provided metadata.
Calling the Bubble as a function is identical to calling the :compose
method explicitly:
local SecondExample = Example:compose({
name = "SecondExample";
})
Several static helper methods are also provided on the base Bubble
object, which means that they are available to all Bubbles. These helper methods allow you to compose Bubbles, changing one property at a time:
local ThirdExample = SecondExample:name("ThirdExample")
Since each of these methods returns a new Bubble, that means that they are chainable:
local FourthExample = ThirdExample:name("FourthExample"):methods({
bar = function(self, x, y)
return x + y
end;
}):init(function()
print("A new", tostring(self), "was just instantiated!")
end):deepProps({
List = {"One"}
})
In the code sample above, init
is a helper function that is short for setting initializers
to an empty array and then placing the provided function inside of it.
Speaking of shortcuts, you can compose Bubbles with a string value as a shorthand for setting the name
:
local FifthExample = FourthExample:compose("FifthExample")
-- or, equivalent:
local FifthExample = FourthExample("FifthExample")
And since methods in Lua are usually nicer to assign with the method syntax shortcut, you can pass a function to methods
which offers this ability for quality of life:
local SixthExample = FifthExample:methods(function(FifthExample)
local function privateMethod(self, x)
return 2 + x
end
function FifthExample:baz(x)
return privateMethod(self, x)
end
end)
In the above sample, we can see that when using the functional method shortcut, a blank table is passed to the function as its first and only argument. Any methods defined on this table will become the methods dictionary in the Bubble descriptor.
Important note: This function is immediately invoked, so you can't store instance-specific private data inside of this closure. However, it can be convenient for helper functions or private methods. Prefixing method names with an underscore to indicate private methods is also an acceptable pattern, and is arguably more robust.
Up until now, we've only composed one Bubble at a time. But when using Bubbles in the wild, the real power comes from the ability to compose many "primitive" objects into one.
local Foo = Bubble({
methods = function(Foo)
function Foo:bar()
-- do somethng
end
end
})
local Bar = Bubble({
methods = function(Bar)
function Bar:foo()
-- do somethng
end
end
})
local Baz = Bubble({
methods = function(Baz)
function Baz:zoinks()
-- do somethng
end
end
})
local FooBarBaz = Foo:compose(Bar, Baz)
-- or, equivalent:
local FooBarBaz = Bubble:compose(Foo, Bar, Baz)
-- or, equivalent:
local FoobarBaz = Bubble(Foo, Bar, Baz)
By default, methods from later-composed Bubbles override methods from earlier-composed ones. But Bubble provides a Collision
utility Bubble which lets you override this behavior.
By composing with a Bubble created with Collsion.forbidCollision
, you can explicitly define method names for which you want to forbid overriding. If you then compose with another Bubble that has methods that share those names, an error will be thrown.
local Bubbles = require(path.to.Bubbles)
local Bubble = Bubbles.Bubble
local Collision = Bubbles.Collision
local Example = Collision.forbidCollision({"foo"}):methods({
foo = function() end;
})
-- Attempting to override `foo` will now cause an error:
Example:methods({
foo = function() end; -- errors
})
If you mark specific method names as deferred, identically-named methods will be collected and wrapped into a single method.
This is most useful for generalized cleanup methods, such as Destroy
!
local Example = Collision.deferCollision({"Destroy"}):methods({
Destroy = function()
-- Destroy things...
return 1
end
})
local Example2 = Example:compose({
methods = {
Destroy = function()
-- destroy more things...
return 2
end
}
})
local example2 = Example2.new()
print(example2:Destroy()) --> 2 1
When you defer multiple methods into one, their return values are captured and returned as a tuple from the method, in inverse order of composition. Only the first return value will be captured from each function.
Often, when using Bubbles, you may be creating a Bubble that is expecting to be composed with another. In situations like this, you may rely on fields from self
that aren't present in your individual Bubble, but will be once composed.
For ease of debuggability, a Required
utility Bubble is provided, which allows you to mark certain fields as required before the Bubble can be instantiated without error.
The Required.require
function expects a descriptor-like object, structured like a Bubble descriptor, except with true
as values when a field is required.
local Bubbles = require(path.to.Bubbles)
local Bubble = Bubbles.Bubble
local Required = Bubbles.Required
local FooRequired = Required.require({
methods = {
foo = true; -- Require the "foo" method before this Bubble can be instantiated.
}
}):methods({
bar = function()
return self:foo() -- We rely on the `foo` method here.
end
})
-- Attempting to instantiate with `FooRequired.new()` right now will raise an error.
-- Elsewhere...
local HasFoo = FooRequired:compose({
methods = {
foo = function()
return 5
end
}
})
local hasFoo = HasFoo.new() -- This now works without error.
Composers are special hook functions which can influence composition. The Collision and Required utility Bubbles are implemented using composers. Composers are a very powerful and dangerous feature, so you should only use them when necessary.
Composers are stored just like the other metadata types. Each composer stored inside of a Bubble is ran in sequence (in order of composition) as the last step of a :compose
call (also including helper methods, as they use :compose
internally).
Composers receive two arguments: the newly-composed Bubble, and an array of composables which were used to create that Bubble.
Important note: composables may be either an entire Bubble or just a descriptor. You can check with compose = bubble.compose or bubble
.
It is recommended that you mutate the first bubble argument. However, if needed, you can return an entirely new value, which will replace the bubble as the result of the composition.
local composed = Bubble():composers({
function(bubble, composables)
return false
end
}):compose(Bubble)
print(composed == false) --> true
Take a look at the source code for more details on composers by checking out the Collision and Required Bubbles.
- As Bubbles are directly merged together, it is very important that you use descriptive field names. You should be doing this anyway, but it's doubly important when using Bubbles.
- For example, instead of naming a method
get
, you should be specific and include what you're getting in the method name, e.g.getCharacter
.
- For example, instead of naming a method
- All
Bubble.compose
tables share the same metatable. You can use this to detect if an object is a Bubble. As a convenience, anisBubble
function is included out of the box inside of theUtil
namespace, exported from the module. - There is no out-of-the-box method to check if a Bubble has been composed with another Bubble. This is by design: it is preferred that you use duck typing when checking if an object is valid rather than relying on inheriting from one specific object. This makes your code more robust, because it allows you flexibility of switching out different implementations of objects that serve the same purpose. Duck typing is powerful and largely failsafe when using descriptive field names.
Bubbles is inspired by a similar library for JavaScript: stampit. Bubbles are essentially my take on Stamps, adapted especially for Lua.