Dopo essere entrato in contatto con il framework Qt, mi sono sempre chiesto come implementare un meccanismo di signal/slot nell’era del moderno C++.
Wikipedia ci fornisce la sottostante definizione.
[…] a language construct […] which makes it easy to implement the Observer pattern while avoiding boilerplate code.
The concept is that GUI widgets can send signals containing event information which can be received by other controls using special functions known as slots.
– Wikipedia
Tradotto in un lessico più masticabile, il meccanismo di signal/slots consente la comunicazione inter-oggetto basata sugli eventi.
Esistono svariate librerie che implementanto questo pattern di programmazione in maniera eccellente, tuttavia oggi vi fornirò la mia modesta implementazione.
Diagramma UML
Di seguito un semplicissimo diagramma UML che spiega sommariamente la struttura del progetto.
Codice
Essendo una classe template, tutto il codice viene riposto in un header file, per ovvi motivi.
Tutto il codice è interamente commentato per rendere più chiara la lettura.
#ifndef LIBSIGSLOTPP_INCLUDE_LIBSIGSLOTPP_SIGNAL_HPP_
#define LIBSIGSLOTPP_INCLUDE_LIBSIGSLOTPP_SIGNAL_HPP_
#include<functional>#include<memory>namespacesigslotpp{typedefstd::size_tIDType;/**
* @brief Interface ISlotConnection
* It provides a simple interface that identifies the Slot and its main
* connectivity operations.
* ISignal can access private and protected regions dor design purposes.
*/classISlotConnection{private:friendclassISignal;IDTypeid_;///< identifier and index into the signal slots vector.
boolisConnected_;///< slot connection status.
boolisBlocked_;///< slot connection block status.
protected:/**
* @brief Getter for the identifier
* @return id_
*/IDTypeid()constnoexcept{returnid_;}/**
* @brief Getter/Setter for identifier identifier
* @return a reference to id_
*/IDType&id()noexcept{returnid_;}/**
* @brief Clears the slot connection
* Where the real disconnection happens.
* This gets called by the ISlotConnection::disconnect() function, the
* implementation depends by the child subclasses.
*/virtualvoidclear()noexcept=0;public:ISlotConnection()=delete;// not needed but better for compilation error
/**
* @brief Primary constructor
* @post id_ == id
* @post isConnected__ == isConnected
* @post iSBlocked_ == isBlocked
*/explicitISlotConnection(constIDTypeid,constboolisConnected=true,constboolisBlocked=false)noexcept:id_(id),isConnected_(isConnected),isBlocked_(isBlocked){}/**
* @brief Destructor
*/virtual~ISlotConnection(){}public:/**
* @brief Checks the connection status
* Used to see if a slot is still connected to a Signal
*/virtualboolisConnected()constnoexcept{returnisConnected_;}/**
* @brief Checks if the connection is blocked
* Used to temporarily disable slot invocation.
* @return true if the slot invocation is blocked else false.
*/boolisBlocked()constnoexcept{returnisBlocked_;}public:/**
* @brief Blocks the slot invocation
* @post isBlocked_ == true
*/voidblock()noexcept{isBlocked_=true;}/**
* @brief Unblocks the slot invocation
* @post isBlocked_ == false
*/voidunblock()noexcept{isBlocked_=false;}/**
* @brief Disconnects the slot from the signal
* @post isConnected_ == false
*/voiddisconnect(){if(isConnected_){isConnected_=false;clear();}}};/**
* @brief Class Connection
* This class allows interaction with an ongoing signal-slot connection and
* exposes an interface to manipulate and query the status of this connection.
* Note that Connection is not a RAII object, one does not need to hold one
* such object to keep the signal-slot connection alive.
*/classConnection{private:std::weak_ptr<ISlotConnection>slot_;///< the slot to manipulate and query
public:Connection()=delete;// not needed but better for compilation error
/**
* @brief Primary constructor
* @param slot that will be handled by this connection
*/explicitConnection(std::weak_ptr<ISlotConnection>&&slot):slot_(slot){}/**
* @brief Destructor
*/virtual~Connection(){}public:/**
* @brief Checks if this connection is still valid
* To have this information just see if the std::weak_ptr is expired
* @return true if the connection is valid else false
*/boolisValid()constnoexcept{return!slot_.expired();}/**
* @brief Checks if the slot is still connected to its signal
* @return true if the slot is still connected else false
* @see ISlotConnection::isConnected()
*/boolisConnected()constnoexcept{std::shared_ptr<ISlotConnection>d=slot_.lock();returnd&&d->isConnected();}/**
* @brief Checks if the slot connection is blocked
* @return true if the slot connection is blocked else false
* @see ISlotConnection::isBLocked()
*/boolisBlocked()constnoexcept{std::shared_ptr<ISlotConnection>d=slot_.lock();returnd&&d->isBlocked();}/**
* @brief Blocks the slot invocation
* @see ISlotConnection::block()
*/voidblock()noexcept{std::shared_ptr<ISlotConnection>d=slot_.lock();if(d)d->block();}/**
* @brief Unblocks the slot invocation
* @see ISlotConnection::unblock()
*/voidunblock()noexcept{std::shared_ptr<ISlotConnection>d=slot_.lock();if(d)d->unblock();}/**
* @brief Disconnects the slot from its signal
* @see ISlotConnection::disconnect()
*/voiddisconnect(){std::shared_ptr<ISlotConnection>d=slot_.lock();if(d)d->disconnect();}};///////////////////////////////
/**
* @brief Interface ISignal
* It provides a simple interface that identifies the Signal and its essential
* functions.
*/classISignal{protected:/**
* @brief Getter for specified ISlotConnection identifier
* @return id of the specified slot
*/IDTypeidOf(ISlotConnection*constslotPtr)constnoexcept{returnslotPtr->id();}/**
* @brief Getter/Setter for specified ISlotConnection identifier
* @return a reference to id of the specified slot
*/IDType&idOf(ISlotConnection*constslotPtr)noexcept{returnslotPtr->id();}public:/**
* @brief Destructor
*/virtual~ISignal(){}/**
* @brief Disconnect the slot from this signal
* @param slot a pointer to the slot to disconnect from
*/virtualvoiddisconnect(ISlotConnection*constslot)=0;};/**
* @brief Interface ISlot
* It provides a simple interface that identifies the Slot and its core
* invocation functionality.
* @tparam Args... the argument types of the function to call
*/template<typename...Args>classISlot:publicISlotConnection{private:ISignal&signal_;///< reference to the signal
protected:/**
* @brief Clears the slot connection
*/virtualvoidclear()noexceptoverride{signal_.disconnect(this);}/**
* @brief Invokes the function
* Where the function is really invoked.
* This gets called by the ISlot::operator()() function, the
* implementation depends by the child subclasses.
* @param args the arguments of the function to call
*/virtualvoidinvoke(Args...args)=0;public:ISlot()=delete;// not needed but better for compilation error
/**
* @brief Primary constructor
* We have a reference that must be initialized, so no default constructor
* allowed here.
* @param id the slot identifier
* @param signal the reference of the signal connected to this slot
* @post signal_ points to the signal connected to this slot
* @see ISlotConnection
*/ISlot(constIDTypeid,ISignal&signal)noexcept:ISlotConnection(id),signal_(signal){}/**
* @brief Invokes the function
* Invokes or calls the function stored in the slot.
* Take note that i add an extra template here for flexibility and design
* purposes. As mentioned in Signal::emit() we use the signature here.
* At the end it is used std::forward to forward lvalues as either lvalues
* or as rvalues, depending on Params.
* @see Signal::emit()
* @param params the parameters of the function to call
* @tparam ...Params types of the parameters of the function to call
*/template<typename...Params>voidoperator()(Params&&...params){if(ISlotConnection::isConnected()&&!ISlotConnection::isBlocked())invoke(std::forward<Params>(params)...);}};/**
* @brief Class SimpleSlot
* Basic slot that does not track anything an thus it is imperative that what
* is called by the std::function must exceeds the lifetime of signal this
* slot is connected to.
* @tparam Args... the argument types of the function to call
*/template<typename...Args>classSimpleSlotfinal:publicISlot<Args...>{private:std::function<void(Args...)>function_;///< function to be invoked by this slot
protected:/**
* @copydoc ISlot::invoke()
*/voidinvoke(Args...args)overridefinal{function_(std::forward<Args>(args)...);}public:SimpleSlot()=delete;// not needed but better for compilation error
/**
* @brief Primary constructor
* @param id the slot identifier
* @param function to be invoked by this slot
* @param signal the reference to the signal this slot is connected to
* @see ISlot
*/SimpleSlot(constIDTypeid,std::function<void(Args...)>&&function,ISignal&signal)noexcept:ISlot<Args...>(id,signal),function_(std::forward<std::function<void(Args...)>>(function)){}};/**
* @brief Class TrackingSlot
* Tracking slot that tracks an object with a std::weak_ptr, it is auto
* disconnected upon expiration of the std::weak_ptr thus no need to take care
* of complex object lifetime managment.
* @tparam T the type of the object to track
* @tparam Args... the argument types of the function to call
*/template<typenameT,typename...Args>classTrackingSlotfinal:publicISlot<Args...>{private:std::function<void(Args...)>function_;///< function to be invoked by this slot
std::weak_ptr<T>objectPtr_;///< the pointer to the object this slot is tracking
protected:/**
* @copydoc ISlot::invoke()
*/voidinvoke(Args...args)overridefinal{// this is probably useless in signle thread
autosp=objectPtr_.lock();if(!sp){ISlotConnection::disconnect();return;}if(ISlotConnection::isConnected()){function_(args...);}}public:TrackingSlot()=delete;// not needed but better for compilation error
/**
* @brief Primary constructor
* @param id the slot identifier
* @param objectPtr the pointer to the object to track
* @param function to be invoked by this slot
* @param signal the reference to the signal this slot is connected to
* @see ISlot
*/TrackingSlot(constIDTypeid,std::weak_ptr<T>&&objectPtr,std::function<void(Args...)>&&function,ISignal&signal)noexcept:ISlot<Args...>(id,signal),function_(std::forward<std::function<void(Args...)>>(function)),objectPtr_(std::forward<std::weak_ptr<T>>(objectPtr)){}public:/**
* @brief Checks if the connection still there
*/boolisConnected()constnoexceptoverridefinal{return!objectPtr_.expired()&&ISlotConnection::isConnected();}};/**
* @brief Class Signal
* This class manages a collection of ISlots
* @tparam R the return type of the function to call
* @tparam Args... the argument types of the function to call
*/template<typenameR,typename...Args>classSignalfinal:publicISignal{private:std::vector<std::shared_ptr<ISlot<Args...>>>slots_;///< slots connected to this Signal
/**
* @brief Disconnects the specified slot
* @param slot the pointer to the slot to disconnect from
* @post slots_.size() decremented by one
*/voiddisconnect(ISlotConnection*constslot)noexceptoverridefinal{IDTypeindex=idOf(slot);if(!slots_.empty()){std::swap(slots_[index],slots_.back());idOf(slots_[index].get())=index;slots_.pop_back();}}public:/**
* @brief Default constructor
* @post slots_.size() == 0
*/Signal()noexcept:slots_(){}/**
* @brief Destructor
*/virtual~Signal()noexcept{disconnectAll();}/**
* @brief Move constructor
* @see Signal::operator=()
*/Signal(Signal&&other)noexcept:slots_(){*this=std::move(other);}/**
* @brief Move assignment operator
* @return a reference to this
* @post slots_ == other.slots_
*/Signal&operator=(Signal&&other)noexcept{if(this!=&other){slots_=std::move(other.slots_);}return*this;}/**
* @brief Connects a slot to the signal
* Connects a std::function to the signal.
* @param function the signal connects to
* @return a Connection instance for managment purposes
* @post slots_.size() incremented by one
*/Connectionconnect(std::function<R(Args...)>&&function)noexcept{IDTypeid=slots_.size();std::shared_ptr<ISlot<Args...>>slot=std::make_shared<SimpleSlot<Args...>>(id,std::forward<std::function<R(Args...)>>(function),*this);slots_.push_back(slot);returnConnection(std::weak_ptr(slots_[id]));}/**
* @brief Connects and tracks a slot to the signal
* Connects a std::function to the signal but this time the slot is tracked
* by a std::weak_ptr pointing to the object of type T. The purpose is to
* disconnect this slot automatically upon said object destruction.
* @param function the signal connects to
* @param objectPtr pointer to the object to be tracked
* @tparam T the type of the object to track
* @return a Connection instance for managment purposes
* @post slots_.size() incremented by one
*/template<typenameT>Connectionconnect(std::weak_ptr<T>&&objectPtr,std::function<R(Args...)>&&function)noexcept{IDTypeid=slots_.size();std::shared_ptr<ISlot<Args...>>slot=std::make_shared<TrackingSlot<T,Args...>>(id,std::forward<std::weak_ptr<T>>(objectPtr),std::forward<std::function<R(Args...)>>(function),*this);slots_.push_back(slot);returnConnection(std::weak_ptr(slots_[id]));}/**
* @brief Connects and tracks a slot to the signal
* Convenience method to connect a member function.
* Connects a function pointer to the signal.
* The slot is tracked by a std::weak_ptr pointing to the object of type T.
* The purpose is to disconnect this slot automatically upon said object
* destruction.
* @param function pointer the signal connects to
* @param objectPtr pointer to the object to be tracked
* @tparam T the type of the object to track
* @return a Connection instance for managment purposes
* @post slots_.size() incremented by one
*/template<typenameT>Connectionconnect(std::shared_ptr<T>&objectPtr,R(T::*function)(Args...))noexcept{T*constptr=objectPtr.get();returnconnect(std::weak_ptr<T>(objectPtr),[ptr,function](Args...args)->R{return(ptr->*function)(args...);});}/**
* @brief Connects and tracks a slot to the signal
* Convenience method to connect a const member function.
* Connects a function pointer to the signal.
* The slot is tracked by a std::weak_ptr pointing to the object of type T.
* The purpose is to disconnect this slot automatically upon said object
* destruction.
* @param function pointer the signal connects to
* @param objectPtr pointer to the object to be tracked
* @tparam T the type of the object to track
* @return a Connection instance for managment purposes
* @post slots_.size() incremented by one
*/template<typenameT>Connectionconnect(std::shared_ptr<T>&objectPtr,R(T::*function)(Args...)const)noexcept{T*constptr=objectPtr.get();returnconnect(std::weak_ptr<T>(objectPtr),[ptr,function](Args...args)->R{return(ptr->*function)(args...);});}/**
* @brief Disconnects all the slots
* Disconnects all the slots this signal is connected to.
* @post slots_.empty() == true
*/voiddisconnectAll()noexcept{slots_.clear();}/**
* @brief Emits the signal
* All non blocked and connected slot functions will be called
* with supplied arguments.
* Important explanation!
* A template is used here, this is for flexibility and design purposes.
* To this function can be passed rvalues or lvalues but if we use the Args
* type it is basically precluding us to emit the signal when it is a
* different types from Args. Suppose Signal<bool, int, double&> the matched
* function is std::function<bool(int, double&)>. Now i can't emit something
* like emit(5, 5.0) because 5.0 is a non const lvalue.
* @see ISlot::operator()()
* @param params arguments to emit
* @tparam ...Params type of the parameter to emit
*/template<typename...Params>voidemit(Params...params){for(autoconst&it:slots_){it->operator()(params...);}}/**
* @brief Gets the number of connected slots
* @return slots.size()
*/std::size_tconnectedSlots()constnoexcept{returnslots_.size();}};}// namespace sigslotpp
#endif // LIBSIGSLOTPP_INCLUDE_LIBSIGSLOTPP_SIGNAL_HPP_
Utilizzo
Per comprendere come utilizzare la soluzione implementata vi mostro gli unit tests che ho scritto.
conststd::stringff="free function";conststd::stringmf="member function";conststd::stringsmf="static member function";conststd::stringmo="member operator";conststd::stringl="lambda";conststd::stringgl="generic lambda";voidf(){fmt::print("{}\n",ff);}structs{voidm(){fmt::print("{}\n",mf);}staticvoidsm(){fmt::print("{}\n",smf);}};structo{voidoperator()(){fmt::print("{}\n, mo");}};TEST_CASE("slots can be added and removed from the signal"){std::shared_ptr<s>d;autolambda=[](){fmt::print("{}\n",l);};autogen_lambda=[](auto&&...a){fmt::print("{}\n",gl);};sigslotpp::Signal<void>sig;SUBCASE("connecting slots to signal increases connected slots"){autoc1=sig.connect(f);sig.connect(d,&s::m);sig.connect(&s::sm);autoc2=sig.connect(o());sig.connect(lambda);sig.connect(gen_lambda);CHECK(sig.connectedSlots()==6);}SUBCASE("discconnecting all slots from the signal put size to zero"){autoc1=sig.connect(f);autoc2=sig.connect(o());sig.connect(lambda);sig.connect(gen_lambda);sig.disconnectAll();CHECK(sig.connectedSlots()==0);}SUBCASE("disconnecting slots from signal decreases connected slots"){autoc1=sig.connect(f);autoc2=sig.connect(o());c1.disconnect();CHECK(sig.connectedSlots()==1);}}TEST_CASE("signal can be emitted"){intx{0};autolambda=[&x](intvalue){x=x+value;};sigslotpp::Signal<void,int>sig;sig.connect(lambda);sig.emit(5);CHECK(x==5);sig.emit(1);CHECK(x==6);sig.emit(-6);CHECK(x==0);}intsum=0;structx{voidf(inti){sum+=i;}};TEST_CASE("signal can be automatically disconnected"){autoa=std::make_shared<x>();sigslotpp::Signal<void,int>sig;sig.connect(a,&x::f);sig.emit(4);sig.emit(3);sig.emit(-2);CHECK(sum==5);a.reset();sig.emit(9);CHECK(sum==5);CHECK(sig.connectedSlots()==0);}TEST_CASE("signal connection to slot can be blocked"){intx{0};autolambda=[&x](intvalue){x=x+value;};sigslotpp::Signal<void,int>sig;autoc1=sig.connect(lambda);sig.emit(5);CHECK(x==5);c1.block();sig.emit(1);CHECK(x==5);c1.unblock();sig.emit(-6);CHECK(x==-1);}
Analisi
In questione abbiamo le seguenti classi:
ISlotConnection
ISlot
SimpleSlot
TrackingSLot
ISignal
Signal
Connection
Non sento il bisogno di analizzare tutte le classi vista la chiarezza del codice, tuttavia ve ne sono due che meritano una spiegazione accurata ossia TrackingSlot e Connection.
Classe TrackingSlot
Questa classe permette tramite un std::weak_ptr di poter tracciare l’esistenza in memoria di un oggetto specificato.
Per farlo viene richiesto da un punto di vista progettuale l’utilizzo degli smart pointers in particolare di un std::shared_ptr
per istanziare l’oggetto da tracciare.
Quando il weak_ptr non riesce più ad effettuare il lock significa che l’oggetto tracciato ha terminato il suo ciclo di vita.
Questo slot non verrà rimosso in seguito alla fine del ciclo di vita del suddetto oggetto, ma alla prossima richiesta di un emit del signal a cui è connesso.
È doveroso specificare che questa classe non interagisce sul ciclo di vita dell’oggetto tracciato.
Classe Connection
Questa classe non segue il RAII pattern, ma poco importa.
Serve per avere un punto di accesso, l’unico che ho voluto implementare, alla connessione tra signal e slot.
Connection mantiene un riferimento, un weak_ptr, allo slot creato in seguito ad un signal.connect(...).
La classe può operare sulle seguenti funzioni dello slot:
Disconnessione
Blocco/Sblocco della ricezione di un emissione di signal
Verifica dello stato dello slot
È doveroso specificare che il ciclo di vita di questa classe non intacca il ciclo di vita dello slot.
Problemi aperti
Vi sono nell’immediato alcune lacune dal punto di vista funzionale, poichè:
Non è thread safe.
Non posso disconnetermi direttamente dallo slot (si supponga di avere un segnale che deve essere emesso una sola volta).
Overloading dei metodi.
Metodi con valori default.
Non c’è modo di recuperare il valore di ritorno degli slot.
Ora, il punto 1 non lo tengo in considerazione data la complessità della gestione.
Il punto 4 purtroppo non ho idea di come risolverlo.
Il problema 5 nasce solo per motivi progettuali, ho preferito non implementarlo perchè superfluo per lo scopo della classe Signal.
Nella mia implementazione è possibile connetersi a slot con valore di ritorno, che vengono poi trasformati in slot con ritorno void.
Per poter implementare il valore di ritorno servirebbe un vettore di appoggio nel quale salvare il ritorno di ogni slot e mettere a disposizione
dei metodi che permettano di leggere tale vettore. Ovviamente ogni emit sovrascriverà il suddetto vettore.
Problema: disconnesione diretta dallo slot
Questo problema, grazie alla classe Connection, è praticamente di immediata soluzione.
Basta aggiungere una classe ExtendedSlot che abbia come membro una variabile di tipo Connection, cosi da poterla iniettare
all’interno della funzione invoke. Tuttavia c’è un compromesso, ovvero dovremmo disporre di funzioni che abbiano come
parametro un tipo Connection.
template<typename...Args>classExtendedSlotfinal:publicISlot<Args...>{private:std::function<void(Args...)>function_;///< function to be invoked by this slot
Connectionconnection_///< what we need
protected:voidinvoke(Args...args)overridefinal{function_(connection_,std::forward<Args>(args)...);}public:ExtendedSlot(constIDTypeid,std::function<void(Args...)>&&function,ISignal&signal)noexcept:ISlot<Args...>(id,signal),function_(std::forward<std::function<void(Args...)>>(function)){}// not the best but does the job for this implementation.
voidsetConnection(Connectionconnection){connection_=connection}};
Modifichiamo Signal e aggiungiamo un metodo connectExtended.
Abbiamo visto come implementare un meccanismo di signal/slot.
La soluzione fornita presenta ovviamente delle pecche, tuttavia fornisce un’interfaccia di utilizzo molto semplice e priva di “magie”.
Con questa classe potremo ora implementare in maniera chiara e pulita l’observer pattern.
A questo link trovate la cartella di progetto che potrete compilare con CMake.