C++ Signals

2020-02-15

 

While working on one of my current hobby projects I needed to broadcast a state change to more than just one observer. I was quick to add my rather trusted EventEmitter. But I did hesitate, because something about the EventEmitter always bothered me.

The problem with EventEmitter is that, although EventEmitter is type safe, the type safety can only be asserted during runtime. A bit like JavaScript, where it comes from, assigning wrong typed handlers will throw an exception when the caller is actually called.

enum 
{
    FOO_EVENT
};

EventEmitter emitter;
emitter.on(FOO_EVENT, [] (int c) {
    // do something
});

long change_value = 42;
emitter.emit(FOO_EVENT, change_value); // exception

This was always annoying, since there are so many different types that are effectively the same, but the compiler really things are different. For example, the types size_t, int and long can be the same type, and yet the compiler thinks they are not.

The API of EventEmitter becomes event more frustrating, since modern compilers allows for auto in almost all contexts. But since the context of the on function is a template that cleverly derives it's arguments from the function arguments. You must either explicitly state your types in the function or as template arguments to the on function.

EventEmitter emitter;
emitter.on(FOO_EVENT, [] (int c) {
    // do something
});
emitter.on<int>(FOO_EVENT, [] (auto c) {
    // do something
});

// this does not compile
emitter.on(FOO_EVENT, [] (auto c) {
    // do something
});

So I looked at signals again. Well there is the venerable sigc++, boost::signal and lsignal to name a few that I know about and looked at. What all these libraries have in common is, they are HUGE for the simple task of event multiplexing. Now, to some extent this is because they are carrying the baggage of needing to support C++98 and that where quite dark times. But to some extent allot of code is dedicated to solve the rather tricky issue of observer dereregistration.

The problem boils down to the following, if the signal outlives it's observer and is triggered, it will crash. To this end a number of mechanics where invented to implement automatic deregistration.

For example the sigc::trackable implements a base class that will notify any signal it was registered to and break the connection. This obviously requires a large number of lists of back pointers and some clever registration logic.

After some consideration I looked at most of my usage patterns and they fall into two categories, either the signal emitter and the observer are destroyed virtually at the same time or the deregistration needs to be synced on something that is not the object's destruction.

And like always, when it's my hobby project and there are not good alternatives, I write my own: rsig

The basic usage is like all other signal libraries:

rsig::signal<unsigned int, unsigned int> processing_signal;

processing_signal.connect([&] (auto done, auto total) {
    auto percent = static_cast<float>(done) / static_cast<float>(total) * 100.0f;
    std::cout << "Handled " << done << " of " << total << " [" << percent << "%]" << std::endl;
});

for (auto i = 0u; i < items.size(); i++)
{
    process_item(items[i]);
    processing_signal.emit(i+1, items.size());
}

If the signal and the handler have similar lifetimes you can register the observer on the signal and that is it. Eny time the signal is emitted it will call the handler.

But life is not so simple, take the following a bit less contrived example. You have a mouse class, a bit like so:

class Mouse
{
public:

    rsig::signal<int, int>& get_move_signal()
    {
        return move_signal;
    }

    void update()
    {
        // get x and y from the OS
        move_signal.emit(x, y);
    }

private:
    rsig::signal<int, int> move_signal;
};

If you want to observe the mouse motion in a game, you would write a player controller a bit like so:

class PlayerController
{
public:
    void activate(Mouse& mouse)
    {
        move_con = mouse.get_move_signal().connect([this] (auto x, auto y) {
            control(x, y);
        });
    }

    void deactivate(Mouse& mouse)
    {
        mouse.get_move_signal().disconnect(move_con);
    }

    void control(int x, int y)
    {
        // magic and unicorns
    }

private:
    rsig::connection move_con;
};

The connection object is a opaque handle to the handler registration. All you need to do is save that handle and pass it to disconnect once you are done with handling events.

The implementation is less than 100 lines of code:

struct connection
{
    size_t id = 0;
    void* signal = nullptr;
};

template <typename... Args>
class signal
{
public:
    signal() = default;
    ~signal() = default;

    connection connect(const std::function<void(Args...)>& fun);

    void disconnect(connection id);

    size_t emit(Args... args) const;

private:
    mutable
    std::mutex mutex;
    size_t last_id = 0;
    std::map<size_t, std::function<void (Args...)>> observers;

    signal(const signal<Args...>&) = delete;
    signal<Args...>& operator = (const signal<Args...>&) = delete;
};

template <typename... Args>
connection signal<Args...>::connect(const std::function<void(Args...)>& fun)
{
    std::scoped_lock<std::mutex> sl(mutex);
    if (!fun)
    {
        throw std::invalid_argument("Signal observer is invalid.");
    }

    auto id = ++last_id;
    observers[id] = fun;
    return {id, this};
}

template <typename... Args>
void signal<Args...>::disconnect(connection id)
{
    if (id.signal != this)
    {
        throw std::invalid_argument("signal::disconnect: mismatched connection");
    }

    std::scoped_lock<std::mutex> sl(mutex);
    auto i = observers.find(id.id);
    if (i == end(observers))
    {
        throw std::runtime_error("No observer with this id.");
    }
    observers.erase(i);
}

template <typename... Args>
size_t signal<Args...>::emit(Args... args) const
{
    std::scoped_lock<std::mutex> sl(mutex);
    for (auto& [id, fun] : observers)
    {
        assert(fun);
        fun(args...);
    }
    return observers.size();
}

If you remove legacy support for the dark ages of C++ and remove automatic deregistration, the code is quite simple. You could even shave off a few lines more if you like to live dangerous and think sanity checks are for the weak.