From Boost.Signals2
Boost.Signals2 and jai::signal share a model: a typed multicast event, callables connected to it, every receiver run in connection order when it fires. Most of a migration is spelling.
Three defaults differ:
- only the owner of a private emitter can emit;
- a connection lives exactly as long as someone holds its handle;
- an emitter takes no lock unless its type says so, and then the lock is held while receivers run.
The framework is documented in Signals & Observable Properties. The last section has the notes for Qt and sigslot code.
The concept map
Headers: <jaiscript/signals/signal_decl.hpp> where signal members are only declared, <jaiscript/signals/signal.hpp> in a translation unit that emits (an emit instantiates the script-receiver path, which that header defines), and <jaiscript/signals/receiver_owner.hpp> for receiver_owner.
| Boost.Signals2 | JaiScript |
|---|---|
boost::signals2::signal<void(int)> onOpen;, a public member | a private jai::signal_emitter<void(int)> onOpenSignal; and a public jai::signal<void(int)> onOpen; built from it in the constructor (onOpen(onOpenSignal)); in a property_owner, JAI_SIGNAL_PROPERTY(void(int), onOpen) declares the pair |
onOpen(42);, from anywhere | onOpenSignal(42); or onOpenSignal.emit(42);, from the owner |
boost::signals2::connection c = onOpen.connect(f); | auto c = onOpen.connect(f);, a jai::signal<void(int)>::shared_receiver_type (a std::shared_ptr to the receiver), [[nodiscard]] |
c.disconnect(); | c = nullptr; or onOpen.disconnect(c); |
c.connected() | c->connected() |
boost::signals2::scoped_connection | the default: the handle disconnects when it is destroyed |
slot_type(f).track(p), .track_foreign(p) | the tracked object holds the handle: a member, or a jai::receiver_owner (built into every property_owner as track(...)) |
boost::signals2::shared_connection_block block(c); | c->block(); ... c->unblock(); (counted, so blocks nest); block() / unblock() on the emitter blocks every receiver |
onOpen.disconnect_all_slots() | onOpenSignal.clear() |
onOpen.num_slots(), onOpen.empty() | onOpen.observer_count(), onOpen.observer_count() == 0 |
onOpen.disconnect(f) by slot, onOpen.disconnect(group) | by handle, onOpen.disconnect(c), or by name: onOpen.connect("hud", f) and onOpen.disconnect("hud") |
groups, at_front / at_back | no equivalent: receivers run in the order they connected |
a combiner; the default returns an optional of the last slot's result | no equivalent: emit returns nothing. Receivers write into an accumulator passed by reference, and a bool signature has emit_while (Combiners) |
| a slot connected during an emit, first called by the next emit | the same by default; onOpen.connect_immediate(f) also joins the emits already running |
connect_extended, whose slot receives its own connection | the callback captures a variable that holds its handle; connect_oneshot(f) for a receiver that fires once |
keywords::mutex_type<dummy_mutex> for one signal type | the default: jai::signal_emitter<void(int)> takes no lock. jai::signal_emitter<void(int), jai::signal_synchronized> with a jai::signal<void(int), jai::signal_synchronized> facade (or JAI_SYNCHRONIZED_SIGNAL_PROPERTY) locks (Threads) |
boost::signals2::deconstruct, postconstructor | not needed: the object that connects holds the handle, so nothing has to run after construction |
A class converted
A door that announces how far it opened, a HUD that listens while it exists, and a log that listens until it is told to stop:
#include <boost/signals2.hpp>
class Door {
public:
boost::signals2::signal<void(int)> onOpen;
void open(int width) { onOpen(width); }
};
class Hud {
public:
explicit Hud(Door& door) {
connection = door.onOpen.connect([this](int width) { show(width); });
}
void show(int width) { std::cout << "HUD: door opened to " << width << "\n"; }
private:
boost::signals2::scoped_connection connection;
};
Door door;
boost::signals2::connection logged = door.onOpen.connect([](int width) { std::cout << "log: " << width << "\n"; });
{
Hud hud(door);
door.open(42);
}
door.open(7);
logged.disconnect();
door.open(1);In JaiScript, Hud changes only the type of its member, to jai::signal<void(int)>::shared_receiver_type:
#include <jaiscript/signals/signal.hpp>
class Door {
private:
jai::signal_emitter<void(int)> onOpenSignal;
public:
jai::signal<void(int)> onOpen;
Door() : onOpen(onOpenSignal) {}
void open(int width) { onOpenSignal(width); }
};
Door door;
auto logged = door.onOpen.connect([](int width) { std::cout << "log: " << width << "\n"; });
{
Hud hud(door);
door.open(42);
}
door.open(7);
logged = nullptr;
door.open(1);log: 42
HUD: door opened to 42
log: 7The emitter is declared before the facade so it is constructed first. door.onOpen(5) outside Door does not compile.
Tracking and blocking
Boost ties a connection to an object by having the slot watch it: track (or track_foreign for a std::shared_ptr) ends the connection when the object expires and keeps the object alive while its slot runs. In JaiScript the object holds its handles, and its death ends them. One jai::receiver_owner holds receivers of any signature:
auto panel = std::make_shared<Panel>();
using slot_type = boost::signals2::signal<void(int)>::slot_type;
door.onOpen.connect(slot_type([p = panel.get()](int width) { p->show(width); }).track_foreign(panel));#include <jaiscript/signals/receiver_owner.hpp>
class Panel {
public:
void watch(Door& door) { connections.track(door.onOpen.connect([this](int width) { show(width); })); }
void show(int width) { std::cout << "panel: " << width << "\n"; }
private:
jai::receiver_owner connections;
};
auto panel = std::make_shared<Panel>();
panel->watch(door);
door.open(3); // panel: 3
panel.reset(); // the receivers die with the panel
door.open(4); // nothing; observer_count() is 0A receiver stays alive for the length of its own call, but nothing pins an object its callback captured: a callback that can outlive what it captures holds a std::weak_ptr and locks it.
A shared_connection_block is a scope; a receiver's block() and unblock() are calls, counted the way Boost counts blocks. The emitter has its own pair: unblock() returns true when the signal fired while it was blocked, and set_blocked_callback(f) runs f in place of the receivers during a block.
Combiners and return values
A JaiScript signature may return a value, but emit and operator() return nothing: each receiver's result is discarded. Boost hands the results to a combiner, and the default returns an optional holding the last slot's result:
boost::signals2::signal<int(int)> modifiers;
modifiers.connect([](int base) { return base + 2; });
modifiers.connect([](int base) { return base * 3; });
boost::optional<int> last = modifiers(10); // 30A result every receiver contributes to travels as a reference parameter:
jai::signal_emitter<void(int, int&)> modifiers;
auto add = modifiers.connect([](int base, int& total) { total += base + 2; });
auto triple = modifiers.connect([](int base, int& total) { total += base * 3; });
int total = 0;
modifiers(10, total); // total is 42A veto is the one built-in aggregate. emit_while, available when the signature returns bool, stops at the first receiver that returns false and returns false, otherwise true (a blocked signal returns true without running any). In Boost that is a combiner that stops iterating at the first false.
Behavior that changes
Only the owner fires
Whoever can reach a Boost signal can both connect and call it, so a class that keeps emission to itself keeps the signal private and writes a forwarding connect. JaiScript splits the roles into two types: signal_emitter<Sig> connects and emits, and signal<Sig> is a facade over a reference to an emitter with only the connecting half (connect, connect_oneshot, disconnect, connected, observer_count). Script holds the same line: a facade read from script has no emit, and script fires a signal only when the host binds the emitter itself.
The facade holds a reference, so a copy of it is valid only while the owning object lives.
The handle is the connection
connect returns a shared_ptr to the receiver, and the emitter keeps only a weak reference. Reset the pointer, or let the member holding it die, and the receiver stops firing; the emitter drops the dead entry after its next emit.
A Boost connection outlives its connection handle until someone calls disconnect or the signal is destroyed, so Boost code often ignores what connect returns. Here a discarded handle disconnects at once and the receiver never runs, which is why the unnamed connect overloads are [[nodiscard]].
A connection that should belong to the signal, as a Boost connection does, is a named connection:
connect("id", f)returns a handle nobody has to keep; the connection lasts untildisconnect("id"),clear()or the emitter's death.- Connecting again under an id already connected takes the name over: the earlier receiver stops firing unless C++ still holds its handle.
- Script connects only this way.
- If the emitter dies first, a handle stays safe to hold and its
connected()reportsfalse, as a Boostconnectiondoes.
Connecting and disconnecting during an emit
The libraries agree by default. An emit runs the receivers connected when it started, in order, and skips each one that has left by the time it reaches it:
- A receiver connected during an emit runs from the next emit on. An emit a receiver starts after the connect (a re-emit of the same signal) is a new emit and runs the new receiver; the emit that was running does not. A receiver that connects another every time it fires therefore cannot keep an emit going.
- A receiver disconnected during an emit does not run later in it, however it left:
disconnect(c),disconnect("id"),clear(), its last handle dropped (a connect that takes a name over drops the emitter's handle to the receiver it replaces), or a connect of the same receiver again, which moves it to the end of the list as a new connection.
connect_immediate has no Boost counterpart. It takes the same arguments as connect (a callable, script text, a receiver handle, or a name with any of those), and the receiver also joins every emit already running, the one in progress and each emit it is nested in, after the receivers those emits started with. Script connects with plain connect. A receiver may connect, disconnect or re-emit the same signal while it fires.
Receivers that throw
The libraries agree: an exception from a receiver propagates out of the emit and the receivers after it do not run. That holds for script receivers too, and the emitter stays consistent as the exception unwinds, so the emit site can catch it and fire again (When a receiver fails).
Threads
Boost.Signals2 locks internally, so connect, disconnect and invocation can run on different threads at once, and it runs slots without holding the signal's own lock. A JaiScript emitter's threading is part of its type, chosen per signal as Boost's mutex_type is, with the opposite default:
signal_emitter<Sig>takes no lock and one thread at a time uses it: Boost'sdummy_mutex.signal_emitter<Sig, jai::signal_synchronized>holds one recursive mutex through every call and the whole of an emit, receivers included (Threads has the full guarantee).
A Boost signal that several threads use becomes a synchronized emitter, whose emits from different threads run one after another. A receiver must not wait on another thread that is connecting to or emitting the same signal: Boost code that does this without deadlocking deadlocks here.
What Boost has no counterpart for
A receiver can be script: source text or a script function, connected from C++ or from script, and it keeps working across hot reload (Script receivers and hot reload).
A signal declared with JAI_SIGNAL_PROPERTY saves its script-text receivers with the object that owns it and reconnects them on load (Connections that save), so a host reconnects only its C++ callbacks after a load. A saved receiver is script source that runs on the next emit: load an object with signal properties only from a trusted source, or on an engine under the safety rails.
From Qt and sigslot libraries
| Qt | JaiScript | |
|---|---|---|
| Declared as | a signals: member function that moc implements, on a QObject | a private signal_emitter<Sig> and a public signal<Sig> built from it |
| Who can emit | anyone who can reach the object; keeping emission inside the class is a convention | the owner of the emitter |
connect returns | a QMetaObject::Connection handle; dropping it leaves the connection in place | shared_receiver_type; dropping it disconnects |
| Tied to a receiving object | automatic: destroying the sender, the receiver or a functor's context object disconnects; a functor with no context object lives as long as the sender | the object holds the handle (a member, receiver_owner, track(...)) |
| Script receivers | JavaScript handlers through the QML engine, a separate mechanism from C++ slots | script text and script functions on the same emitter as C++ receivers |
| Saved with the object | no | script-text receivers, named and unnamed (JAI_SIGNAL_PROPERTY) |
| A receiver throws | undefined behavior unless the slot catches it | out of the emit; later receivers do not run |
| Threads | direct connections run on the emitting thread; a queued connection copies the arguments into an event and runs the slot from the receiver thread's event loop, and the default automatic connection queues when the receiver lives in another thread | receivers run on the emitting thread, with no queued delivery: a host that emits from a worker and wants receivers on its main thread hands the emit over itself. Locking is chosen per signal (Threads) |
| Return values | signals are documented as void | none from emit; emit_while stops at the first false |
Sigslot libraries. Lightweight header-only sigslot libraries usually take Boost's shape with fewer features: a signal object anyone can emit, connect returning a connection handle (often with a scoped variant), lifetime tracking through an observer base class or a weak pointer, and thread safety chosen by a template or a compile-time switch. Where this page says Boost, read that family too: an observer base class becomes a receiver_owner member, and a scoped connection becomes the plain handle.