Using libsigc++ signals

From Inkscape Wiki
Revision as of 12:24, 4 February 2023 by PBS (talk | contribs) (→‎Memory safety: Fix Emitter -> Receiver)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search

This is a very short guide to using libsigc++ signals. They can make complex code with a lot of things to update after each change in some data structure very simple.

What are signals?

Signals are type-safe callbacks, essentially lists of function pointers on steroids. Each signal stores a list of slots. A slot defines what should be done when the signal is emitted. Typically an object emits the signal when it changes state. When a signal is emitted, the slots from the signal's slot list are executed, and sometimes a result is returned.

Note that libsigc++ signals have absolutely nothing to do with POSIX signals. They are however almost identical to Qt signals and slots.

Defining signals

Signals are typically defined as public attributes like this:

class Emitter
{
public:
   void increaseNumber(int by) {
      _number += by;
      signal_number_changed.emit(by);
   }
   
   int getNumber() const { return _number; }
   
   sigc::signal<void (int)> signal_number_changed;
   
private:
   int _number = 0;
};

std::ostream &operator<<(std::ostream &o, Emitter const &e) {
   o << e.getNumber();
   return o;
}

Several Inkscape classes use private signal attributes and define accessor methods for connecting slots, but this prevents you from using some advanced features. Wrapping them all in accessors isn't worth the hassle.

signal is a class template. It takes a function type parameter defining the types of parameters passed to each slot when the signal is emitted, and the type that the slot must return.

Connecting to signals

The signal class has a connect method, which takes a slot and adds it to the list of the signal's slots. Any function object can be used as a slot, including any lambda. Raw function pointers and member function pointers can also be used, provided they are wrapped as function objects using sigc::ptr_fun() and sigc::mem_fun() respectively. Here is a typical example:

class Receiver
{
public:
   Receiver(Emitter &emitter) {
      emitter.signal_number_changed.connect(
         sigc::mem_fun(*this, &Receiver::_handleNumberChange)
      );
   }
   
private:
   void _handleNumberChange(int increment) {
      std::cout << "The number increased by " << increment << std::endl;
   }
};

sigc::mem_fun() produces a function object that invokes a given method on the given object when it is called. Now whenever the number stored in the Emitter class changes, all Receiver objects created with this Emitter object will have the _handleNumberChange() method called.

Memory safety

The above example is only memory-safe if the lifetime of the Receiver can be guaranteed to end before the lifetime of the Emitter. Otherwise, there is nothing stopping the slot being invoked on a destructed Receiver. If this happens in our trivial example it will be harmless, but most of the time it will be a use-after-free, and a crash.

If you are used to Qt signals and slots, this will come as a surprise, because in Qt slots are automatically disconnected when objects are destroyed, making them memory-safe by default. There is no such guarantee in libsigc++; safety must instead be opted-into.

There are two ways to solve this problem.

Automatic disconnection

Make Receiver publically derive from sigc::trackable. Then use any of sigc::mem_fn(), sigc::track_obj() or sigc::track_object() to create slots. The slot will then be disconnected when the object is destroyed.

Here are some further comments and caveats with this approach:

  • If you forget to make make Receiver inherit from sigc::trackable, then sigc::mem_fn() and sigc::track_obj() will silently fall back to being unsafe, which makes code that uses them fragile. By contrast, sigc::track_object() will refuse to compile. However, at the time of writing, the superior sigc::track_object() cannot be used yet due to not being supported on all build targets.
  • Even with the above approach, signals will be disconnected during the destructor of the sigc::trackable base subobject, which happens after the destructor of the Receiver subobject. So it is entirely possible for a sufficiently complicated class hierarchy, written by many different authors and performing complex signal manipulations in its destructor, to invoke slots on destructed objects even when the best practices for sigc::trackable and sigc::track_obj() are followed. This is not an issue for simple classes, or for the approach of the next section.

Manual disconnection

The connect() function actually returns a sigc::connection object with a disconnect() method that can be used to disconnect the connection. We can use this to improve our example:

