Recently I've implemented a built-in system for creating custom events. The implementation is based on the signals and slots design. Normally, this system can be found in high level GUI libraries because it makes it easy to deal to events from the GUI widgets. However, it can be quite useful in other situation as well.
The purpose of this implementation is to help improve the isolation of shared snippets and/or sub components of a script. And at the same time improve interaction between them. Without any of them having to know about each other or having to touch their code. So how does this work?
Well, the whole thing starts with you creating a Signal.
// Named signal which can be accessed from anywhere by name
SqCreateSignal("MySignal");
// Anonymous signal, must have access to the instance to use it
MySignal = SqCreateSignal();
As you can see I can make my signal globally available by giving it a name. To access a named signal anywhere within the code I can search it by name with the `SqSignal` function which returns the instance or throws an error if it doesn't exist:
SqSignal("MySignal").Something();
The signal can also be anonymous, which means that you can use it as a member of a class and therefore act as a localized event:
class Test
{
onMySignal = null
function constructor()
{
onMySignal = SqCreateSignal();
}
}
Now when I create instances of `Test`, each signal will be unique to each instance of that class.
To remove a named signal you ca use the `SqRemoveSignal` function:
SqRemoveSignal("MySignal");
To remove an anonymous signal you simply release all references to it and cleans itself up:
MySignal = SqCreateSignal();
//...
MySignal = null; // Cleans itself up if this was the last reference
Alright, so I've created my signal (
synonymous to creating your own event). But how do I tell it which functions to call? Well, you connect them to the signal. Simply using the `Connect` method from the signal.
We can do it for the named signals:
function A()
{
print("A");
}
SqSignal("MySignal").Connect(this, A);
That anonymous signal we made:
function B()
{
print("B");
}
MySignal.Connect(this, B);
And also that signal from the class we made, after we make some instances:
class Test
{
onMySignal = null
function constructor()
{
onMySignal = SqCreateSignal();
}
}
local test1 = Test();
local test2 = Test();
function C()
{
print("C");
}
test1.onMySignal.Connect(this, C);
test2.onMySignal.Connect(this, C);
As you can see, you must specify an environment in which the function will be called. Can be your current environment by using `this`, can be a class instance, a class declaration, and basically anything that can be used as a table which means it can be a table as well. If you want the default environment, then specify `null`. And it'll default to the root table.
NOTE: You can connect as many functions as you want to a single Signal. Once connected to a signal, a function cannot be connected again with the same environment. Meaning all connected functions are unique. You can connect a function multiple times but it must have a different environment. Same goes with the environments, you can connect them multiple times, but with different functions.
INFO: Once connected to the signal, a function is called a slot. A slot is actually a unique combination between a function and an environment. That combination will always point to the same slot. A slot that will receive signals. And that's how I'll refer to them from now on.
Let me put it in a simple example which you can relate to. You know how table's work, right? You have a key/index which points to a value.
Well, let's say that you have some strings "X", "Y", "Z". And let's imagine they're environments. And now let's have some other strings "A", "B", "C". And let's imagine they're functions. You got that so far?
local x = "X", y = "Y", z = "Z";
local a = "A", b = "B", c = "C";
Alright, now let's combine environment "Y" with function "C". This will give us a string "YC". And let's think of that as our slot and try to set a value to it into a table.
local signal = {};
signal[y+c] = 32;
Now, whenever I use that combination. It'll always refer to the same element in the table.
print( signal[y+c] ); // Output: 32
signal[y+c] = 88;
print( signal[y+c] ); // Output: 88
That combination between "Y" and "C" will always yield the same value. And that's why a slot will always be unique and cannot exist multiple times in the same signal.
As soon as I combine the function or environment with something else. They'll become and entirely different slot. I hope this explains why the combination between a function and an environment is called a slot and will always be unique.
Internally, this uses the address in memory (
which will always be unique) where the function and environment is stored. That's why they'll always be unique.
Alright, so I've told the signal which functions to call. But how do I tell the signal which functions to not call anymore? Same as connecting them. Except you use the `Disconnect` method.
We can do it for the named signals:
function A()
{
print("A");
}
SqSignal("MySignal").Disconnect(this, A);
That anonymous signal we made:
function B()
{
print("B");
}
MySignal.Disconnect(this, B);
And also that signal from the class we made, after we make some instances:
class Test
{
onMySignal = null
function constructor()
{
onMySignal = SqCreateSignal();
}
}
local test1 = Test();
local test2 = Test();
function C()
{
print("C");
}
test1.onMySignal.Disconnect(this, C);
test2.onMySignal.Disconnect(this, C);
NOTE: You must specify the exact environment that you've used to connect it. Remember, all connected functions become unique in the signal and the environment contributes to that uniqueness.
To disconnect a all functions that use a particular environment we can use the `EliminateThis` method:
function A()
{
print(" A ");
}
function B()
{
print(" B ");
}
function C()
{
print(" C ");
}
SqSignal("MySignal").Connect(this, A);
SqSignal("MySignal").Connect(this, B);
SqSignal("MySignal").Connect(this, C);
SqSignal("MySignal").EliminateThis(this);
Now all three functions were disconnected because they all used the same environment.
To disconnect all occurrences of a function regardless of the environment we can use the `EliminateFunc` method:
class Test
{
function A()
{
print("A");
}
}
local test1 = Test();
local test2 = Test();
local test3 = Test();
SqSignal("MySignal").Connect(test1, Test.A);
SqSignal("MySignal").Connect(test2, Test.A);
SqSignal("MySignal").Connect(test3, Test.A);
SqSignal("MySignal").EliminateFunc(Test.A);
Now all occurrences of the `A` functions were removed. It didn't matter they all used a different instance as the environment.
To remove all slots connected to a signal you can use the `Clear` method.
To see how many occurrences of an environment exist in a slot regardless of the function you can use the `CountThis` method. Has the same syntax as the `EliminateThis` method except it only returns the number of occurrences and does not remove anything.
To see how many occurrences of a function exist in a slot regardless of the environment you can use the `CountFunc` method. Has the same syntax as the `EliminateFunc` method except it only returns the number of occurrences and does not remove anything.
To find out how many slots were connected in total to a signal you can use the `Slots` property:
function A()
{
print("A");
}
function B()
{
print("B");
}
function C()
{
print("C");
}
SqSignal("MySignal").Connect(this, A);
SqSignal("MySignal").Connect(this, B);
SqSignal("MySignal").Connect(this, C);
print(SqSignal("MySignal").Slots); // Output: 3
Alright, so I can connect them, I can disconnect them, I can count them. But, how do I call them? Well there are several ways of doing it.
The most simple way is the `Emit` method. You simply call the `Emit` with the parameters that you want to send to the slots:
function A(a, b)
{
print(a + " A " + b);
}
function B(a, b)
{
print(a + " B " + b);
}
function C(a, b)
{
print(a + " C " + b);
}
SqSignal("MySignal").Connect(this, A);
SqSignal("MySignal").Connect(this, B);
SqSignal("MySignal").Connect(this, C);
SqSignal("MySignal").Emit("letter", "is nice");
This should output:
letter C is nice
letter B is nice
letter A is nice
There's an alias for the `Emit` method which is called `Broadcast`. If that makes more sense in the code. Meaning you broadcast a signal. Pretty much the same thing.
But what if the slots return some values and you want to do something with them? Well, for that situation we have the `Query` method. Basically that means you want to query/interrogate the slots and they must reply with something:
function A(a, b)
{
return a + b;
}
function B(a, b)
{
return a * b;
}
function C(a, b)
{
return a - b;
}
function D(a, b)
{
return a / b.tofloat();
}
SqSignal("MySignal").Connect(this, A);
SqSignal("MySignal").Connect(this, B);
SqSignal("MySignal").Connect(this, C);
SqSignal("MySignal").Connect(this, D);
SqSignal("MySignal").Query(this, function(result) {
print(result);
}, 3, 7);
We specified a function which will receive the returned value of each slot and an environment in which to run that function. Then we specified the parameters we want to forward like we did with the `Emit` function.
The output should be:
The next way of invoking the slots allows slots to prevent other slots from receiving the signal. This is achieved through the `Consume` method. Basically, allows one slot to consume the event while the remaining ones are ignored:
function A(a, b)
{
if (a != b)
{
print("A consumed the event");
return true;
}
else print("A ignored the event");
}
function B(a, b)
{
if (a == b)
{
print("B consumed the event");
return true;
}
else print("B ignored the event");
}
function C(a, b)
{
if (a < b)
{
print("C consumed the event");
return true;
}
else print("C ignored the event");
}
function D(a, b)
{
if (a > b)
{
print("D consumed the event");
return true;
}
else print("D ignored the event");
}
SqSignal("MySignal").Connect(this, A);
SqSignal("MySignal").Connect(this, B);
SqSignal("MySignal").Connect(this, C);
SqSignal("MySignal").Connect(this, D);
if (SqSignal("MySignal").Consume(5, 5))
{
print("The signal was consumed");
}
else
{
print("The signal was not consumed");
}
This works the same as the `Emit` method. Except the return value from the slots is no longer ignored. This time, the first slot that returns a value which can be evaluated to true (
not necessarily a boolean) is considered to have consumed the signal and therefore ignore the others.
If the event was consumed, the `Consume` method returns true otherwise it returns `false`. So you know whether your signal was consumed or not.
The output should be:
D ignored the event
C ignored the event
B consumed the event
The signal was consumed
All slots are called in the reverse order in which you add them. Meaning, the last connected slot is called first.
Accompanying the `Connect` method there is the `Head` and `Tail` methods. They work very similarly to the `Connect` method. With a few differences.
The `Head` method tries to connect a slot to the front of the list (same as `Connect`). If the slot does not exist, then it will be created at the top of the list. If the slot already exists, then it will be moved to the top of the list. Something which the `Connect` method does not do. When the slot is at the top of the list, it will be the first to receive the signal.
The `Tail` method has the same behavior as the `Head` method. But like the name implies, it creates or moves the slot to the back of the list. Meaning it'll be the last one to receive the signal.
Here's an example:
SqCreateSignal("MySignal");
function A(a, b)
{
print(a + " A " + b);
}
function B(a, b)
{
print(a + " B " + b);
}
function C(a, b)
{
print(a + " C " + b);
}
function D(a, b)
{
print(a + " D " + b);
}
SqSignal("MySignal").Connect(this, A);
SqSignal("MySignal").Connect(this, B);
SqSignal("MySignal").Connect(this, C);
SqSignal("MySignal").Connect(this, D);
// Move B to the front/hed
SqSignal("MySignal").Head(this, B);
// Move C to the back/tail
SqSignal("MySignal").Tail(this, C);
SqSignal("MySignal").Emit("letter", "is nice");
In this example, even though `B` and `C`are in the middle. We moved `B` to the front and `C` to the back.
The output should be:
letter B is nice
letter D is nice
letter A is nice
letter C is nice
The order in which the signals are received can be important sometimes. Which is why these methods can come in handy.
Anyway, this is a very efficient and very flexible way of introducing custom events in your scripts without having to deal with managing them or worrying about performance. The implementation is still fresh but in my tests it came out to be quite stable. There are still some things that I'd like to implement but so far it does a good job at what it's supposed to do.
Binary Status: available