Sigslot est une implémentation thread-safe d'emplacements de signal pour C++, uniquement en-tête.
L'objectif principal était de remplacer Boost.Signals2.
Outre les fonctionnalités habituelles, il offre
Sigslot est testé unitairement et devrait être suffisamment fiable et stable pour remplacer Boost Signals2.
Les tests s'exécutent proprement sous les désinfectants d'adresse, de thread et de comportement non défini.
De nombreuses implémentations autorisent les types de retour de signal, ce qui n'est pas le cas de Sigslot car je n'en ai aucune utilité. Si je peux être convaincu du contraire, je pourrais changer d’avis plus tard.
Aucune compilation ou installation n'est requise, incluez simplement sigslot/signal.hpp
et utilisez-le. Sigslot dépend actuellement d'un compilateur compatible C++14, mais si nécessaire, il peut être mis à niveau vers C++11. Il est connu pour fonctionner avec les compilateurs Clang 4.0 et GCC 5.0+ sur GNU Linux, MSVC 2017 et versions ultérieures, Clang-cl et MinGW sous Windows.
Cependant, soyez conscient d'un piège potentiel sous Windows avec les compilateurs MSVC et Clang-Cl, qui peuvent avoir besoin des indicateurs de l'éditeur de liens /OPT:NOICF
dans des situations exceptionnelles. Lisez le chapitre Détails de mise en œuvre pour une explication.
Un fichier de liste CMake est fourni à des fins d'installation et de génération d'un module d'importation CMake. Il s'agit de la méthode d'installation préférée. La cible importée Pal::Sigslot
est disponible et applique déjà les indicateurs de l'éditeur de liens nécessaires. Il est également requis pour les exemples et les tests, qui dépendent éventuellement de Qt5 et Boost pour les tests unitaires des adaptateurs.
# Using Sigslot from cmake
find_package (PalSigslot)
add_executable (MyExe main.cpp)
target_link_libraries (MyExe PRIVATE Pal::Sigslot)
Une option de configuration SIGSLOT_REDUCE_COMPILE_TIME
est disponible au moment de la configuration. Lorsqu'il est activé, il tente de réduire la surcharge du code en évitant les instanciations de modèles lourdes résultant des appels à std::make_shared
. Cette option est désactivée par défaut, mais peut être activée pour ceux qui souhaitent privilégier la taille du code et le temps de compilation au détriment d'un code un peu moins efficace.
L'installation peut être effectuée en utilisant les instructions suivantes à partir du répertoire racine :
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 sigslot-examples
# And compile/execute unit tests:
cmake --build . --target sigslot-tests
Pal::Sigslot
peut également être intégré à l'aide de la méthode FetchContent.
include (FetchContent)
FetchContent_Declare(
sigslot
GIT_REPOSITORY https://github.com/palacaze/sigslot
GIT_TAG 19a6f0f5ea11fc121fe67f81fd5e491f2d7a4637 # v1.2.0
)
FetchContent_MakeAvailable(sigslot)
add_executable (MyExe main.cpp)
target_link_libraries (MyExe PRIVATE Pal::Sigslot)
Sigslot implémente la construction signal-slot populaire dans les frameworks d'interface utilisateur, ce qui facilite l'utilisation du modèle d'observateur ou de la programmation basée sur les événements. Le point d'entrée principal de la bibliothèque est le modèle de classe sigslot::signal<T...>
.
Un signal est un objet qui peut émettre des notifications typées, en réalité des valeurs paramétrées après les paramètres du modèle de classe de signal, et enregistrer un nombre quelconque de gestionnaires de notifications (appelables) de types d'arguments compatibles à exécuter avec les valeurs fournies chaque fois qu'une émission de signal se produit. Dans le langage des slots de signal, cela s'appelle connecter un slot à un signal, où un « slot » représente une instance appelable et une « connexion » peut être considérée comme un lien conceptuel entre le signal et le slot.
Tous les extraits présentés ci-dessous sont disponibles sous forme de code source compilable dans le sous-répertoire exemple.
Voici un premier exemple qui présente les fonctionnalités les plus basiques de la bibliothèque.
Nous déclarons d'abord un signal sig
sans paramètre, puis nous procédons à la connexion de plusieurs slots et émettons enfin un signal qui déclenche l'invocation de chaque slot appelable connecté au préalable. Remarquez comment la bibliothèque gère diverses formes d'appelables.
# 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);
// a free connect() function is also available
sigslot::connect (sig, f);
// emit a signal
sig ();
}
Par défaut, l'ordre d'invocation du slot lors de l'émission d'un signal n'est pas spécifié, veuillez ne pas compter sur qu'il soit toujours le même. Vous pouvez contraindre un ordre d'appel particulier en utilisant des groupes d'emplacements, qui sont présentés plus loin.
Ce premier exemple était simple mais pas si utile, passons plutôt à un signal qui émet des valeurs. Un signal peut émettre n'importe quel nombre d'arguments, ci-dessous.
# 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);
}
Comme indiqué, les types d'arguments slots n'ont pas besoin d'être strictement identiques aux paramètres du modèle de signal, être convertibles depuis est très bien. Les arguments génériques conviennent également, comme le montre le lambda générique printer
(qui aurait également pu être écrit comme modèle de fonction).
À l'heure actuelle, je peux penser à deux limitations en ce qui concerne la gestion des appels : les arguments par défaut et la surcharge de fonctions. Les deux fonctionnent correctement dans le cas d'objets fonction mais ne parviendront pas à se compiler avec les fonctions statiques et membres, pour des raisons différentes mais liées.
Considérez le morceau de code suivant :
struct foo {
void bar ( double d);
void bar ();
};
À quoi &foo::bar
devrait-il faire référence ? Conformément à la surcharge, ce pointeur sur la fonction membre ne correspond pas à un symbole unique, le compilateur ne pourra donc pas choisir le bon symbole. Une façon de résoudre le bon symbole consiste à convertir explicitement le pointeur de fonction vers le bon type de fonction. Voici un exemple qui fait exactement cela en utilisant un petit outil d'aide pour une syntaxe plus légère (en fait, je l'ajouterai probablement bientôt à la bibliothèque).
# 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 ;
}
Les arguments par défaut ne font pas partie de la signature du type de fonction et peuvent être redéfinis, ils sont donc très difficiles à gérer. Lors de la connexion d'un slot à un signal, la bibliothèque détermine si l'appelable fourni peut être invoqué avec les types d'arguments du signal, mais à ce stade, l'existence des arguments de fonction par défaut est inconnue, il peut donc y avoir une incohérence dans le nombre d'arguments.
Une solution simple pour ce cas d'utilisation serait de créer un adaptateur de liaison, en fait nous pouvons même le rendre assez générique comme ceci :
# 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 ;
}
Ce qui n'était pas évident jusqu'à présent, c'est que signal::connect()
renvoie en fait un objet sigslot::connection
qui peut être utilisé pour gérer le comportement et la durée de vie d'une connexion signal-slot. sigslot::connection
est un objet léger (essentiellement un std::weak_ptr
) qui permet une interaction avec une connexion signal-slot en cours et expose les fonctionnalités suivantes :
signal::connect()
. Un sigslot::connection
ne lie pas une connexion à une portée : ce n'est pas un objet RAII, ce qui explique pourquoi il peut être copié. Il peut cependant être implicitement converti en un sigslot::scoped_connection
qui détruit la connexion en cas de sortie de portée.
Voici un exemple illustrant certaines de ces fonctionnalités :
# 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
}
Sigslot prend en charge une signature de slot étendue avec une référence sigslot::connection
supplémentaire comme premier argument, ce qui permet la gestion des connexions depuis l'intérieur du slot. Cette signature étendue est accessible via la méthode connect_extended()
.
# 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
}
L'utilisateur doit s'assurer que la durée de vie d'un slot dépasse celle d'un signal, ce qui peut s'avérer fastidieux dans des logiciels complexes. Pour simplifier cette tâche, Sigslot peut automatiquement déconnecter l'objet slot dont il est capable de suivre la durée de vie. Pour ce faire, le slot doit être convertible en un pointeur faible d'une certaine forme.
std::shared_ptr
et std::weak_ptr
sont pris en charge dès le départ, et des adaptateurs sont fournis pour prendre en charge boost::shared_ptr
, boost::weak_ptr
et Qt QSharedPointer
, QWeakPointer
et toute classe dérivée de QObject
.
D'autres objets traçables peuvent être ajoutés en déclarant une fonction d'adaptateur to_weak()
.
# 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
}
Une autre façon d'assurer la déconnexion automatique du pointeur sur les emplacements des fonctions membres consiste à hériter explicitement de sigslot::observer
ou sigslot::observer_st
. Le premier est thread-safe, contrairement au second.
Voici un exemple d'utilisation.
# 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 == 2
}
Les objets qui utilisent cette approche intrusive peuvent être connectés à un nombre illimité de signaux non liés.
La prise en charge de la déconnexion des emplacements en fournissant une signature de fonction appropriée, un pointeur d'objet ou un tracker a été introduite dans la version 1.2.0.
On peut déconnecter n'importe quel nombre de slots en utilisant la signal::disconnect()
, qui propose 4 surcharges pour spécifier le critère de déconnexion :
La déconnexion des lambdas n'est possible que pour les lambdas liés à une variable, en raison de leur unicité.
La deuxième surcharge nécessite actuellement que RTTI se déconnecte des pointeurs vers les fonctions membres, les objets fonction et les lambdas. Cette limitation ne s'applique pas aux fonctions membres libres et statiques. La raison vient du fait qu'en C++, les pointeurs vers des fonctions membres de types non liés ne sont pas comparables, contrairement aux pointeurs vers des fonctions membres libres et statiques. Par exemple, le pointeur vers les fonctions membres de méthodes virtuelles de différentes classes peut avoir la même adresse (ils stockent en quelque sorte le décalage de la méthode dans la table virtuelle).
Cependant, Sigslot peut être compilé avec RTTI désactivé et la surcharge sera désactivée pour les cas problématiques.
En tant que nœud secondaire, cette fonctionnalité a certes ajouté plus de code que prévu au début, car il est difficile et facile de se tromper. Il a été conçu avec soin, dans un souci d’exactitude, et n’entraîne aucun coût caché à moins que vous ne l’utilisiez réellement.
Voici un exemple illustrant la fonctionnalité.
# 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 ;
}
À partir de la version 1.2.0, les slots peuvent se voir attribuer un identifiant de groupe afin de contrôler l'ordre relatif d'invocation des slots.
L'ordre d'invocation des slots dans un même groupe n'est pas spécifié et ne doit pas être pris en compte, cependant les groupes de slots sont invoqués dans l'ordre croissant des identifiants de groupe. Lorsque l'identifiant de groupe d'un emplacement n'est pas défini, il est attribué au groupe 0. Les identifiants de groupe peuvent avoir n'importe quelle valeur dans la plage d'entiers signés de 32 bits.
# 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 ;
}
La fonction autonome sigslot::connect()
peut être utilisée pour connecter un signal à un autre avec des arguments compatibles.
# include < sigslot/signal.hpp >
# include < iostream >
int main () {
sigslot:: signal < int > sig1;
sigslot:: signal < double > sig2;
sigslot::connect (sig1, sig2);
sigslot::connect (sig2, [] ( double d) { std::cout << " got " << d << std::endl; });
sig ( 1 );
return 0 ;
}
La sécurité des threads est testée unitairement. En particulier, l'émission de signaux croisés et l'émission récursive fonctionnent correctement dans un scénario à plusieurs threads.
sigslot::signal
est une définition de type de la classe de modèle sigslot::signal_base
plus générale, dont le premier argument de modèle doit être un type verrouillable. Ce type dictera la politique de verrouillage de la classe.
Sigslot propose 2 typedefs,
sigslot::signal
utilisable à partir de plusieurs threads et utilise std::mutex comme verrouillable. En particulier, la connexion, la déconnexion, l’émission et l’exécution des slots sont thread-safe. Il est également sûr avec l'émission de signaux récursifs.sigslot::signal_st
est une alternative non thread-safe, elle échange la sécurité contre un fonctionnement légèrement plus rapide. Comparer des pointeurs de fonctions est un cauchemar en C++. Voici un tableau démontrant la taille et l'adresse d'une variété de vitrines :
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 {}
};
Symbole | GCC 9 Linux 64 Taille de | GCC 9 Linux 64 Adresse | MSVC 16.6 32 Taille de | MSVC 16.6 32 Adresse | GCC 8 Mingw 32 Taille de | GCC 8 Mingw 32 Adresse | Clang-cl 9 32 Taillede | Clang-cl 9 32 Adresse |
---|---|---|---|---|---|---|---|---|
amusant | 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 :: machine virtuelle | 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 et Clang-cl en mode Release optimisent les fonctions avec la même définition en les fusionnant. Il s'agit d'un comportement qui peut être désactivé avec l'option de l'éditeur de liens /OPT:NOICF
. Les tests et exemples Sigslot s'appuient sur de nombreux callables identiques qui déclenchent ce comportement, c'est pourquoi il désactive cette optimisation particulière sur les compilateurs concernés.
L'utilisation de lambdas génériques avec GCC inférieur à la version 7.4 peut déclencher le bogue n° 68071.