Signals and Delegates

Callback mechanisms for event handling have become ubiqitous in todays application frameworks. Older examples are the use of function pointers as callbacks or the message maps found in the MFC toolkit. More modern approaches include the .NET delegates and so-called signal-slot techniques. When signals and slots are used, objects can communicate with each other by connecting a signal of one object to the slot of another object. In most cases connection management features are built-in so that an object closes all it connections automatically when it gets destroyed. Once the connection has been established, all connected slots are called when a signal is send. Connecting signals to slots is type-safe i.e. a signal can only be connected to a slot that matches the signal's signature. At the same time it allows a great deal of flexibility (loose coupling), since the caller has no intimite knowledge of the callee. A simple but real example might look like this:

int main()
{
timer.setActive( app.loop() );
timer.start(1000);
timer.timeout() += Pt::slot(app, &Pt::System::Application::exit);
return app.run();
}

This program will simply exit, when the timer expires after 1000 ms. The application object is the callee and the member function Application::exit serves as a slot. The timer is the caller, which has a signal called timeout.

Signals

Signals are normally members of objects and are being sent e.g. When the object state changes or some event occurs. When a signal is sent, it calls all slots it is connected to. Callable entities, like functions or member functions can serve as slots for signals. The template parameter list of the Pt::Signal class template determines the signature of the signal. If a signal does not have any arguments the parameter list is left empty:

Pt::Signal<> sig0; // Signal without arguments
Pt::Signal<int> sig1; // Signal with one argument
Pt::Signal<int, int> sig2; // Signal with two arguments

Slots can be constructed with the slot() function, which is overloaded for various types of callable entities, most notably functions or member functions. Slots are lightweight proxy-objects and one example is the Pt::MethodSlot, which allows to use a member function as a slot.

A signal can be connected to a slot if the signatures are compatible. One important feature of Pt::Signal is that the return value of a slot is ignored and therefore a slot is compatible to a signal no matter what type it returns. The following code example shows how a signal is connected to a function and a member function:

class Callee : public Pt::Connectable
{
public:
void slot()
{ std::cout << "Callee::slot() called" << std::endl; }
};
void slot()
{ std::cout << "slot() called." << std::endl; }
int main()
{
Callee callee;
Pt::Signal<> signal;
signal += Pt::slot(slot);
signal += Pt::slot(callee, &Callee::slot);
return 0;
}

Two slots are constructed, one from a function pointer and another one from a member function pointer and the object instance to be called. The signal is connected to both slots. Signals can only be connected to objects that derive from Pt::Connectable, to ensure that all connections are closed when the object runs out of scope and no dangling connections are left. The += operator, to connect a signal with a slot, returns a connection object, which can be used to disconnect signals from slots manually. The following code illustrates this:

void slot()
{ std::cout << "slot() called." << std::endl; }
int main()
{
Pt::Signal<> signal;
Connection c = signal += Pt::slot(slot);
c.isValid() // returns true
c.close();
c.isValid() // returns false
return 0;
}

A connection is reference counted and can not be duplicated as such, but always refers to the same shared connection data. If one peer of a connection is destroyed or the connection is closed manually, the connection becomes invalid. Once a connection has been established, signals can be send to invoke the connected slots. This happens by calling send() with the appropriate arguments, if any.

void tellAge(int age)
{ std::cout << "I am " << age << " years old\n"; }
int main ()
{
signal += Pt::slot(tellAge);
signal.send(26);
return 0;
}

When the signal is send, the slot is called with the same value passed to Signal::send. Nothing will happen if the signal is not connected to any slots. When a signal is sent, the slot is called immediatly and directly and does not depend on an event loop. If multiple slots are connected to a signal, the slots will be called one after another.

Delegates

The Pt::Delegate is an alternative to the Pt::Signal and differs from it in two ways. Firstly, delegates forward the return value of the slot and secondly, delegates can only be connected to one slot at a time. The same types of slots can be used for signals and delegates. The template parameter list of the Delegate determines its signature, where the first parameter represents the return type:

Pt::Delegate<int> del0; // Delegate only returns int
Pt::Delegate<int, int> del1; // Delegate with one argument
Pt::Delegate<int, int, int> del2; // Delegate with two arguments

The syntax for connecting delegates is identical to how signals are connected to slots. However, since a delegate forwards the return value of its slot, not only the arguments passed to the slot must be compatible, but also the return value. Furthermore, when an already connected delegate is connected again, the current connection will be closed and the delegate is connected to its new target.

int slotA()
{ return 5; }
int slotB()
{ return 6; }
int main()
{
delegate += Pt::slot(slotA);
delegate += Pt::slot(slotB); // disconnects from slotA
return 0;
}

The example above constructs a delegate which can be connected to any slot that returns an int. It is first connected to a slot of the function slotA. When it is connected for the second time to a slot of the function slotB, the old connection will be closed and only slotB is called when the delegate is called.

There are two possibilities how a delegate can call its slot. The member function call() will return the return value of the slot. If the delegate is not connected to a slot, an exception is thrown. The second method is through invoke(), where the return value is ignored, but if the delegate is not connected, no exception will be thrown.

int slot()
{ return 5; }
int main()
{
try
{
Pt::Connection connection = delegate += Pt::slot(slot);
int i = delegate.call(); // i is 5 now
connection.close();
delegate.invoke() // does not throw
delegate.call(); // will throw because not connected
}
catch(const std::logic_error& ex)
{
std::cerr << "could not call delegate" << std::endl;
}
return 0;
}

The program above connects a delegate to a slot and then calls it. After the connection was closed, the delegate is invoked, which has no effect. Finally, when the delegate is called again, an exception is thrown and catched.