class Receiver
{
public:
   Receiver(Emitter &emitter) {
      _conn = emitter.signal_number_changed.connect(
         sigc::mem_fun(*this, &Receiver::_handleNumberChange)
      );
   }
   
   ~Receiver() { _conn.disconnect(); }
   
private:
   sigc::connection _conn;

   void _handleNumberChange(int increment) {
      std::cout << "The number increased by " << increment << std::endl;
   }
};

However, when working with sigc::connection there are still a number of traps that make it extremely easy to write bugs:

  • If you destroy a connection without disconnecting it, it will not automatically be disconnected.
  • Similarly, if you overwrite a connection with another one, the original will not automatically be disconnected.

These errors are some of the most frequent sources of use-after-frees in Inkscape. The problem is that sigc::connection behaves a lot like a raw pointer, with disconnect() being another way of spelling delete. What is needed is something that behaves more like a std::unique_ptr. For that reason, Inkscape has a RAII wrapper around sigc::connection called Inkscape::auto_connection that does not have these issues, and should be preferred wherever possible.

Adaptors

As you can see, we cannot directly access the object that emitted the signal from the handling method. You might think that changing the signal definition in the Emitter class to

sigc::signal<void (Emitter &, int)> signal_number_changed;

is the way to go. However, there are a few problems: first, the Emitter parameter for this slot is always the same (when you connect to another Emitter object from the same Receiver, you are creating a different slot stored in a different Emitter object). Moreover, it may be possible that some handlers only need the information about the number change - for instance, when you derive a class from Emitter and connect to the signals of the superclass, you already have the pointer to the superclass instance in this. There is a much better way to solve this problem. Another type of slots that libsigc++ provides are called adaptors - they allow you to change the type signature of your handler function by binding constant parameters to its invocation, ignoring some parameters supplied by the signal, and more.

Binding parameters

Let's say you want to print the name of the emitter that had its number increased. Instead of modifying the signal signature (a thing you sometimes cannot do, for example when you connect to a GTK-- signal), you can bind a parameter to your functor. First you need to modify the handler function:

void _handleNumberChange(Emitter &e, int by) {
   std::cout << "Emitter " << e.getName()
             << " increased number by " << by << std::endl;
}

then bind the first parameter like this.

emitter.signal_number_changed.connect(
   sigc::bind<0>(
      sigc::mem_fun(*this, &Receiver::_handleNumberChange),
      emitter)
   )
);

sigc::bind takes an integer template parameter that tells it the zero-based index of the parameter it should bind. 0 denotes the first parameter, and the default value of -1 tells it to bind the last parameter. After this, your handler function will be called with the first parameter holding a reference to the object that emitted the signal.

Hiding parameters

Let's say you only want to print the name of the emitter and the current number, and you're not interested in how much it increased. You can then hide some of the signal's parameters.

void _handleNumberChange(Emitter &e) {
   std::cout << "Emitter << e.getName() << " changed its number." << std::endl;
}
emitter.signal_number_changed.connect(
   sigc::hide(
      sigc::bind<0>(
         sigc::mem_fun(*this, &Receiver::_handleNumberChange),
         emitter)
      )
   )
);

sigc::hide takes a template parameter just like sigc::bind. Here we use its default value. Also notice that you can easily chain adaptors. This powerful technique allows you to bind handlers to signals even if they weren't designed for each other.

Using the functors sigc::bind_return and sigc::hide_return you can also manipulate the return type. The first one changes to return type of the slot to that of a constant bound parameter. The second ignores the return value and create a slot returning void.

Note that there is an inversion of meaning: you use sigc::bind to supply something that isn't present in the signal, and sigc::hide to ignore something that isn't needed by the slot. With return adaptors, it's the other way around: you use sigc::bind_return to supply something that isn't present in the slot, and sigc::hide_return to ignore something that isn't needed by the signal.

Advanced: reordering parameters

Another powerful type of adaptor is sigc::group. It allows you to execute arbitrary mathematical expressions on the parameter list before pasing it to the handler. Here is an example.

