This document describes the rationale behind some of the major design decisions made for the Boost.Signals library.
The definition of a slot differs amongst signals and slots libraries. Within Boost.Signals, a slot is defined in a very loose manner: it can be any function object that is callable given parameters of the types specified by the signal, and whose return value is convertible to the result type expected by the signal. However, alternative definitions have associated pros and cons that were considered prior to the construction of Boost.Signals.
Slot
abstract class
that defines a virtual function calling the slot. Adaptors can be
used to convert a definition such as this to a definition similar
to that used by Boost.Signals, but in this case the original
specification ties the implementation to the use of virtual
functions internally. This approach does have the benefit of
simplicity of implementation and user interface, from an
object-oriented perspective.Users not satisfied with the slot definition choice may opt to replace the default slot function type with an alternative that meets their specific needs.
Users need to have fine control over the connection of signals
to slots and their eventual disconnection. The approach taken by
Boost.Signals is to return a connection
object
that enables connected/disconnected query, manual disconnection, and
an automatic disconnection on destruction mode. Some other possible
interfaces include:
sig.connect(slot)
is performed via
sig.disconnect(slot)
. Internally, a linear search
using slot comparison is performed and the slot, if found, is
removed from the list. Unfortunately, querying connectedness will
generally also end up as linear-time operations. This model also
fails for implementation reasons when slots become more complex
than simple function pointers, member function pointers, and a
limited set of compositions and argument binders it is tough to
rely on comparison of function objects because arbitrary function
objects are not comparable.new
and delete
for dynamic memory
allocation. While errors of this sort would not be catastrophic
for a signals and slots implementation, their detection is
generally nontrivial. This type of interface is supported in Boost.Signals via the
named connections mechanism. It augments the connection
object-based connection management scheme.
The Combiner interface was chosen to mimic a call to an algorithm in the C++ standard library. It is felt that by viewing slot call results as merely a sequence of values accessed by input iterators, the combiner interface would be most natural to a proficient C++ programmer. Competing interface design generally required the combiners to be constructed to conform to an interface that would be customized for (and limited to) the Signals library. While these interfaces are generally enable more straighforward implementation of the signals & slots libraries, the combiners are unfortunately not reusable (either in other signals & slots libraries or within other generic algorithms), and the learning curve is steepened slightly to learn the specific combiner interface.
The Signals formulation of combiners is based on the combiner using the "pull" mode of communication, instead of the more complex "push" mechanism. With a "pull" mechanism, the combiner's state can be kept on the stack and in the program counter, because whenever new data is required (i.e., calling the next slot to retrieve its return value), there is a simple interface to retrieve that data immediately and without returning from the combiner's code. Contrast this with the "push" mechanism, where the combiner must keep all state in class members because the combiner's routines will be invoked for each signal called. Compare, for example, a combiner that returns the maximum element from calling the slots. If the maximum element ever exceeds 100, no more slots are to be called.
Pull | Push |
---|---|
struct pull_max { typedef int result_type; template<typename InputIterator> result_type operator()(InputIterator first, InputIterator last) { if (first == last) throw std::runtime_error("Empty!"); int max_value = *first++; while(first != last && *first <= 100) { if (*first > max_value) max_value = *first; ++first; } return max_value; } }; |
struct push_max { typedef int result_type; push_max() : max_value(), got_first(false) {} // returns false when we want to stop bool operator()(int result) { if (result > 100) return false; if (!got_first) { got_first = true; max_value = result; return true; } if (result > max_value) max_value = result; return true; } int get_value() const { if (!got_first) throw std::runtime_error("Empty!"); return max_value; } private: int max_value; bool got_first; }; |
There are several points to note in these examples. The "pull"
version is a reusable function object that is based on an input
iterator sequence with an integer value_type
, and is
very straightforward in design. The "push" model, on the other hand,
relies on an interface specific to the caller and is generally
reusable. It also requires extra state values to determine, for
instance, if any elements have been received. Though code quality
and ease-of-use is generally subjective, the "pull" model is clearly
shorter and more reusable and will often be construed as easier to
write and understand, even outside the context of a signals &
slots library.
The cost of the "pull" combiner interface is paid in the implementation of the Signals library itself. To correctly handle slot disconnections during calls (e.g., when the dereference operator is invoked), one must construct the iterator to skip over disconnected slots. Additionally, the iterator must carry with it the set of arguments to pass to each slot (although a reference to a structure containing those arguments suffices), and must cache the result of calling the slot so that multiple dereferences don't result in multiple calls. This apparently requires a large degree of overhead, though if one considers the entire process of invoking slots one sees that the overhead is nearly equivalent to that in the "push" model, but we have inverted the control structures to make iteration and dereference complex (instead of making combiner state-finding complex).
Boost.Signals supports a connection syntax with the form
sig.connect(slot)
, but a more terse syntax sig +=
slot
has been suggested (and has been used by other signals
& slots implementations). There are several reasons as to why
this syntax has been rejected:
connect()
vs. +=
) is essentially negligible. Furthemore, one
could argue that calling connect()
is more readable
than an overload of +=
.
+=
operation: should it be a reference to the signal
itself, to enable sig += slot1 += slot2
, or should it
return a connection
for the
newly-created signal/slot connection?+=
, it seems natural to have a
disconnection operator -=
. However, this presents
problems when the library allows arbitrary function objects to
implicitly become slots, because slots are no longer comparable
(see the discussion on this topic in User-level Connection Management).
The second obvious addition when one has
operator+=
would be to add a +
operator
that supports addition of multiple slots, followed by assignment
to a signal. However, this would require implementing
+
such that it can accept any two function objects,
which is technically infeasible.
trackable
rationale The trackable
class is the primary user interface to automatic connection
lifetime management, and its design affects users directly. Two
issues stick out most: the odd copying behavior of
trackable
, and the limitation requiring users to
derive from trackable
to create types that can
participate in automatic connection management.
trackable
copying behavior The copying behavior of trackable
is essentially
that trackable
subobjects are never copied;
instead, the copy operation is merely a no-op. To understand
this, we look at the nature of a signal-slot connection and note
that the connection is based on the entities that are being
connected; when one of the entities is destroyed, the connection
is destroyed. Therefore, when a trackable
subobject
is copied, we cannot copy the connections because the
connections don't refer to the target entity - they refer to the
source entity. This reason is dual to the reason signals are
noncopyable: the slots connected to them are connected to that
particular signal, not the data contained in the signal.
trackable
? For trackable
to work properly, there are two
constraints:
trackable
must have storage space to keep
track of all connections made to this object.trackable
must be notified when the object is
being destructed so that it can disconnect its
connections.trackable
meets these two
guidelines. We have not yet found a superior solution.
libsigc++ is a C++ signals & slots library that originally started as part of an initiative to wrap the C interfaces to GTK libraries in C++, and has grown to be a separate library maintained by Karl Nelson. There are many similarities between libsigc++ and Boost.Signals, and indeed Boost.Signals was strongly influenced by Karl Nelson and libsigc++. A cursory inspection of each library will find a similar syntax for the construction of signals and in the use of connections and automatic connection lifetime management. There are some major differences in design that separate these libraries:
Microsoft has introduced the .NET Framework and an associated set of languages and language extensions, one of which is the delgate. Delegates are similar to signals and slots, but they are more limited than most C++ signals and slots implemetations in that they:
this
already bound