Skip to content

A fork of a simple C++14 signal-slots implementation, updated to C++20

License

Notifications You must be signed in to change notification settings

mousebyte/sigslot20

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Sigslot20, a signal-slot library

GitHub Workflow Status

Sigslot20 is a C++20 fork of palacaze/sigslot with a few added features. It's a header-only, thread safe implementation of signal-slots for C++. Since C++20 support isn't yet widely used in production environments, this is more of an experiment than anything else; however, the unit tests are working fine and Sigslot20 should be compatible with code that depends on the original (as long as it doesn't go poking around in the detail namespaces and such).

To-Do

  • Replace SFINAE with C++20 concepts
  • Add signal_interface class
  • Allow group type to be parameterized
  • Allow blocking by group
  • Add new unit tests

In This Readme

Differences from sigslot

Sigslot20 takes advantage of some C++20 features, such as concepts. There are also a couple of new or changed features in the library.

Signal Interface

Sigslot20 adds a new class, signal_interface, which wraps a signal and provides access control to a template friend. This is useful for UI classes, which can expose a public member signal_interface that can only be called or blocked from within the class. Two type aliases are provided for convenience: signal_ix_st (which wraps a signal_st) and signal_ix (which wraps a signal).

Example:

#include <sigslot/signal.hpp>

class sig_owner {
    sigslot::signal<float> _privateSig;
public:
    sigslot::signal_ix<sig_owner, int> IntSignal; // signals can use their own internal signal object
    sigslot::signal_ix<sig_owner, flot> FloatSignal;
    sig_owner() : FloatSignal(&_privateSig) { }   // or take a pointer to an existing signal
    
    void do_stuff() {
        FloatSignal(3.0f); // can access private call operator and blocking function from here
    }
};

void foo() {
    sig_owner o;
    
    // functions outside sig_owner can connect and disconnect
    o.IntSignal.connect([](int) {
        // do stuff
    });
    o.IntSignal.disconnect(3);
    
    // but can't access the private call operators or blocking functions
    //o.IntSignal(5);
    //o.IntSignal.block();
}

Custom group type

In Sigslot20, the group type used to organize slots is customizable. In order for a type T to be considered a valid group type, it must be default constructible, copy constructible, and the expressions t1 == t2 and t1 < t2 (Where t1 and t2 are objects of type T) must be valid and the result must be convertible to bool.

The default type for signal groups is int32_t. A signal's group type can be customized using the appropriate template alias: signal_g and signal_g_st for signals, and signal_ix_g and signal_ix_g_st for signal interfaces.

Example:

#include <sigslot/signal.hpp>

// signal grouped by char
// takes a bool as an argument
sigslot::signal_g<char, bool> sig1;

struct foo {
    // signal interface grouped by a custom type
    // takes an int and a string as arguments
    sigslot::signal_ix_g<foo, my_custom_type, int, std::string> Sig2;
};

Block and unblock by group

Signals can now be blocked and unblocked by group from the signal object. For signal interfaces, this is only available to the owning class.

Example:

#include <sigslot/signal.hpp>

int main() {
    sigslot::signal<int> sig;
    int sum;

    sig.connect([&](int v) {
        sum += v;
    }, 1);
    sig.connect([](int v) {
        sum += v * 2;
    }, 2);

    sum = 0;
    sig.block(2);

    // group 2 now blocked, sum will be set to 3
    sig(3);

    sum = 0;
    sig.unblock(2);

    // group 2 now unblocked, sum will be set to 9
    sig(3);

}

Features

The main goal was to replace Boost.Signals2.

Apart from the usual features, it offers

  • Thread safety,
  • Object lifetime tracking for automatic slot disconnection (extensible through ADL),
  • RAII connection management,
  • Slot groups to enforce slots execution order,
  • Reasonable performance. and a simple and straightforward implementation.

Sigslot20 is unit-tested and should be reliable and stable enough to replace Boost Signals2.

The tests run cleanly under the address, thread and undefined behaviour sanitizers.

From palacaze/sigslot on signal return types:

Many implementations allow signal return types, Sigslot does not because I have no use for them. If I can be convinced of otherwise I may change my mind later on.

I generally agree with this sentiment and have no intention of adding them in this fork.

Installation

No compilation or installation is required, just include sigslot/signal.hpp and use it. Sigslot20 depends on a compiler with C++20 concepts support. It should work on modern variants of GCC, Clang, and MSVC. If you'd like to help provide info about specific compiler version support, feel free to open an issue. Make sure to build the tests target and include the results in the issue. Some of the CMake configuration probably needs to be updated. I'm not the most adept with CMake, but I will try to update it as I learn more or as issues/PRs come in.

Currently I've tested Sigslot20 with Clang 11 on Mingw 64. Github Actions have been set up to test on GCC 10.2.0 and Clang 10.0.0 (Linux) and clang-cl (Windows).

Be aware of a potential gotcha on Windows with MSVC and Clang-Cl compilers, which may need the /OPT:NOICF linker flags in exceptional situations. Read The Implementation Details chapter for an explanation.

A CMake list file is supplied for installation purpose and generating a CMake import module. This is the preferred installation method. The Pal::Sigslot imported target is available and already applies the needed linker flags. It is also required for examples and tests, which optionally depend on Qt5 and Boost for adapters unit tests.

# Using Sigslot from cmake
find_package(PalSigslot)

add_executable(MyExe main.cpp)
target_link_libraries(MyExe PRIVATE Pal::Sigslot)

A configuration option SIGSLOT_REDUCE_COMPILE_TIME is available at configuration time. When activated, it attempts to reduce code bloat by avoiding heavy template instantiations resulting from calls to std::make_shared. This option is off by default, but can be activated for those who wish to favor code size and compilation time at the expanse of slightly less efficient code.

Installation may be done using the following instructions from the root directory:

mkdir build && cd build
cmake .. -DSIGSLOT_REDUCE_COMPILE_TIME=ON -DCMAKE_INSTALL_PREFIX=~/local
cmake --build . --target install

# If you want to compile examples:
cmake --build . --target examples

# And compile/execute unit tests:
cmake --build . --target tests

Documentation

Sigslot implements the signal-slot construct popular in UI frameworks, making it easy to use the observer pattern or event-based programming. The main entry point of the library is the sigslot::signal<T...> class template.

A signal is an object that can emit typed notifications, really values parametrized after the signal class template parameters, and register any number of notification handlers (callables) of compatible argument types to be executed with the values supplied whenever a signal emission happens. In signal-slot parlance this is called connecting a slot to a signal, where a "slot" represents a callable instance and a "connection" can be thought of as a conceptual link from signal to slot.

All the snippets presented below are available in compilable source code form in the example subdirectory.

Basic usage

Here is a first example that showcases the most basic features of the library.

We first declare a parameter-free signal sig, then we proceed to connect several slots and at last emit a signal which triggers the invocation of every slot callable connected beforehand. Notice how The library handles diverse forms of callables.

#include <sigslot/signal.hpp>
#include <iostream>

void f() { std::cout << "free function\n"; }

struct s {
    void m() { std::cout << "member function\n"; }
    static void sm() { std::cout << "static member function\n";  }
};

struct o {
    void operator()() { std::cout << "function object\n"; }
};

int main() {
    s d;
    auto lambda = []() { std::cout << "lambda\n"; };
    auto gen_lambda = [](auto && ...a) { std::cout << "generic lambda\n"; };

    // declare a signal instance with no arguments
    sigslot::signal<> sig;

    // connect slots
    sig.connect(f);
    sig.connect(&s::m, &d);
    sig.connect(&s::sm);
    sig.connect(o());
    sig.connect(lambda);
    sig.connect(gen_lambda);

    // emit a signal
    sig();
}

By default, the slot invocation order when emitting a signal is unspecified, please do not rely on it being always the same. You may constrain a particular invocation order by using slot groups, which are presented later on.

Signal with arguments

That first example was simple but not so useful, let us move on to a signal that emits values instead. A signal can emit any number of arguments, below.

#include <sigslot/signal.hpp>
#include <iostream>
#include <string>

struct foo {
    // Notice how we accept a double as first argument here.
    // This is fine because float is convertible to double.
    // 's' is a reference and can thus be modified.
    void bar(double d, int i, bool b, std::string &s) {
        s = b ? std::to_string(i) : std::to_string(d);
    }
};

// Function objects can cope with default arguments and overloading.
// It does not work with static and member functions.
struct obj {
    void operator()(float, int, bool, std::string &, int = 0) {
        std::cout << "I was here\n";
    }

    void operator()() {}
};

int main() {
    // declare a signal with float, int, bool and string& arguments
    sigslot::signal<float, int, bool, std::string&> sig;

    // a generic lambda that prints its arguments to stdout
    auto printer = [] (auto a, auto && ...args) {
        std::cout << a;
        (void)std::initializer_list<int>{
            ((void)(std::cout << " " << args), 1)...
        };
        std::cout << "\n";
    };

    // connect the slots
    foo ff;
    sig.connect(printer);
    sig.connect(&foo::bar, &ff);
    sig.connect(obj());

    float f = 1.f;
    short i = 2;  // convertible to int
    std::string s = "0";

    // emit a signal
    sig(f, i, false, s);
    sig(f, i, true, s);
}

As shown, slots arguments types don't need to be strictly identical to the signal template parameters, being convertible-from is fine. Generic arguments are fine too, as shown with the printer generic lambda (which could have been written as a function template too).

Right now there are two limitations that I can think of with respect to callable handling: default arguments and function overloading. Both are working correctly in the case of function objects but will fail to compile with static and member functions, for different but related reasons.

Coping with overloaded functions

Consider the following piece of code:

struct foo {
    void bar(double d);
    void bar();
};

What should &foo::bar refer to? As per overloading, this pointer over member function does not map to a unique symbol, so the compiler won't be able to pick the right symbol. One way of resolving the right symbol is to explicitly cast the function pointer to the right function type. Here is an example that does just that using a little helper tool for a lighter syntax (In fact I will probably add this to the library soon).

#include <sigslot/signal.hpp>

template <typename... Args, typename C>
constexpr auto overload(void (C::*ptr)(Args...)) {
    return ptr;
}

template <typename... Args>
constexpr auto overload(void (*ptr)(Args...)) {
    return ptr;
}

struct obj {
    void operator()(int) const {}
    void operator()() {}
};

struct foo {
    void bar(int) {}
    void bar() {}

    static void baz(int) {}
    static void baz() {}
};

void moo(int) {}
void moo() {}

int main() {
    sigslot::signal<int> sig;

    // connect the slots, casting to the right overload if necessary
    foo ff;
    sig.connect(overload<int>(&foo::bar), &ff);
    sig.connect(overload<int>(&foo::baz));
    sig.connect(overload<int>(&moo));
    sig.connect(obj());

    sig(0);

    return 0;
}

Coping with function with default arguments

Default arguments are not part of the function type signature, and can be redefined, so they are really difficult to deal with. When connecting a slot to a signal, the library determines if the supplied callable can be invoked with the signal argument types, but at this point the existence of default function arguments is unknown so there might be a mismatch in the number of arguments.

A simple work around for this use case would is to create a bind adapter, in fact we can even make it quite generic like so:

#include <sigslot/signal.hpp>

#define ADAPT(func) \
    [=](auto && ...a) { (func)(std::forward<decltype(a)>(a)...); }

void foo(int &i, int b = 1) {
    i += b;
}

int main() {
    int i = 0;

    // fine, all the arguments are handled
    sigslot::signal<int&, int> sig1;
    sig1.connect(foo);
    sig1(i, 2);

    // must wrap in an adapter
    i = 0;
    sigslot::signal<int&> sig2;
    sig2.connect(ADAPT(foo));
    sig2(i);

    return 0;
}

Connection management

Connection object

What was not made apparent until now is that signal::connect() actually returns a sigslot::connection object that may be used to manage the behaviour and lifetime of a signal-slot connection. sigslot::connection is a lightweight object (basically a std::weak_ptr) that allows interaction with an ongoing signal-slot connection and exposes the following features:

  • Status querying, that is testing whether a connection is valid, ongoing or facing destruction,
  • Connection (un)blocking, which allows to temporarily disable the invocation of a slot when a signal is emitted,
  • Disconnection of a slot, the destruction of a connection previously created via signal::connect().

A sigslot::connection does not tie a connection to a scope: this is not a RAII object, which explains why it can be copied. It can be however implicitly converted into a sigslot::scoped_connection which destroys the connection when going out of scope.

Here is an example illustrating some of those features:

#include <sigslot/signal.hpp>
#include <string>

int i = 0;

void f() { i += 1; }

int main() {
    sigslot::signal<> sig;

    // keep a sigslot::connection object
    auto c1 = sig.connect(f);

    // disconnection
    sig();  // i == 1
    c1.disconnect();
    sig();  // i == 1

    // scope based disconnection
    {
        sigslot::scoped_connection sc = sig.connect(f);
        sig();  // i == 2
    }

    sig();  // i == 2;


    // connection blocking
    auto c2 = sig.connect(f);
    sig();  // i == 3
    c2.block();
    sig();  // i == 3
    c2.unblock();
    sig();  // i == 4
}

Extended connection signature

Sigslot supports an extended slot signature with an additional sigslot::connection reference as first argument, which permits connection management from inside the slot. This extended signature is accessible using the connect_extended() method.

#include <sigslot/signal.hpp>

int main() {
    int i = 0;
    sigslot::signal<> sig;

    // extended connection
    auto f = [](auto &con) {
        i += 1;             // do work
        con.disconnect();   // then disconnects
    };

    sig.connect_extended(f);
    sig();  // i == 1
    sig();  // i == 1 because f was disconnected
}

Automatic slot lifetime tracking

The user must make sure that the lifetime of a slot exceeds the one of a signal, which may get tedious in complex software. To simplify this task, Sigslot can automatically disconnect slot object whose lifetime it is able to track. In order to do that, the slot must be convertible to a weak pointer of some form.

std::shared_ptr and std::weak_ptr are supported out of the box, and adapters are provided to support boost::shared_ptr, boost::weak_ptr and Qt QSharedPointer, QWeakPointer and any class deriving from QObject.

Other trackable objects can be added by declaring a to_weak() adapter function.

#include <sigslot/signal.hpp>
#include <sigslot/adapter/qt.hpp>

int sum = 0;

struct s {
    void f(int i) { sum += i; }
};

class MyObject : public QObject {
    Q_OBJECT
public:
    void add(int i) const { sum += i; }
};

int main() {
    sum = 0;
    signal<int> sig;

    // track lifetime of object and also connect to a member function
    auto p = std::make_shared<s>();
    sig.connect(&s::f, p);

    sig(1);     // sum == 1
    p.reset();
    sig(1);     // sum == 1

    // track an unrelated object lifetime
    struct dummy;
    auto l = [&](int i) { sum += i; };

    auto d = std::make_shared<dummy>();
    sig.connect(l, d);
    sig(1);     // sum == 2
    d.reset();
    sig(1);     // sum == 2

    // track a QObject
    {
        MyObject o;
        sig.connect(&MyObject::add, &o);

        sig(1); // sum == 3
    }

    sig(1);     // sum == 3
}

Intrusive slot lifetime tracking

Another way of ensuring automatic disconnection of pointer over member functions slots is by explicitly inheriting from sigslot::observer or sigslot::observer_st. The former is thread-safe, contrary to the later.

Here is an example usage.

#include <sigslot/signal.hpp>

int sum = 0;

struct s : sigslot::observer_st {
    void f(int i) { sum += i; }
};

struct s_mt : sigslot::observer {
    ~s_mt() {
        // Needed to ensure proper disconnection prior to object destruction
        // in multithreaded contexts.
        this->disconnect_all();
    }
    
    void f(int i) { sum += i; }
};

int main() {
    sum = 0;
    signal<int> sig;
    
    {
        // Lifetime of object instance p is tracked
        s p;
        s_mt pm;
        sig.connect(&s::f, p);
        sig.connect(&s_mt::f, pm);
        sig(1);     // sum == 2
    }
    
    // The slots got disconnected at instance destruction
    sig(1);         // sum == 1
}

The objects that use this intrusive approach may be connected to any number of unrelated signals.

Disconnection without a connection object

Support for slot disconnection by supplying an appropriate function signature, object pointer or tracker has been introduced in version 1.2.0.

One can disconnect any number of slots using the signal::disconnect() method, which proposes 4 overloads to specify the disconnection criterion:

  • The first takes a reference to a callable. Any kind of callable can be passed, even pointers to member functions, function objects and lambdas,
  • The second takes a pointer to an object, for slots bound to a pointer to member function, or a tracking object,
  • The third overload takes both kinds of arguments at the same time and can be used to pinpoint a specific pair of object + callable.
  • The last overload takes a group id and disconnects all the slots in this group.

Disconnection of lambdas is only possible for lambdas bound to a variable, due to their uniqueness.

The second overload currently needs RTTI to disconnect from pointers to member functions, function objects and lambdas. This limitation does not apply to free and static member functions. The reasons stems from the fact that in C++, pointers to member functions of unrelated types are not comparable, contrary to pointers to free and static member functions. For instance, the pointer to member functions of virtual methods of different classes can have the same address (they kind of store the offset of the method into the vtable).

However, Sigslot can be compiled with RTTI disabled and the overload will be deactivated for problematic cases.

As a side node, this feature admittedly added more code than anticipated at first because it is a tricky and easy to get wrong. It has been designed carefully, with correctness in mind, and does not have any hidden costs unless you actually use it.

Here is an example demonstrating the feature.

#include <sigslot/signal.hpp>
#include <string>

static int i = 0;

void f1() { i += 1; }
void f2() { i += 1; }

struct s {
    void m1() { i += 1; }
    void m2() { i += 1; }
    void m3() { i += 1; }
};

struct o {
    void operator()() { i += 1; }
};

int main() {
    sigslot::signal<> sig;
    s s1;
    auto s2 = std::make_shared<s>();

    auto lbd = [&] { i += 1; };

    sig.connect(f1);           // #1
    sig.connect(f2);           // #2
    sig.connect(&s::m1, &s1);  // #3
    sig.connect(&s::m2, &s1);  // #4
    sig.connect(&s::m3, &s1);  // #5
    sig.connect(&s::m1, s2);   // #6
    sig.connect(&s::m2, s2);   // #7
    sig.connect(o{});          // #8
    sig.connect(lbd);          // #9

    sig();  // i == 9

    sig.disconnect(f2);              // #2 is removed
    sig.disconnect(&s::m1);          // #3 and #6 are removed
    sig.disconnect(o{});             // #8 and is removed
 // sig.disconnect(&o::operator());  // same as the above, more efficient
    sig.disconnect(lbd);             // #9 and is removed
    sig.disconnect(s2);              // #7 is removed
    sig.disconnect(&s::m3, &s1);     // #5 is removed, not #4

    sig();  // i == 11

    sig.disconnect_all();         // remove all remaining slots
    return 0;
}

Enforcing slot invocation order with slot groups

From version 1.2.0, slots can be assigned a group id in order to control the relative order of invocation of slots.

The order of invocation of slots in a same group is unspecified and should not be relied upon, however slot groups are invoked in ascending group id order. When the group id of a slot is not set, it is assigned to the group 0. Group ids can have any value in the range of signed 32 bit integers.

#include <sigslot/signal.hpp>
#include <cstdio>
#include <limits>

int main() {
    sigslot::signal<> sig;

    // simply assigning a group id as last argument to connect
    sig.connect([] { std::puts("Second"); }, 1);
    sig.connect([] { std::puts("Last"); }, std::numeric_limits<sigslot::group_id>::max());
    sig.connect([] { std::puts("First"); }, -10);
    sig();

    return 0;
}

Thread safety

Thread safety is unit-tested. In particular, cross-signal emission and recursive emission run fine in a multiple threads scenario.

sigslot::signal is a typedef to the more general sigslot::signal_base template class, whose first template argument must be a Lockable type. This type will dictate the locking policy of the class.

Sigslot offers 2 typedefs,

  • sigslot::signal usable from multiple threads and uses std::mutex as a lockable. In particular, connection, disconnection, emission and slot execution are thread safe. It is also safe with recursive signal emission.
  • sigslot::signal_st is a non thread-safe alternative, it trades safety for slightly faster operation.

Implementation details

Using function pointers to disconnect slots

Comparing function pointers is a nightmare in C++. Here is a table demonstrating the size and address of a variety of cases as a showcase:

void fun() {}

struct b1 {
    virtual ~b1() = default;
    static void sm() {}
    void m() {}
    virtual void vm() {}
};

struct b2 {
    virtual ~b2() = default;
    static void sm() {}
    void m() {}
    virtual void vm() {}
};

struct c {
    virtual ~c() = default;
    virtual void w() {}
};

struct d : b1 {
    static void sm() {}
    void m() {}
    void vm() override {}
};

struct e : b1, c {
    static void sm() {}
    void m() {}
    void vm() override{}
};
Symbol GCC 9 Linux 64
Sizeof
GCC 9 Linux 64
Address
MSVC 16.6 32
Sizeof
MSVC 16.6 32
Address
GCC 8 Mingw 32
Sizeof
GCC 8 Mingw 32
Address
Clang-cl 9 32
Sizeof
Clang-cl 9 32
Address
fun 8 0x802340 4 0x1311A6 4 0xF41540 4 0x0010AE
&b1::sm 8 0xE03140 4 0x7612A5 4 0x308D40 4 0x0010AE
&b1::m 16 0xF03240 4 0x1514A5 8 0x248D40 4 0x0010AE
&b1::vm 16 0x11 4 0x9F11A5 8 0x09 4 0x8023AE
&b2::sm 8 0x003340 4 0xA515A5 4 0x408D40 4 0x0010AE
&b2::m 16 0x103440 4 0xEB10A5 8 0x348D40 4 0x0010AE
&b2::vm 16 0x11 4 0x6A14A5 8 0x09 4 0x8023AE
&d::sm 8 0x203440 4 0x2612A5 4 0x108D40 4 0x0010AE
&d::m 16 0x303540 4 0x9D13A5 8 0x048D40 4 0x0010AE
&d::vm 16 0x11 4 0x4412A5 8 0x09 4 0x8023AE
&e::sm 8 0x403540 4 0xF911A5 4 0x208D40 4 0x0010AE
&e::m 16 0x503640 8 0x8111A5 8 0x148D40 8 0x0010AE
&e::vm 16 0x11 8 0xA911A5 8 0x09 8 0x8023AE

MSVC and Clang-cl in Release mode optimize functions with the same definition by merging them. This is a behaviour that can be deactivated with the /OPT:NOICF linker option. Sigslot tests and examples rely on a lot a identical callables which trigger this behaviour, which is why it deactivates this particular optimization on the affected compilers.

Known bugs

Using generic lambdas with GCC less than version 7.4 can trigger Bug #68071.

About

A fork of a simple C++14 signal-slots implementation, updated to C++20

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages

  • C++ 91.3%
  • CMake 8.7%