class SomeGeometricObject {
...
   sigc::signal<
      void,
      Geom::Point const & /* old_position */,
      Geom::point const & /* new_position */ > signal_position_changed;
void _handlePositionChange(Geom::Point const &delta) {
   std::cout << "Object moved by"
             << ": X=" << delta[Geom::X]
             << ", Y=" << delta[Geom::Y] << std::endl;
}
some_object.signal_position_changed.connect(
   sigc::group(
      sigc::mem_fun(*this, &Receiver::_handlePositionChange),
      sigc::_2 - sigc::_1
   )
);

The objects sigc::_1 and sigc::_2 are called lambda expressions, and are used to denote the first and second parameter of the signal. The expression will be evaluated at slot call time, thanks to operator overloading. Note however that if you use variables other than lambdas, their values will be evaluated at connection time. To use references to variables instead of their current values, use sigc::ref.

Return values of signals

Signals can return a value. By default, they return the value returned from the last evaluated slot, or the default value for this return type of there was none (normally zero for numerical types, NULL for pointers, false for booleans, etc.).

signal<double> signal_get_length;
double length_from_last_slot = signal_get_length.emit();

This is not ideal, because you lose data from other slots. libsigc++ provides a powerful mechanism for handling return values from multiple slots, called accumulators.

Accumulators

Accumulator is a small class template that defines a signal's return type and a call function that evaluates the slot list of a signal. Here is an example accumulator that calculates the sum of the values returned by slots.

template<class T>
struct sum_accumulator {
   typedef T result_type;
   template<class I>
   result_type operator()(I first, I last) {
      T sum = 0;
      for (; first != last; first ++) sum += *first;
      return sum;
   }

};

There a few important things to notice:

  1. Each accumulator must have two public members: a type called result_type which defines the return value of the signal's emit() function, and an overloaded operator(), taking two arguments of some type (the type in question is called sigc::internal::slot_iterator_buf, but it's better to just use a template).
  2. The parameters of the function call operator are two iterators, pointing to the start and the end of the slot list. The expression *i evaluates the slot (i.e. calls it and returns the result).
  3. It is safe to write things like sum += (*i) * (*i); because the results are buffered - each slot is evaluated at most once in each signal emission.

To use the accumulator, you need to changed the signal definition a bit.

sigc::signal<double>::accumulated<sum_accumulator<int> > signal_get_length;
double sum_of_lengths = signal_get_length.emit();

Now sum_of_lengths contains the sum of return values from all the slots.

WARNING: There is an old bug in libsigc++ that makes accumulators severely broken, because their result type must be implicitly convertible to and from the slot return type. See this GNOME bug. It will be fixed in the next libsigc++ release. --Tweenk 09:27, 31 December 2009 (UTC)

Conventions and tips

  • Your signals should only have parameters that can change with each invocation. For example, a position change signal could have the old and new positions as the parameters. In particular, signals should not have the emitting object as a parameter. The proper way to connect to a signal from a different object is to use sigc::bind like this:
class SomeObject {
public:
   sigc::signal<void> signal_update;
...
};
class OtherObject {
...
   void onUpdate(SomeObject &so);
};
...
some_object.signal_update.connect(
   sigc::bind<0>(
       sigc::mem_fun(other_object, &OtherObject::onUpdate),
       some_object));

In the example, a reference is bound (because the SomeObject parameter will never need to be NULL), but you can also use a pointer.

  • Use the C++ wrappers for GTK+ as a reference for good signal parameter choices.
  • The memory and performance overhead of adding signals to an object is quite small - most things are handled at compile time thanks to the use of templates.
  • When using Boost shared pointers, you cannot bind them as the "emitting object" parameter to slots. That's because the shared pointer will be copied into the object's slot list, keeping it in memory indefinitely. You need to use a weak pointer, which won't prevent the object's desctruction, but can be used to obtain a regular shared pointer. See Shared pointers for a more detailed guide.

See Also

libsigc++ reference documentation Gnome bug #586436 - epic accumulator fail